├── .coderabbit.yaml ├── .devcontainer.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── renovate.json └── workflows │ ├── hacs.yaml │ ├── hassfest.yaml │ ├── pythonpackage.yaml │ └── release.yaml ├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── valetudo_vacuum_mapper.iml └── vcs.xml ├── .pre-commit-config.yaml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── NOTICE.txt ├── README.md ├── custom_components ├── __init__.py └── mqtt_vacuum_camera │ ├── NOTICE.txt │ ├── __init__.py │ ├── camera.py │ ├── common.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── hass_types.py │ ├── icons.json │ ├── manifest.json │ ├── options_flow.py │ ├── repairs.py │ ├── sensor.py │ ├── services.yaml │ ├── snapshots │ ├── log_files.py │ └── snapshot.py │ ├── strings.json │ ├── translations │ ├── ar.json │ ├── cz.json │ ├── da.json │ ├── de.json │ ├── en.json │ ├── es.json │ ├── fr.json │ ├── it.json │ ├── jp.json │ ├── nl.json │ ├── no.json │ ├── pl.json │ ├── ru.json │ ├── sv.json │ └── zh-Hant.json │ └── utils │ ├── __init__.py │ ├── camera │ ├── __init__.py │ ├── camera_processing.py │ └── camera_services.py │ ├── connection │ ├── __init__.py │ └── connector.py │ ├── files_operations.py │ ├── fonts │ ├── FiraSans.ttf │ ├── Inter-VF.ttf │ ├── Lato-Regular.ttf │ ├── MPLUSRegular.ttf │ ├── NotoKufiArabic-VF.ttf │ ├── NotoSansCJKhk-VF.ttf │ └── NotoSansKhojki.ttf │ ├── language_cache.py │ ├── room_manager.py │ ├── status_text.py │ ├── thread_pool.py │ └── vacuum │ ├── __init__.py │ └── mqtt_vacuum_services.py ├── docs ├── actions.md ├── auto_zoom.md ├── colours.md ├── croping_trimming.md ├── images_options.md ├── install.md ├── obstacles_detection.md ├── snapshots.md ├── status_text.md └── transparency.md ├── hacs.json ├── repository.json ├── requirements.test.txt ├── scripts ├── develop ├── lint └── setup ├── setup.cfg └── tests ├── __init__.py ├── bandit.yaml ├── conftest.py ├── mqtt_data.raw ├── test_camera.py └── test_config_flow.py /.coderabbit.yaml: -------------------------------------------------------------------------------- 1 | language: en-US 2 | tone_instructions: 'cool' 3 | early_access: false 4 | enable_free_tier: true 5 | reviews: 6 | profile: chill 7 | request_changes_workflow: false 8 | high_level_summary: true 9 | high_level_summary_placeholder: '@coderabbitai summary' 10 | auto_title_placeholder: '@coderabbitai' 11 | review_status: true 12 | poem: false 13 | collapse_walkthrough: false 14 | sequence_diagrams: true 15 | changed_files_summary: true 16 | path_filters: [] 17 | path_instructions: [] 18 | abort_on_close: true 19 | auto_review: 20 | enabled: true 21 | auto_incremental_review: true 22 | ignore_title_keywords: [] 23 | labels: [] 24 | drafts: false 25 | base_branches: [] 26 | tools: 27 | shellcheck: 28 | enabled: true 29 | ruff: 30 | enabled: true 31 | markdownlint: 32 | enabled: true 33 | github-checks: 34 | enabled: true 35 | timeout_ms: 90000 36 | languagetool: 37 | enabled: true 38 | enabled_only: false 39 | level: default 40 | biome: 41 | enabled: true 42 | hadolint: 43 | enabled: true 44 | swiftlint: 45 | enabled: true 46 | phpstan: 47 | enabled: true 48 | level: default 49 | golangci-lint: 50 | enabled: true 51 | yamllint: 52 | enabled: true 53 | gitleaks: 54 | enabled: true 55 | checkov: 56 | enabled: true 57 | detekt: 58 | enabled: true 59 | eslint: 60 | enabled: true 61 | rubocop: 62 | enabled: true 63 | buf: 64 | enabled: true 65 | regal: 66 | enabled: true 67 | actionlint: 68 | enabled: true 69 | pmd: 70 | enabled: true 71 | cppcheck: 72 | enabled: true 73 | semgrep: 74 | enabled: true 75 | chat: 76 | auto_reply: true 77 | knowledge_base: 78 | opt_out: false 79 | learnings: 80 | scope: auto 81 | issues: 82 | scope: auto 83 | jira: 84 | project_keys: [] 85 | linear: 86 | team_keys: [] 87 | pull_requests: 88 | scope: auto 89 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mqtt_vacuum_camera", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "ms-python.python", 18 | "github.vscode-pull-request-github", 19 | "ryanluker.vscode-coverage-gutters", 20 | "ms-python.vscode-pylance" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 4, 25 | "python.pythonPath": "/usr/bin/python3", 26 | "python.analysis.autoSearchPaths": false, 27 | "python.linting.pylintEnabled": true, 28 | "python.linting.enabled": true, 29 | "python.formatting.provider": "black", 30 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 31 | "editor.formatOnPaste": false, 32 | "editor.formatOnSave": true, 33 | "editor.formatOnType": true, 34 | "files.trimTrailingWhitespace": true 35 | } 36 | } 37 | }, 38 | "remoteUser": "vscode", 39 | "features": { 40 | "ghcr.io/devcontainers/features/rust:1": {} 41 | } 42 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Report an issue with MQTT Vacuum Camera 2 | description: Report an issue with MQTT Vacuum Camera. 3 | labels: bug 4 | assignees: 'SCA075' 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | This issue form is for reporting bugs only! 10 | 11 | If you have a question, feature or enhancement request, please use the dedicated form. 12 | - type: checkboxes 13 | id: checklist 14 | attributes: 15 | label: Checklist 16 | options: 17 | - label: I have updated the integration to the latest version available 18 | required: true 19 | - label: I have checked if the problem is already reported 20 | required: true 21 | - type: textarea 22 | validations: 23 | required: true 24 | attributes: 25 | label: The problem 26 | description: >- 27 | Describe the issue you are experiencing here. 28 | - type: markdown 29 | attributes: 30 | value: | 31 | ## Environment 32 | - type: input 33 | id: bug-version 34 | validations: 35 | required: true 36 | attributes: 37 | label: What version of an integration has described problem? 38 | placeholder: vX.X.X 39 | - type: input 40 | id: last-working-version 41 | validations: 42 | required: false 43 | attributes: 44 | label: What was the last working version of an integration? 45 | placeholder: vX.X.X 46 | description: > 47 | If known, otherwise leave blank. 48 | - type: input 49 | id: vacuum-model 50 | validations: 51 | required: true 52 | attributes: 53 | label: What vacuum model do you have problems with? 54 | placeholder: Roborock.V1 55 | - type: input 56 | id: valetudo-firmware-version 57 | validations: 58 | required: false 59 | attributes: 60 | label: Please firmware installed on your Vacuum. 61 | placeholder: yyyy.mm.x 62 | description: > 63 | If known, otherwise leave blank. 64 | - type: dropdown 65 | validations: 66 | required: true 67 | attributes: 68 | label: What type of platform you use? 69 | description: > 70 | As per there are limitations recently reported, please specify the machine were Home Assistant Runs. 71 | 72 | [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) 73 | options: 74 | - Intel NUC (or generic x86_64) 75 | - ARM (Raspberry Pi, Odroid, etc.) < 4GB 76 | - ARM (Raspberry Pi, Odroid, etc.) > 4GB 77 | - VEMLinux (Virtual Machine) such as Proxmox, VMWare, etc. 78 | - type: input 79 | id: ha-version 80 | validations: 81 | required: true 82 | attributes: 83 | label: What version of Home Assistant do you use? 84 | placeholder: core- 85 | description: > 86 | Can be found in: [Configuration panel -> Info](https://my.home-assistant.io/redirect/info/). 87 | 88 | [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) 89 | - type: dropdown 90 | validations: 91 | required: true 92 | attributes: 93 | label: What type of installation are you running? 94 | description: > 95 | Can be found in: [Configuration panel -> Info](https://my.home-assistant.io/redirect/info/). 96 | 97 | [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) 98 | options: 99 | - Home Assistant OS 100 | - Home Assistant Container 101 | - Home Assistant Supervised 102 | - Home Assistant Core 103 | - type: markdown 104 | attributes: 105 | value: | 106 | # Details 107 | - type: textarea 108 | id: logs 109 | attributes: 110 | label: Logs or Errors shown in the HA snapshots (please enable the Debug Mode) text will be auto formatted to code. 111 | render: shell 112 | - type: dropdown 113 | validations: 114 | required: false 115 | attributes: 116 | label: Function, that in your opinion is creating the issue. 117 | description: > 118 | If you know what option is creating the issue, please select it. 119 | options: 120 | - Calibration of the Map 121 | - Colors Configuration 122 | - Image Aspect Ratio 123 | - Logs Export 124 | - Migration Process 125 | - Room Names Advanced Option 126 | - Snapshots Functions 127 | - Vacuum Status Text 128 | - Not sure / none of the above. 129 | - type: textarea 130 | attributes: 131 | label: Additional information 132 | description: > 133 | Please provide additional information's, use the field below. This will help to understand/recreate the issue better. 134 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a question/open a discussion 4 | url: https://github.com/sca075/mqtt_vacuum_camera/discussions 5 | about: To ask a question or open a discussion please use a dedicated section. 6 | - name: Report a bug in the map card (Lovelace Vacuum Map card) 7 | url: https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card/issues 8 | about: This is the issue tracker for the MQTT Camera. Please report issues with the card in its repository. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Request a feature for your MQTT Vacuum Camera 2 | description: Request a feature for your MQTT Vacuum Camera 3 | labels: enhancement 4 | assignees: 'SCA075' 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | This issue form is for feature requests or enhancements only! 10 | 11 | If you have a bug or a question, please use the dedicated form. 12 | - type: textarea 13 | validations: 14 | required: true 15 | attributes: 16 | label: Description 17 | description: >- 18 | A clear and concise description of what should change[...] 19 | - type: textarea 20 | validations: 21 | required: true 22 | attributes: 23 | label: Solution 24 | description: >- 25 | A clear and concise description of what you want to happen. 26 | - type: textarea 27 | validations: 28 | required: false 29 | attributes: 30 | label: Alternatives 31 | description: >- 32 | A clear and concise description of any alternative solutions or features you've considered. 33 | - type: textarea 34 | validations: 35 | required: false 36 | attributes: 37 | label: Context 38 | description: >- 39 | Add any other context about the feature request here. -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | ## Breaking change 6 | 13 | 14 | 15 | ## Proposed change 16 | 22 | 23 | 24 | ## Type of change 25 | 31 | 32 | - [ ] Dependency upgrade 33 | - [ ] Bugfix (non-breaking change which fixes an issue) 34 | - [ ] New feature (which adds functionality to our integration) 35 | - [ ] Deprecation (breaking change to happen in the future) 36 | - [ ] Breaking change (fix/feature causing existing functionality to break) 37 | - [ ] Code quality improvements to existing code or addition of tests 38 | 39 | ## Additional information 40 | 44 | 45 | - This PR fixes or closes issue: fixes # 46 | - This PR is related to issue: 47 | - Link to documentation pull request: 48 | - Link to developer documentation pull request: 49 | - Link to frontend pull request: 50 | 51 | ## Checklist 52 | 58 | 59 | - [ ] The code change is tested and works locally. 60 | - [ ] Local tests pass. **Your PR cannot be merged unless tests pass** 61 | - [ ] There is no commented out code in this PR. 62 | - [ ] The code has been formatted using Ruff (`ruff format`) 63 | - [ ] The code has been been check with pylint. 64 | - [ ] Tests have been added to verify that the new code works. 65 | 66 | If user exposed functionality or configuration variables are added/changed: 67 | 68 | - [ ] Documentation added/updated. 69 | 70 | If the code communicates uses new third-party tools or libraries: 71 | 72 | - [ ] New or updated dependencies have been added to `manifest.json`. 73 | - [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. 74 | - [ ] Ensure your pull request is based on the `dev` branch, not `main`. 75 | 76 | 86 | 87 | 88 | 89 | 92 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | labels: 6 | - "pr: dependency-update" 7 | schedule: 8 | interval: weekly 9 | time: "06:00" 10 | open-pull-requests-limit: 10 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | labels: 14 | - "pr: dependency-update" 15 | schedule: 16 | interval: weekly 17 | time: "06:00" 18 | open-pull-requests-limit: 10 19 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "branchPrefix": "camera_dev/", 3 | "dryRun": "full", 4 | "username": "renovate-release", 5 | "gitAuthor": "Renovate Bot ", 6 | "onboarding": false, 7 | "platform": "github", 8 | "includeForks": true, 9 | "repositories": [ 10 | "renovatebot/github-action", 11 | "renovate-tests/cocoapods1", 12 | "renovate-tests/gomod1" 13 | ], 14 | "packageRules": [ 15 | { 16 | "description": "lockFileMaintenance", 17 | "matchUpdateTypes": [ 18 | "pin", 19 | "digest", 20 | "patch", 21 | "minor", 22 | "major", 23 | "lockFileMaintenance" 24 | ], 25 | "dependencyDashboardApproval": false, 26 | "stabilityDays": 0 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | ignore: "brands" 18 | category: "integration" 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yaml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: "30 16 * * WED" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | max-parallel: 2 13 | matrix: 14 | python-version: ["3.13"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.test.txt 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | COMPONENT_NAME: mqtt_vacuum_camera 9 | 10 | jobs: 11 | release: 12 | name: Prepare release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | id-token: write 17 | steps: 18 | - name: Download repo 19 | uses: actions/checkout@v4.2.2 20 | 21 | - name: Adjust version number 22 | shell: bash 23 | run: | 24 | version="${{ github.event.release.tag_name }}" 25 | yq e -P -o=json \ 26 | -i ".version = \"${version}\"" \ 27 | "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}/manifest.json" 28 | 29 | - name: Zip ${{ env.COMPONENT_NAME }} dir 30 | run: | 31 | cd "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}" 32 | zip ${{ env.COMPONENT_NAME }}.zip -r ./ 33 | 34 | 35 | - name: Upload zip to release 36 | uses: softprops/action-gh-release@v2.2.2 37 | with: 38 | files: ${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}/${{ env.COMPONENT_NAME }}.zip 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | bin/ 162 | lib64 163 | 164 | # Home Assistant configuration 165 | config/ 166 | www/ -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/valetudo_vacuum_mapper.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v2.3.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 19.10b0 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v1.16.0 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=hass,valetudo,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://gitlab.com/pycqa/flake8 25 | rev: 3.8.1 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.5.0 30 | - pydocstyle==5.0.2 31 | files: ^(homeassistant|script|tests)/.+\.py$ 32 | - repo: https://github.com/PyCQA/bandit 33 | rev: 1.6.2 34 | hooks: 35 | - id: bandit 36 | args: 37 | - --quiet 38 | - --format=custom 39 | - --configfile=tests/bandit.yaml 40 | files: ^(homeassistant|script|tests)/.+\.py$ 41 | - repo: https://github.com/pre-commit/mirrors-isort 42 | rev: v4.3.21 43 | hooks: 44 | - id: isort 45 | - repo: https://github.com/pre-commit/pre-commit-hooks 46 | rev: v2.4.0 47 | hooks: 48 | - id: check-executables-have-shebangs 49 | stages: [manual] 50 | - id: check-json 51 | - repo: https://github.com/pre-commit/mirrors-mypy 52 | rev: v0.770 53 | hooks: 54 | - id: mypy 55 | args: 56 | - --pretty 57 | - --show-error-codes 58 | - --show-error-context 59 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Remote Attach", 9 | "type": "python", 10 | "request": "attach", 11 | "connect": { 12 | "host": "localhost", 13 | "port": 5678 14 | }, 15 | "pathMappings": [ 16 | { 17 | "localRoot": "${workspaceFolder}", 18 | "remoteRoot": "." 19 | } 20 | ], 21 | "justMyCode": false 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.ignoreLimitWarning": true, 3 | "files.associations": { 4 | "*.yaml": "home-assistant" 5 | } 6 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | ============== 3 | 4 | _Version 2.0, January 2004_ 5 | _<>_ 6 | 7 | ### Terms and Conditions for use, reproduction, and distribution 8 | 9 | #### 1. Definitions 10 | 11 | “License” shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | “Legal Entity” shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, “control” means **(i)** the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 22 | outstanding shares, or **(iii)** beneficial ownership of such entity. 23 | 24 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | “Source” form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | 31 | “Object” form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object code, 33 | generated documentation, and conversions to other media types. 34 | 35 | “Work” shall mean the work of authorship, whether in Source or Object form, made 36 | available under the License, as indicated by a copyright notice that is included 37 | in or attached to the work (an example is provided in the Appendix below). 38 | 39 | “Derivative Works” shall mean any work, whether in Source or Object form, that 40 | is based on (or derived from) the Work and for which the editorial revisions, 41 | annotations, elaborations, or other modifications represent, as a whole, an 42 | original work of authorship. For the purposes of this License, Derivative Works 43 | shall not include works that remain separable from, or merely link (or bind by 44 | name) to the interfaces of, the Work and Derivative Works thereof. 45 | 46 | “Contribution” shall mean any work of authorship, including the original version 47 | of the Work and any modifications or additions to that Work or Derivative Works 48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 49 | by the copyright owner or by an individual or Legal Entity authorized to submit 50 | on behalf of the copyright owner. For the purposes of this definition, 51 | “submitted” means any form of electronic, verbal, or written communication sent 52 | to the Licensor or its representatives, including but not limited to 53 | communication on electronic mailing lists, source code control systems, and 54 | issue tracking systems that are managed by, or on behalf of, the Licensor for 55 | the purpose of discussing and improving the Work, but excluding communication 56 | that is conspicuously marked or otherwise designated in writing by the copyright 57 | owner as “Not a Contribution.” 58 | 59 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 60 | of whom a Contribution has been received by Licensor and subsequently 61 | incorporated within the Work. 62 | 63 | #### 2. Grant of Copyright License 64 | 65 | Subject to the terms and conditions of this License, each Contributor hereby 66 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 67 | irrevocable copyright license to reproduce, prepare Derivative Works of, 68 | publicly display, publicly perform, sublicense, and distribute the Work and such 69 | Derivative Works in Source or Object form. 70 | 71 | #### 3. Grant of Patent License 72 | 73 | Subject to the terms and conditions of this License, each Contributor hereby 74 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 75 | irrevocable (except as stated in this section) patent license to make, have 76 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 77 | such license applies only to those patent claims licensable by such Contributor 78 | that are necessarily infringed by their Contribution(s) alone or by combination 79 | of their Contribution(s) with the Work to which such Contribution(s) was 80 | submitted. If You institute patent litigation against any entity (including a 81 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 82 | Contribution incorporated within the Work constitutes direct or contributory 83 | patent infringement, then any patent licenses granted to You under this License 84 | for that Work shall terminate as of the date such litigation is filed. 85 | 86 | #### 4. Redistribution 87 | 88 | You may reproduce and distribute copies of the Work or Derivative Works thereof 89 | in any medium, with or without modifications, and in Source or Object form, 90 | provided that You meet the following conditions: 91 | 92 | * **(a)** You must give any other recipients of the Work or Derivative Works a copy of 93 | this License; and 94 | * **(b)** You must cause any modified files to carry prominent notices stating that You 95 | changed the files; and 96 | * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 97 | all copyright, patent, trademark, and attribution notices from the Source form 98 | of the Work, excluding those notices that do not pertain to any part of the 99 | Derivative Works; and 100 | * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 101 | Derivative Works that You distribute must include a readable copy of the 102 | attribution notices contained within such NOTICE file, excluding those notices 103 | that do not pertain to any part of the Derivative Works, in at least one of the 104 | following places: within a NOTICE text file distributed as part of the 105 | Derivative Works; within the Source form or documentation, if provided along 106 | with the Derivative Works; or, within a display generated by the Derivative 107 | Works, if and wherever such third-party notices normally appear. The contents of 108 | the NOTICE file are for informational purposes only and do not modify the 109 | License. You may add Your own attribution notices within Derivative Works that 110 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 111 | provided that such additional attribution notices cannot be construed as 112 | modifying the License. 113 | 114 | You may add Your own copyright statement to Your modifications and may provide 115 | additional or different license terms and conditions for use, reproduction, or 116 | distribution of Your modifications, or for any such Derivative Works as a whole, 117 | provided Your use, reproduction, and distribution of the Work otherwise complies 118 | with the conditions stated in this License. 119 | 120 | #### 5. Submission of Contributions 121 | 122 | Unless You explicitly state otherwise, any Contribution intentionally submitted 123 | for inclusion in the Work by You to the Licensor shall be under the terms and 124 | conditions of this License, without any additional terms or conditions. 125 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 126 | any separate license agreement you may have executed with Licensor regarding 127 | such Contributions. 128 | 129 | #### 6. Trademarks 130 | 131 | This License does not grant permission to use the trade names, trademarks, 132 | service marks, or product names of the Licensor, except as required for 133 | reasonable and customary use in describing the origin of the Work and 134 | reproducing the content of the NOTICE file. 135 | 136 | #### 7. Disclaimer of Warranty 137 | 138 | Unless required by applicable law or agreed to in writing, Licensor provides the 139 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 140 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 141 | including, without limitation, any warranties or conditions of TITLE, 142 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 143 | solely responsible for determining the appropriateness of using or 144 | redistributing the Work and assume any risks associated with Your exercise of 145 | permissions under this License. 146 | 147 | #### 8. Limitation of Liability 148 | 149 | In no event and under no legal theory, whether in tort (including negligence), 150 | contract, or otherwise, unless required by applicable law (such as deliberate 151 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 152 | liable to You for damages, including any direct, indirect, special, incidental, 153 | or consequential damages of any character arising as a result of this License or 154 | out of the use or inability to use the Work (including but not limited to 155 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 156 | any and all other commercial damages or losses), even if such Contributor has 157 | been advised of the possibility of such damages. 158 | 159 | #### 9. Accepting Warranty or Additional Liability 160 | 161 | While redistributing the Work or Derivative Works thereof, You may choose to 162 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 163 | other liability obligations and/or rights consistent with this License. However, 164 | in accepting such obligations, You may act only on Your own behalf and on Your 165 | sole responsibility, not on behalf of any other Contributor, and only if You 166 | agree to indemnify, defend, and hold each Contributor harmless for any liability 167 | incurred by, or claims asserted against, such Contributor by reason of your 168 | accepting any such warranty or additional liability. 169 | 170 | _END OF TERMS AND CONDITIONS_ 171 | 172 | ### APPENDIX: How to apply the Apache License to your work 173 | 174 | To apply the Apache License to your work, attach the following boilerplate 175 | notice, with the fields enclosed by brackets `[]` replaced with your own 176 | identifying information. (Don't include the brackets!) The text should be 177 | enclosed in the appropriate comment syntax for the file format. We also 178 | recommend that a file or class name and description of purpose be included on 179 | the same “printed page” as the copyright notice for easier identification within 180 | third-party archives. 181 | 182 | Copyright [yyyy] [name of copyright owner] 183 | 184 | Licensed under the Apache License, Version 2.0 (the "License"); 185 | you may not use this file except in compliance with the License. 186 | You may obtain a copy of the License at 187 | 188 | http://www.apache.org/licenses/LICENSE-2.0 189 | 190 | Unless required by applicable law or agreed to in writing, software 191 | distributed under the License is distributed on an "AS IS" BASIS, 192 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 193 | See the License for the specific language governing permissions and 194 | limitations under the License. -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | NOTICE 2 | MQTT Vacuum's Camera renders the images of the Vacuums connected via MQTT with Home Assistant (mainly Valetudo ones), 3 | extracting and utilizing data from Vacuums firmware through MQTT to create a detailed map image with vacuum coordinates, 4 | walls, LiDar data, virtual restrictions, paths, cleaned areas, and segments (rooms). 5 | The author of this component developed it independently and is not associated with the Valetudo project or its creators. 6 | The scope of this project is to provide a visual representation of the vacuum's maps within Home Assistant. 7 | 8 | Origin of the Work 9 | This work is based on Valetudo firmware, which is an open-source project aimed at providing a cloud-free solution for 10 | robotic vacuums within Home Assistant. The integration for Home Assistant allows users to visualize the maps, 11 | facilitating the control of their MQTT or Valetudo-compatible vacuums within the Home Assistant ecosystem. 12 | 13 | Trademarks 14 | This component does not grant permission to use the trade names, trademarks, service marks, or product names of the 15 | Valetudo firmware's creators (the Licensor), including "Valetudo" and "Valetudo Vacuums," except as required for 16 | reasonable and customary use in describing the origin of the Work and reproducing the content of this NOTICE file. 17 | 18 | Disclaimer of Warranty 19 | Unless required by applicable law or agreed to in writing, the Licensor provides the Valetudo firmware (the Work) 20 | on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, 21 | without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 22 | PARTICULAR PURPOSE. Users are solely responsible for determining the appropriateness of using or redistributing 23 | the Work and assume any risks associated with their exercise of permissions under this License. 24 | 25 | Limitation of Liability 26 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required 27 | by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be 28 | liable for damages, including any direct, indirect, special, incidental, or consequential damages of any character 29 | arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages 30 | for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), 31 | even if such Contributor has been advised of the possibility of such damages. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [releases_shield]: https://img.shields.io/github/release/sca075/mqtt_vacuum_camera.svg?style=popout 2 | [latest_release]: https://github.com/sca075/mqtt_vacuum_camera/releases/latest 3 | [releases]: https://github.com/sca075/mqtt_vacuum_camera/releases 4 | [downloads_total_shield]: https://img.shields.io/github/downloads/sca075/mqtt_vacuum_camera/total 5 | 6 | # MQTT Vacuum's Camera 7 |

8 | logo@2x 9 |

10 | 11 | ## Current Release: [![GitHub Latest Release][releases_shield]][latest_release] [![GitHub All Releases][downloads_total_shield]][releases] 12 | 13 | ![Screenshot 2023-12-27 at 13 37 57](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/4f1f76ee-b507-4fde-b1bd-32e6980873cb) 14 | 15 | 16 | # Valetudo Vacuums maps in Home Assistant was never so easy. 17 | 18 | **About:** 19 | Extract the maps of Vacuum Cleaners connected via MQTT to Home Assistant such as Valetudo [Hypfer](https://valetudo.cloud/) or [RE(rand256)](https://github.com/rand256/valetudo) firmwares, [easy setup](./docs/install.md) thanks to [HACS](https://hacs.xyz/) and guided Home Assistant GUI configuration. 20 | 21 | **What it is:** 22 | 23 | ❗This is an _unofficial_ repo and is not created, maintained, or in any sense linked to [valetudo.cloud](https://valetudo.cloud) 24 | 25 | This custom component is simple to install and setup, decode and render the vacuum maps to Home Assistant in few clicks. 26 | When you want also to control your vacuum you will need to also install the: 27 | [lovelace-xiaomi-vacuum-map-card (recommended)](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card) from HACS as well. 28 | 29 | ### 🔗 Related Repositories 30 | 31 | - [Valetudo Map Extractor (library for extracting the maps)](https://github.com/sca075/Python-package-valetudo-map-parser) 32 | 33 | ### Goal of this project. 34 | The goal of this project is to deliver an out-of-the-box solution for integrating MQTT-based vacuums into the Home Assistant ecosystem. 35 | This includes real-time map extraction, sensor data (when not provided), and control services (not available by default) 36 | for a seamless user experience. 37 | 38 | Our current focus is evolving beyond map rendering to provide full vacuum control, ensuring a reliable, complete integration for all Valetudo-based vacuums, while continuously improving the user experience through regular updates. 39 |
40 | Planned in the next Release 41 | 42 | In the last releases we did start to implement the Actions for Rand256 and Hypfer. 43 | We can now see the Obstacles Images when available, and somehow we start to organize the code. 44 | The camera is stable and updated to all requirements of Home Assistant. 45 | Will be also time to take a brake and work in the background, so I do not expect unless required releases in January. 46 | Unfotunatelly the planed release 2025.05.0 will not happen as per are still work in progress. 47 | 48 | Would be really appreciated your kind help and understanding. 49 | 50 | #### 2025.5.0 - **Refactoring and New Additions** 51 | 52 | - This release will be postponed to 2025.6.0 53 | - **Changes** 54 | - (in progress) Improvements of the code structure. 55 | - Refactored the code to improve readability and maintainability. 56 | - Remove file operation routines not required for logging export. 57 | - **Features / Improvements :** 58 | - (done) Improved rooms outlines, for non rectangular rooms shapes. 59 | - Enable loading and saving of maps via services by fully integrating with [MapLoader](https://github.com/pkoehlers/maploader). 60 | - (done) Enable selection of specific elements to display on the map.. 61 | - (postponed) Add options for Area and Floor management. 62 | - **Potential Fixes:** 63 | - Fix Obstacles view. 64 | - Fix camera startup timeouts. 65 | - Fix the issue where the absence of a map causes the camera to malfunction. 66 | - Fix the alpha colours of the elements. 67 | - Implement a fully coordinated integration of the cameras and sensors. 68 |
69 | 70 | 71 | ### Features: 72 |
We here List what this camera offers as futures. 73 | 74 | 1) All Valetudo equipped vacuums are supported. 75 | 2) Supported languages (English, Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese, Polish, Norwegian, Russian, Spanish, Swedish). 76 | 3) **Automatically Generate the calibration points for the lovelace-xiaomi-vacuum-map-card** to ensure full compatibility to this user-friendly card. 77 | 4) **Automatically Generate rooms based configuration when vacuum support this functionality**, this will allow you to configure the rooms quickly on the [lovelace-xiaomi-vacuum-map-card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card). 78 | 5) **The camera take automatically [snapshots](./docs/snapshots.md) (when the vacuum idle/ error / docked)**. It is also possible to save a snapshot using the Action from Home Assistant with the file name and location you want to use. By the default the snapshot is saved in the www folder of Home Assistant. If the snapshot is disabled from Image Options the png will be deleted automatically. 79 | ``` 80 | service: camera.snapshot 81 | target: 82 | entity_id: camera.valetudo_your_vacuum_camera 83 | data: 84 | filename: /config/www/REPLACE_ME.png 85 | ``` 86 | 6) **Change the image options** directly form the Home Assistant integration UI with a simple click on the integration configuration. 87 | - **Image Rotation**: 0, 90, 180, 270 (default is 0). 88 | - [**Trim automatically the images**](./docs/croping_trimming.md). The standard Valetudo images size 5210x5210 or more, are resized automatically (At boot the camera trims and reduces the images sizes). Default margins are 150 pixels, you can customize this value from the image options. 89 | - Base colors are the **colors for robot, charger, walls, background, zones etc**. 90 | - **Rooms colors**, Room 1 is actually also the Floor color (for vacuum that do not support rooms). 91 | - **[Transparency level](./docs/transparency.md) for all elements and rooms** colours can be also customize. 92 | - It is possible to **display on the image the vacuum status**, this option add a vacuum status text at the top left of the image. Status and room where the vacuum is will be display on the text filed. 93 | 7) This integration make possible to **render multiple vacuums** as per each camera will be named with the vacuum name (example: vacuum.robot1 = camera.robot1_camera.. vacuum.robotx = camera.robotx_camera) 94 | 8) The camera as all cameras in HA **supports the ON/OFF service**, it is possible to *suspend and resume the camera streem as desired*. 95 | 9) In the attributes is possible to get on what room the vacuum is. 96 | 10) No Go, Virtual Walls, Zone Clean, Active Segments and Obstacles are draw on the map when available. 97 | 11) [Auto Zooming the room (segment)](./docs/auto_zoom.md) when the vacuum is cleaning it. 98 | 12) Support Actions "reload" and "reset_trims" implemented for changing the camera settings without restarting Home Assistant. 99 | 13) Rand256 sensors are pre-configured from the integration, this will allow you to have all the sensors available in Home Assistant. 100 | 14) Added the [**Actions**](./docs/actions.md) for Rand256 / Hypfer to control the vacuums without to format the MQTT messages. 101 | 15) [Obstacles](./docs/obstacles_detection.md) are displayed on the map when available. When the vacuum support ```ObstaclesImage``` is also possible to view the obstacles images. 102 |
103 | 104 | 105 | ### How to install: 106 | 107 | [![Open HACS repository in Home Assistant](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=sca075&repository=mqtt_vacuum_camera&category=integration) 108 | 109 | The instructions in [here](./docs/install.md) show detailed steps and will help to set up the camera also without HACS (manual setup). 110 | Our setup guide also includes **important** informations on how to setup the [lovelace-xiaomi-vacuum-map-card (recommended)](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card). 111 | 112 | 113 | ### Limitations and Compatibility: 114 |
115 | 116 | Please Read the "Limitations and Compatibility" before to install the camera. 117 | 118 | 119 | I kindly ask for your understanding regarding any limitations you may encounter with this custom component (please read also 120 | our [**notice**](./NOTICE.txt)). 121 | While it's been extensively tested on a PI4 8GB and now also on ProxMox VE, hardware below PI4 8GB may face issues. **Your feedback on such platforms is invaluable**; 122 | please report any problems you encounter. 123 | As a team of one, I'm diligently working to address compatibility across all environments, but this process takes time. In the interim, you can utilize [ValetudoPNG](https://github.com/erkexzcx/valetudopng) as an alternative on unsupported platforms. 124 | Your support in making this component compatible with all environments is greatly appreciated. If you'd like to contribute, whether through code or time, please consider joining our efforts. 125 | For further details on how the camera operates and how you can contribute, refer to the Wiki section of this project. Your patience and assistance are crucial as we strive toward our goal of universal compatibility. 126 | 127 | #### Compatibility: 128 | - PI3 4GB: The camera is working on PI3 4GB, anyhow no chance there to run two vacuums cameras at the same time. 129 | - PI4 4GB: The camera is working on PI4 4GB, anyhow run two vacuums cameras at the same time isn't advised even if possible. 130 | - All Vacuums with Valetudo Hypfer or Rand256 firmware are supported. 131 | - If you have a vacuum with a different firmware connected via MQTT, please let us know, we will try to add the support for it. 132 |
133 | 134 | 135 | ### Notes: 136 | - This integration is developed and tested using a PI4 with Home Assistant OS fully updated [to the last version](https://www.home-assistant.io/faq/release/), this allows us to confirm that the component is working properly with Home Assistant. Tested also on ProxMox and Docker Supervised "production" enviroment (fully setup home installation). 137 | ### Tanks to: 138 | - [@PiotrMachowski](https://github.com/PiotrMachowski) inspiring this integration and his amazing work. 139 | - [@billyourself](https://github.com/billyourself) for providing us the data and motivation to evolve this project. 140 | - [@Skeletorjus](https://github.com/Skeletorjus) that using this integration gave us several ideas to improve it. 141 | - [@rohankapoorcom](https://github.com/rohankapoorcom) autor of the v1.4.0 that make really easy to set up this integration. 142 | - [@gunjambi](https://github.com/gunjambi) that found a solution to re-draw the robot and also implemented the snapshots png to be enabled or disabled from the options. 143 | - [@T0ytoy](https://github.com/T0ytoy) for the superb cooperation in testing our Camera that improved [using the threading](https://github.com/sca075/valetudo_vacuum_camera/discussions/71). 144 | - [@borgqueenx](https://github.com/borgqueenx) for the great cooperation in testing our Camera and helping us to improve it, [see more here](https://github.com/sca075/mqtt_vacuum_camera/discussions/296#:~:text=Edit-,borgqueenx,-2%20weeks%20ago) 145 | - And to all of you using this integration and reporting any issues, improvements and vacuums used with it. 146 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | # --- test related 2 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/NOTICE.txt: -------------------------------------------------------------------------------- 1 | NOTICE 2 | MQTT Vacuum's Camera renders the images of the Vacuums connected via MQTT with Home Assistant (mainly Valetudo ones), 3 | extracting and utilizing data from Vacuums firmware through MQTT to create a detailed map image with vacuum coordinates, 4 | walls, LiDar data, virtual restrictions, paths, cleaned areas, and segments (rooms). 5 | The author of this component developed it independently and is not associated with the Valetudo project or its creators. 6 | The scope of this project is to provide a visual representation of the vacuum's maps within Home Assistant. 7 | 8 | Origin of the Work 9 | This work is based on Valetudo firmware, which is an open-source project aimed at providing a cloud-free solution for 10 | robotic vacuums within Home Assistant. The integration for Home Assistant allows users to visualize the maps, 11 | facilitating the control of their MQTT or Valetudo-compatible vacuums within the Home Assistant ecosystem. 12 | 13 | Trademarks 14 | This component does not grant permission to use the trade names, trademarks, service marks, or product names of the 15 | Valetudo firmware's creators (the Licensor), including "Valetudo" and "Valetudo Vacuums," except as required for 16 | reasonable and customary use in describing the origin of the Work and reproducing the content of this NOTICE file. 17 | 18 | Disclaimer of Warranty 19 | Unless required by applicable law or agreed to in writing, the Licensor provides the Valetudo firmware (the Work) 20 | on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, 21 | without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 22 | PARTICULAR PURPOSE. Users are solely responsible for determining the appropriateness of using or redistributing 23 | the Work and assume any risks associated with their exercise of permissions under this License. 24 | 25 | Limitation of Liability 26 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required 27 | by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be 28 | liable for damages, including any direct, indirect, special, incidental, or consequential damages of any character 29 | arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages 30 | for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), 31 | even if such Contributor has been advised of the possibility of such damages. -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MQTT Vacuum Camera. 3 | Version: 2025.05.0 4 | """ 5 | 6 | from functools import partial 7 | import os 8 | 9 | from homeassistant import config_entries, core 10 | from homeassistant.components import mqtt 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import ( 13 | CONF_UNIQUE_ID, 14 | EVENT_HOMEASSISTANT_FINAL_WRITE, 15 | SERVICE_RELOAD, 16 | Platform, 17 | ) 18 | from homeassistant.exceptions import ConfigEntryNotReady 19 | from homeassistant.helpers.reload import async_register_admin_service 20 | from homeassistant.helpers.storage import STORAGE_DIR 21 | 22 | from .common import get_vacuum_device_info, get_vacuum_mqtt_topic, update_options 23 | from .const import ( 24 | CAMERA_STORAGE, 25 | CONF_VACUUM_CONFIG_ENTRY_ID, 26 | CONF_VACUUM_CONNECTION_STRING, 27 | CONF_VACUUM_IDENTIFIERS, 28 | DOMAIN, 29 | LOGGER, 30 | ) 31 | from .coordinator import MQTTVacuumCoordinator 32 | from .utils.camera.camera_services import ( 33 | obstacle_view, 34 | reload_camera_config, 35 | reset_trims, 36 | ) 37 | from .utils.files_operations import ( 38 | async_get_translations_vacuum_id, 39 | async_rename_room_description, 40 | ) 41 | from .utils.thread_pool import ThreadPoolManager 42 | from .utils.vacuum.mqtt_vacuum_services import ( 43 | async_register_vacuums_services, 44 | async_remove_vacuums_services, 45 | is_rand256_vacuum, 46 | ) 47 | 48 | PLATFORMS = [Platform.CAMERA, Platform.SENSOR] 49 | 50 | 51 | async def options_update_listener(hass: core.HomeAssistant, config_entry: ConfigEntry): 52 | """Handle options update.""" 53 | await hass.config_entries.async_reload(config_entry.entry_id) 54 | 55 | 56 | async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: 57 | """Set up platform from a ConfigEntry.""" 58 | 59 | hass.data.setdefault(DOMAIN, {}) 60 | hass_data = dict(entry.data) 61 | 62 | # Language cache initialization moved to room_manager.py 63 | # It now only initializes when needed for room renaming operations 64 | # This improves performance by avoiding unnecessary initialization 65 | 66 | vacuum_entity_id, vacuum_device = get_vacuum_device_info( 67 | hass_data[CONF_VACUUM_CONFIG_ENTRY_ID], hass 68 | ) 69 | 70 | if not vacuum_entity_id: 71 | raise ConfigEntryNotReady( 72 | "Unable to lookup vacuum's entity ID. Was it removed?" 73 | ) 74 | 75 | mqtt_topic_vacuum = get_vacuum_mqtt_topic(vacuum_entity_id, hass) 76 | if not mqtt_topic_vacuum: 77 | raise ConfigEntryNotReady("MQTT was not ready yet, automatically retrying") 78 | 79 | is_rand256 = is_rand256_vacuum(vacuum_device) 80 | 81 | data_coordinator = MQTTVacuumCoordinator(hass, entry, mqtt_topic_vacuum, is_rand256) 82 | 83 | hass_data.update( 84 | { 85 | CONF_VACUUM_CONNECTION_STRING: mqtt_topic_vacuum, 86 | CONF_VACUUM_IDENTIFIERS: vacuum_device.identifiers, 87 | CONF_UNIQUE_ID: entry.unique_id, 88 | "coordinator": data_coordinator, 89 | "is_rand256": is_rand256, 90 | } 91 | ) 92 | # Register Services 93 | if not hass.services.has_service(DOMAIN, SERVICE_RELOAD): 94 | async_register_admin_service( 95 | hass, DOMAIN, SERVICE_RELOAD, partial(reload_camera_config, hass=hass) 96 | ) 97 | hass.services.async_register( 98 | DOMAIN, "reset_trims", partial(reset_trims, hass=hass) 99 | ) 100 | hass.services.async_register( 101 | DOMAIN, "obstacle_view", partial(obstacle_view, hass=hass) 102 | ) 103 | await async_register_vacuums_services(hass, data_coordinator) 104 | # Registers update listener to update config entry when options are updated. 105 | unsub_options_update_listener = entry.add_update_listener(options_update_listener) 106 | # Store a reference to the unsubscribe function to clean up if an entry is unloaded. 107 | hass_data["unsub_options_update_listener"] = unsub_options_update_listener 108 | hass.data[DOMAIN][entry.entry_id] = hass_data 109 | if bool(hass_data.get("is_rand256")): 110 | await hass.config_entries.async_forward_entry_setups( 111 | entry, ["camera", "sensor"] 112 | ) 113 | else: 114 | await hass.config_entries.async_forward_entry_setups(entry, ["camera"]) 115 | 116 | return True 117 | 118 | 119 | async def async_unload_entry( 120 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 121 | ) -> bool: 122 | """Unload a config entry.""" 123 | if bool(hass.data[DOMAIN][entry.entry_id]["is_rand256"]): 124 | unload_platform = PLATFORMS 125 | else: 126 | unload_platform = [Platform.CAMERA] 127 | LOGGER.debug("Platforms to unload: %s", unload_platform) 128 | if unload_ok := await hass.config_entries.async_unload_platforms( 129 | entry, unload_platform 130 | ): 131 | # Remove config entry from domain. 132 | entry_data = hass.data[DOMAIN].pop(entry.entry_id) 133 | entry_data["unsub_options_update_listener"]() 134 | 135 | # Shutdown thread pool for this entry 136 | thread_pool = ThreadPoolManager.get_instance() 137 | await thread_pool.shutdown(f"{entry.entry_id}_camera") 138 | await thread_pool.shutdown(f"{entry.entry_id}_camera_text") 139 | LOGGER.debug("Thread pools for %s shut down", entry.entry_id) 140 | 141 | # Remove services 142 | if not hass.data[DOMAIN]: 143 | hass.services.async_remove(DOMAIN, "reset_trims") 144 | hass.services.async_remove(DOMAIN, "obstacle_view") 145 | hass.services.async_remove(DOMAIN, SERVICE_RELOAD) 146 | await async_remove_vacuums_services(hass) 147 | return unload_ok 148 | 149 | 150 | # noinspection PyCallingNonCallable 151 | async def async_setup(hass: core.HomeAssistant, config: dict) -> bool: 152 | """Set up the MQTT Camera Custom component from yaml configuration.""" 153 | 154 | async def handle_homeassistant_stop(event): 155 | """Handle Home Assistant stop event.""" 156 | LOGGER.info("Home Assistant is stopping. Writing down the rooms data.") 157 | storage = hass.config.path(STORAGE_DIR, CAMERA_STORAGE) 158 | if not os.path.exists(storage): 159 | LOGGER.debug("Storage path: %s do not exists. Aborting!", storage) 160 | return False 161 | vacuum_entity_id = await async_get_translations_vacuum_id(storage) 162 | if not vacuum_entity_id: 163 | LOGGER.debug("No vacuum room data found. Aborting!") 164 | return False 165 | LOGGER.debug("Writing down the rooms data for %s.", vacuum_entity_id) 166 | # This will initialize the language cache only when needed 167 | # The optimization is now handled in room_manager.py 168 | await async_rename_room_description(hass, vacuum_entity_id) 169 | 170 | # Shutdown all thread pools 171 | thread_pool = ThreadPoolManager.get_instance() 172 | await thread_pool.shutdown() 173 | LOGGER.debug("All thread pools shut down") 174 | 175 | await hass.async_block_till_done() 176 | return True 177 | 178 | hass.bus.async_listen_once( 179 | EVENT_HOMEASSISTANT_FINAL_WRITE, handle_homeassistant_stop 180 | ) 181 | 182 | # Make sure MQTT integration is enabled and the client is available 183 | if not await mqtt.async_wait_for_mqtt_client(hass): 184 | LOGGER.error("MQTT integration is not available") 185 | return False 186 | hass.data.setdefault(DOMAIN, {}) 187 | return True 188 | 189 | 190 | async def async_migrate_entry(hass, config_entry: config_entries.ConfigEntry): 191 | """Migrate old entry.""" 192 | # as it loads at every rebot, the logs stay in the migration steps 193 | if config_entry.version == 3.1: 194 | LOGGER.debug("Migrating config entry from version %s", config_entry.version) 195 | old_data = {**config_entry.data} 196 | new_data = {"vacuum_config_entry": old_data["vacuum_config_entry"]} 197 | LOGGER.debug(dict(new_data)) 198 | old_options = {**config_entry.options} 199 | if len(old_options) != 0: 200 | tmp_option = { 201 | "trims_data": { 202 | "trim_left": 0, 203 | "trim_up": 0, 204 | "trim_right": 0, 205 | "trim_down": 0, 206 | }, 207 | } 208 | new_options = await update_options(old_options, tmp_option) 209 | LOGGER.debug("Migration data: %s", dict(new_options)) 210 | hass.config_entries.async_update_entry( 211 | config_entry, version=3.2, data=new_data, options=new_options 212 | ) 213 | LOGGER.info( 214 | "Migration to config entry version %s successful", config_entry.version 215 | ) 216 | return True 217 | if config_entry.version == 3.2: 218 | LOGGER.info("Migration to config entry version %s successful", 3.2) 219 | old_data = {**config_entry.data} 220 | new_data = {"vacuum_config_entry": old_data["vacuum_config_entry"]} 221 | LOGGER.debug(dict(new_data)) 222 | old_options = {**config_entry.options} 223 | if len(old_options) != 0: 224 | tmp_option = { 225 | "disable_floor": False, # Show floor 226 | "disable_wall": False, # Show walls 227 | "disable_robot": False, # Show robot 228 | "disable_charger": False, # Show charger 229 | "disable_virtual_walls": False, # Show virtual walls 230 | "disable_restricted_areas": False, # Show restricted areas 231 | "disable_no_mop_areas": False, # Show no-mop areas 232 | "disable_obstacles": False, # Hide obstacles 233 | "disable_path": False, # Hide path 234 | "disable_predicted_path": False, # Show predicted path 235 | "disable_go_to_target": False, # Show go-to target 236 | "disable_room_1": False, 237 | "disable_room_2": False, 238 | "disable_room_3": False, 239 | "disable_room_4": False, 240 | "disable_room_5": False, 241 | "disable_room_6": False, 242 | "disable_room_7": False, 243 | "disable_room_8": False, 244 | "disable_room_9": False, 245 | "disable_room_10": False, 246 | "disable_room_11": False, 247 | "disable_room_12": False, 248 | "disable_room_13": False, 249 | "disable_room_14": False, 250 | "disable_room_15": False, 251 | } 252 | new_options = await update_options(old_options, tmp_option) 253 | LOGGER.debug("Migration data: %s", dict(new_options)) 254 | hass.config_entries.async_update_entry( 255 | config_entry, version=3.3, data=new_data, options=new_options 256 | ) 257 | LOGGER.info( 258 | "Migration to config entry version %s successful", 259 | config_entry.version, 260 | ) 261 | return True 262 | else: 263 | LOGGER.error( 264 | "Migration failed: No options found in config entry. Please reconfigure the camera." 265 | ) 266 | return False 267 | return True 268 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common functions for the MQTT Vacuum Camera integration. 3 | Version: 2025.3.0b1 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import functools 9 | import re 10 | from typing import Any 11 | 12 | from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN 13 | from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers import device_registry as dr, entity_registry as er 16 | from homeassistant.helpers.device_registry import DeviceEntry 17 | 18 | from .const import KEYS_TO_UPDATE, LOGGER 19 | from .hass_types import GET_MQTT_DATA 20 | 21 | 22 | def get_vacuum_device_info( 23 | config_entry_id: str, hass: HomeAssistant 24 | ) -> tuple[str, DeviceEntry] | None: 25 | """ 26 | Fetches the vacuum's entity ID and Device from the 27 | entity registry and device registry. 28 | """ 29 | vacuum_entity_id = er.async_resolve_entity_id(er.async_get(hass), config_entry_id) 30 | if not vacuum_entity_id: 31 | LOGGER.error("Unable to lookup vacuum's entity ID. Was it removed?") 32 | return None 33 | 34 | device_registry = dr.async_get(hass) 35 | entity_registry = er.async_get(hass) 36 | vacuum_device = device_registry.async_get( 37 | entity_registry.async_get(vacuum_entity_id).device_id 38 | ) 39 | if not vacuum_device: 40 | LOGGER.error("Unable to locate vacuum's device ID. Was it removed?") 41 | return None 42 | 43 | return vacuum_entity_id, vacuum_device 44 | 45 | 46 | def get_camera_device_info(hass, entry): 47 | """Fetch the device info from the device registry based on entry_id or identifier.""" 48 | camera_entry = dict(hass.config_entries.async_get_entry(str(entry.entry_id)).data) 49 | camera_entry_options = dict( 50 | hass.config_entries.async_get_entry(str(entry.entry_id)).options 51 | ) 52 | camera_entry.update(camera_entry_options) 53 | return camera_entry 54 | 55 | 56 | def get_entity_identifier_from_mqtt( 57 | mqtt_identifier: str, hass: HomeAssistant 58 | ) -> str | None: 59 | """ 60 | Fetches the vacuum's entity_registry id from the mqtt topic identifier. 61 | Returns None if it cannot be found. 62 | """ 63 | device_registry = dr.async_get(hass) 64 | entity_registry = er.async_get(hass) 65 | device = device_registry.async_get_device( 66 | identifiers={(MQTT_DOMAIN, mqtt_identifier)} 67 | ) 68 | entities = er.async_entries_for_device(entity_registry, device_id=device.id) 69 | for entity in entities: 70 | if entity.domain == VACUUM_DOMAIN: 71 | return entity.id 72 | 73 | return None 74 | 75 | 76 | def get_vacuum_mqtt_topic(vacuum_entity_id: str, hass: HomeAssistant) -> str | None: 77 | """ 78 | Fetches the mqtt topic identifier from the MQTT integration. Returns None if it cannot be found. 79 | """ 80 | try: 81 | # Get the first subscription topic 82 | full_topic = list( 83 | hass.data[GET_MQTT_DATA] 84 | .debug_info_entities.get(vacuum_entity_id) 85 | .get("subscriptions") 86 | .keys() 87 | )[0] 88 | 89 | # Split and remove the last part after the last "/" 90 | topic_parts = full_topic.split("/") 91 | base_topic = "/".join(topic_parts[:-1]) 92 | return str(base_topic) 93 | except AttributeError: 94 | return None 95 | 96 | 97 | def get_vacuum_unique_id_from_mqtt_topic(vacuum_mqtt_topic: str) -> str: 98 | """ 99 | Returns the unique_id computed from the mqtt_topic for the vacuum. 100 | """ 101 | if not vacuum_mqtt_topic or "/" not in vacuum_mqtt_topic: 102 | raise ValueError("Invalid MQTT topic format") 103 | # Take the identifier no matter what prefixes are used @markus_sedi. 104 | return vacuum_mqtt_topic.split("/")[-1].lower() + "_camera" 105 | 106 | 107 | async def update_options(bk_options, new_options): 108 | """ 109 | Keep track of the modified options. 110 | Returns updated options after editing in Config_Flow. 111 | """ 112 | 113 | keys_to_update = KEYS_TO_UPDATE 114 | try: 115 | updated_options = { 116 | key: new_options[key] if key in new_options else bk_options[key] 117 | for key in keys_to_update 118 | } 119 | except KeyError as e: 120 | LOGGER.warning( 121 | "Error in migrating options, please re-setup the camera: %s", 122 | e, 123 | exc_info=True, 124 | ) 125 | return bk_options 126 | # updated_options is a dictionary containing the merged options 127 | updated_bk_options = updated_options # or backup_options, as needed 128 | return updated_bk_options 129 | 130 | 131 | def extract_file_name(unique_id: str) -> str: 132 | """Extract from the Camera unique_id the file name.""" 133 | file_name = re.sub(r"_camera$", "", unique_id) 134 | return file_name.lower() 135 | 136 | 137 | def is_rand256_vacuum(vacuum_device: DeviceEntry) -> bool: 138 | """ 139 | Check if the vacuum is running Rand256 firmware. 140 | """ 141 | # Check if the software version contains "valetudo" (for Hypfer) or something else for Rand256 142 | sof_version = str(vacuum_device.sw_version) 143 | if (sof_version.lower()).startswith("valetudo"): 144 | LOGGER.debug("No Sensors to startup!") 145 | return False # This is a Hypfer vacuum (Valetudo) 146 | return True 147 | 148 | 149 | def build_full_topic_set( 150 | base_topic: str, topic_suffixes: set, add_topic: str = None 151 | ) -> set: 152 | """ 153 | Append the base topic (self._mqtt_topic) to a set of topic suffixes. 154 | Optionally, add a single additional topic string. 155 | Returns a set of full MQTT topics. 156 | """ 157 | # Build the set of full topics from the topic_suffixes 158 | full_topics = {f"{base_topic}{suffix}" for suffix in topic_suffixes} 159 | 160 | # If add_topic is provided, add it to the set 161 | if add_topic: 162 | full_topics.add(add_topic) 163 | 164 | return full_topics 165 | 166 | 167 | def from_device_ids_to_entity_ids( 168 | device_ids: str, hass: HomeAssistant, domain: str = "vacuum" 169 | ) -> list[Any] | None: 170 | """ 171 | Convert a device_id to an entity_id. 172 | """ 173 | # Resolve device_id to entity_id using Home Assistant’s device and entity registries 174 | dev_reg = dr.async_get(hass) 175 | entity_reg = er.async_get(hass) 176 | resolved_entity_ids = [] 177 | 178 | for device_id in device_ids: 179 | # Look up device by device_id 180 | device = dev_reg.async_get(device_id) 181 | if device: 182 | # Find all entities linked to this device_id in the domain 183 | for entry in entity_reg.entities.values(): 184 | if entry.device_id == device_id and entry.domain == domain: 185 | resolved_entity_ids.append(entry.entity_id) 186 | 187 | return resolved_entity_ids if resolved_entity_ids else None 188 | 189 | 190 | def get_device_info_from_entity_id(entity_id: str, hass) -> DeviceEntry | None: 191 | """ 192 | Fetch the device info from the device registry based on entity_id. 193 | """ 194 | entity_reg = er.async_get(hass) 195 | device_reg = dr.async_get(hass) 196 | for entry in entity_reg.entities.values(): 197 | if entry.entity_id == entity_id and entry.domain == "vacuum": 198 | device_id = entry.device_id 199 | device = device_reg.async_get(device_id) 200 | return device 201 | return None 202 | 203 | 204 | def get_entity_id( 205 | entity_id: str | None, 206 | device_id: str | None, 207 | hass: HomeAssistant, 208 | domain: str = "vacuum", 209 | ) -> str | None: 210 | """Resolve the Entity ID""" 211 | vacuum_entity_id = entity_id # Default to entity_id 212 | if device_id: 213 | resolved_entities = from_device_ids_to_entity_ids(device_id, hass, domain) 214 | vacuum_entity_id = resolved_entities 215 | elif not vacuum_entity_id: 216 | LOGGER.error( 217 | "No vacuum entities found for device_id: %s", device_id, exc_info=True 218 | ) 219 | return None 220 | return vacuum_entity_id 221 | 222 | 223 | def compose_obstacle_links(vacuum_host_ip: str, obstacles: list) -> list | None: 224 | """ 225 | Compose JSON with obstacle details including the image link. 226 | """ 227 | obstacle_links = [] 228 | if not obstacles or not vacuum_host_ip: 229 | LOGGER.debug("No obstacles or vacuum_host_ip provided.") 230 | return None 231 | 232 | for obstacle in obstacles: 233 | # Extract obstacle details 234 | label = obstacle.get("label", "") 235 | points = obstacle.get("points", {}) 236 | image_id = obstacle.get("id", "None") 237 | 238 | if label and points and image_id and vacuum_host_ip: 239 | # Append formatted obstacle data 240 | if image_id != "None": 241 | # Compose the link 242 | image_link = ( 243 | f"http://{vacuum_host_ip}" 244 | f"/api/v2/robot/capabilities/ObstacleImagesCapability/img/{image_id}" 245 | ) 246 | obstacle_links.append( 247 | { 248 | "point": points, 249 | "label": label, 250 | "link": image_link, 251 | } 252 | ) 253 | else: 254 | obstacle_links.append( 255 | { 256 | "point": points, 257 | "label": label, 258 | } 259 | ) 260 | 261 | LOGGER.debug("Obstacle links: linked data complete.") 262 | 263 | return obstacle_links if obstacle_links else None 264 | 265 | 266 | def redact_ip_filter(func): 267 | """Decorator to remove IP addresses from function output""" 268 | 269 | @functools.wraps(func) 270 | def wrapper(*args, **kwargs): 271 | result = func(*args, **kwargs) 272 | if isinstance(result, str): 273 | ip_pattern = r"'?\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'?" 274 | return re.sub(ip_pattern, "'[Redacted IP]'", result) 275 | return result 276 | 277 | return wrapper 278 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/config_flow.py: -------------------------------------------------------------------------------- 1 | """ 2 | config_flow.py 3 | IMPORTANT: Maintain code when adding new options to the camera 4 | it will be mandatory to update const.py and common.py update_options. 5 | Format of the new constants must be CONST_NAME = "const_name" update also 6 | sting.json and en.json please. 7 | Version: 2025.3.0b1 8 | """ 9 | 10 | import os 11 | from typing import Any, Dict, Optional 12 | 13 | from homeassistant import config_entries 14 | from homeassistant.components.vacuum import DOMAIN as ZONE_VACUUM 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import CONF_UNIQUE_ID 17 | from homeassistant.core import callback 18 | from homeassistant.exceptions import ConfigEntryError 19 | from homeassistant.helpers import entity_registry as er 20 | from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig 21 | from homeassistant.helpers.storage import STORAGE_DIR 22 | import voluptuous as vol 23 | 24 | from .common import ( 25 | get_vacuum_device_info, 26 | get_vacuum_mqtt_topic, 27 | get_vacuum_unique_id_from_mqtt_topic, 28 | ) 29 | from .const import ( 30 | CAMERA_STORAGE, 31 | CONF_VACUUM_CONFIG_ENTRY_ID, 32 | CONF_VACUUM_ENTITY_ID, 33 | DEFAULT_VALUES, 34 | DOMAIN, 35 | LOGGER, 36 | ) 37 | from .options_flow import MQTTCameraOptionsFlowHandler 38 | 39 | VACUUM_SCHEMA = vol.Schema( 40 | { 41 | vol.Required(CONF_VACUUM_ENTITY_ID): EntitySelector( 42 | EntitySelectorConfig(domain=ZONE_VACUUM), 43 | ) 44 | } 45 | ) 46 | 47 | 48 | class MQTTCameraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 49 | """Camera Configration Flow Handler""" 50 | 51 | VERSION = 3.3 52 | 53 | def __init__(self): 54 | self.data = {} 55 | self.camera_options = {} 56 | self.name = "" 57 | 58 | async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): 59 | if user_input is not None: 60 | vacuum_entity_id = user_input["vacuum_entity"] 61 | entity_registry = er.async_get(self.hass) 62 | vacuum_entity = entity_registry.async_get(vacuum_entity_id) 63 | vacuum_topic = get_vacuum_mqtt_topic(vacuum_entity_id, self.hass) 64 | if not vacuum_topic: 65 | raise ConfigEntryError( 66 | f"Vacuum {vacuum_entity_id} not supported! No MQTT topic found." 67 | ) 68 | 69 | unique_id = get_vacuum_unique_id_from_mqtt_topic(vacuum_topic) 70 | 71 | for existing_entity in self._async_current_entries(): 72 | if ( 73 | existing_entity.data.get(CONF_VACUUM_ENTITY_ID) == vacuum_entity.id 74 | or existing_entity.data.get(CONF_UNIQUE_ID) == unique_id 75 | ): 76 | return self.async_abort(reason="already_configured") 77 | 78 | self.data.update( 79 | { 80 | CONF_VACUUM_CONFIG_ENTRY_ID: vacuum_entity.id, 81 | CONF_UNIQUE_ID: unique_id, 82 | "platform": "mqtt_vacuum_camera", 83 | } 84 | ) 85 | 86 | # set the unique_id in the entry configuration 87 | await self.async_set_unique_id(unique_id=unique_id) 88 | # set default options 89 | self.camera_options.update(DEFAULT_VALUES) 90 | # create the path for storing the snapshots. 91 | storage_path = f"{self.hass.config.path(STORAGE_DIR)}/{CAMERA_STORAGE}" 92 | if not os.path.exists(storage_path): 93 | LOGGER.debug("Creating the %s path.", storage_path) 94 | try: 95 | os.mkdir(storage_path) 96 | except FileExistsError as e: 97 | LOGGER.error( 98 | "Error %s can not find path %s", e, storage_path, exc_info=True 99 | ) 100 | except OSError as e: 101 | LOGGER.error( 102 | "Error %s creating the path %s", e, storage_path, exc_info=True 103 | ) 104 | else: 105 | LOGGER.debug("Storage %s path found.", storage_path) 106 | # Finally set up the entry. 107 | _, vacuum_device = get_vacuum_device_info( 108 | self.data[CONF_VACUUM_CONFIG_ENTRY_ID], self.hass 109 | ) 110 | 111 | # Return the data and default options to config_entry 112 | return self.async_create_entry( 113 | title=vacuum_device.name + " Camera", 114 | data=self.data, 115 | options=self.camera_options, 116 | ) 117 | 118 | return self.async_show_form(step_id="user", data_schema=VACUUM_SCHEMA) 119 | 120 | @staticmethod 121 | @callback 122 | def async_get_options_flow( 123 | config_entry: ConfigEntry, 124 | ) -> config_entries.OptionsFlow: 125 | """Create the options flow.""" 126 | return MQTTCameraOptionsFlowHandler(config_entry) 127 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/coordinator.py: -------------------------------------------------------------------------------- 1 | """ 2 | MQTT Vacuum Camera Coordinator. 3 | Version: 2025.3.0b0 4 | """ 5 | 6 | import asyncio 7 | from datetime import timedelta 8 | from typing import Optional 9 | 10 | import async_timeout 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | from homeassistant.helpers.event import async_call_later 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | from valetudo_map_parser.config.shared import CameraShared, CameraSharedManager 17 | 18 | from .common import get_camera_device_info 19 | from .const import DEFAULT_NAME, LOGGER, SENSOR_NO_DATA 20 | from .utils.connection.connector import ValetudoConnector 21 | 22 | 23 | class MQTTVacuumCoordinator(DataUpdateCoordinator): 24 | """Coordinator for MQTT Vacuum Camera.""" 25 | 26 | def __init__( 27 | self, 28 | hass: HomeAssistant, 29 | entry: ConfigEntry, 30 | vacuum_topic: str, 31 | rand256_vacuum: bool = False, 32 | polling_interval: timedelta = timedelta(seconds=3), 33 | ): 34 | """Initialize the coordinator.""" 35 | super().__init__( 36 | hass, 37 | LOGGER, 38 | name=DEFAULT_NAME, 39 | update_interval=polling_interval, 40 | ) 41 | self.hass: HomeAssistant = hass 42 | self.vacuum_topic: str = vacuum_topic 43 | self.is_rand256: bool = rand256_vacuum 44 | self.device_entity: ConfigEntry = entry 45 | self.device_info: DeviceInfo = get_camera_device_info(hass, self.device_entity) 46 | self.shared_manager: Optional[CameraSharedManager] = None 47 | self.shared: Optional[CameraShared] = None 48 | self.file_name: str = "" 49 | self.connector: Optional[ValetudoConnector] = None 50 | self.in_sync_with_camera: bool = False 51 | self.sensor_data = SENSOR_NO_DATA 52 | # Initialize shared data and MQTT connector 53 | self.shared, self.file_name = self._init_shared_data(self.vacuum_topic) 54 | self.start_up_mqtt() 55 | self.scheduled_refresh: asyncio.TimerHandle | None = None 56 | 57 | def schedule_refresh(self) -> None: 58 | """Schedule coordinator refresh after 1 second.""" 59 | if self.scheduled_refresh: 60 | self.scheduled_refresh.cancel() 61 | self.scheduled_refresh = async_call_later( 62 | self.hass, 1, lambda: asyncio.create_task(self.async_refresh()) 63 | ) 64 | 65 | async def _async_update_data(self): 66 | """ 67 | Fetch data from the MQTT topics for sensors. 68 | """ 69 | if self.shared is not None and self.connector: 70 | try: 71 | async with async_timeout.timeout(10): 72 | # Fetch and process sensor data from the MQTT connector 73 | sensor_data = await self.connector.get_rand256_attributes() 74 | if sensor_data: 75 | # Format the data before returning it 76 | self.sensor_data = await self.async_update_sensor_data( 77 | sensor_data 78 | ) 79 | return self.sensor_data 80 | return self.sensor_data 81 | except Exception as err: 82 | LOGGER.error( 83 | "Exception raised fetching sensor data: %s", err, exc_info=True 84 | ) 85 | raise UpdateFailed(f"Error fetching sensor data: {err}") from err 86 | else: 87 | return self.sensor_data 88 | 89 | def _init_shared_data( 90 | self, mqtt_listen_topic: str 91 | ) -> tuple[Optional[CameraShared], Optional[str]]: 92 | """ 93 | Initialize the shared data. 94 | """ 95 | shared = None 96 | file_name = None 97 | 98 | if mqtt_listen_topic and not self.shared_manager: 99 | file_name = mqtt_listen_topic.split("/")[1].lower() 100 | self.shared_manager = CameraSharedManager(file_name, self.device_info) 101 | shared = self.shared_manager.get_instance() 102 | LOGGER.debug("Camera %s Starting up..", file_name) 103 | 104 | return shared, file_name 105 | 106 | def start_up_mqtt(self) -> ValetudoConnector: 107 | """ 108 | Initialize the MQTT Connector. 109 | """ 110 | self.connector = ValetudoConnector( 111 | self.vacuum_topic, self.hass, self.shared, self.is_rand256 112 | ) 113 | return self.connector 114 | 115 | def update_shared_data(self, dev_info: DeviceInfo) -> tuple[CameraShared, str]: 116 | """ 117 | Create or update the instance of the shared data. 118 | """ 119 | self.shared_manager.update_shared_data(dev_info) 120 | self.shared = self.shared_manager.get_instance() 121 | self.shared.file_name = self.file_name 122 | self.shared.device_info = dev_info 123 | self.in_sync_with_camera = True 124 | return self.shared, self.file_name 125 | 126 | async def async_update_camera_data(self, process: bool = True): 127 | """ 128 | Fetch data from the MQTT topics. 129 | """ 130 | try: 131 | async with async_timeout.timeout(10): 132 | # Fetch and process maps data from the MQTT connector 133 | return await self.connector.update_data(process) 134 | except Exception as err: 135 | LOGGER.error( 136 | "Error communicating with MQTT or processing data: %s", 137 | err, 138 | exc_info=True, 139 | ) 140 | raise UpdateFailed(f"Error communicating with MQTT: {err}") from err 141 | 142 | async def async_update_sensor_data(self, sensor_data): 143 | """Update the sensor data format before sending to the sensors.""" 144 | try: 145 | if not sensor_data: 146 | return SENSOR_NO_DATA 147 | 148 | try: 149 | battery_level = await self.connector.get_battery_level() 150 | vacuum_state = await self.connector.get_vacuum_status() 151 | except (AttributeError, ConnectionError) as err: 152 | LOGGER.warning("Failed to get vacuum status: %s", err, exc_info=True) 153 | return SENSOR_NO_DATA 154 | 155 | vacuum_room = self.shared.current_room or {"in_room": "Unsupported"} 156 | last_run_stats = sensor_data.get("last_run_stats", {}) 157 | last_loaded_map = sensor_data.get("last_loaded_map", {"name": "Default"}) 158 | 159 | if last_run_stats is None: 160 | last_run_stats = {} 161 | if not last_loaded_map: 162 | last_loaded_map = {"name": "Default"} 163 | 164 | formatted_data = { 165 | "mainBrush": sensor_data.get("mainBrush", 0), 166 | "sideBrush": sensor_data.get("sideBrush", 0), 167 | "filter": sensor_data.get("filter", 0), 168 | "sensor": sensor_data.get("sensor", 0), 169 | "currentCleanTime": sensor_data.get("currentCleanTime", 0), 170 | "currentCleanArea": sensor_data.get("currentCleanArea", 0), 171 | "cleanTime": sensor_data.get("cleanTime", 0), 172 | "cleanArea": sensor_data.get("cleanArea", 0), 173 | "cleanCount": sensor_data.get("cleanCount", 0), 174 | "battery": battery_level, 175 | "state": vacuum_state, 176 | "last_run_start": last_run_stats.get("startTime", 0), 177 | "last_run_end": last_run_stats.get("endTime", 0), 178 | "last_run_duration": last_run_stats.get("duration", 0), 179 | "last_run_area": last_run_stats.get("area", 0), 180 | "last_bin_out": sensor_data.get("last_bin_out", 0), 181 | "last_bin_full": sensor_data.get("last_bin_full", 0), 182 | "last_loaded_map": last_loaded_map.get("name", "Default"), 183 | "robot_in_room": vacuum_room.get("in_room"), 184 | } 185 | return formatted_data 186 | 187 | except AttributeError as err: 188 | LOGGER.warning("Missing required attribute: %s", err, exc_info=True) 189 | return SENSOR_NO_DATA 190 | except KeyError as err: 191 | LOGGER.warning( 192 | "Missing required key in sensor data: %s", err, exc_info=True 193 | ) 194 | return SENSOR_NO_DATA 195 | except TypeError as err: 196 | LOGGER.warning("Invalid data type in sensor data: %s", err, exc_info=True) 197 | return SENSOR_NO_DATA 198 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/hass_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom HassKey types for Home Assistant. 3 | Version: 2025.3.0b0 4 | """ 5 | 6 | from homeassistant.components.mqtt.models import MqttData 7 | from homeassistant.util.hass_dict import HassKey 8 | 9 | GET_MQTT_DATA: HassKey[MqttData] = HassKey("mqtt") 10 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "reload": "mdi:reload", 4 | "reset_trims": "mdi:image-frame", 5 | "snapshot": "mdi:camera", 6 | "turn_off": "mdi:video-off", 7 | "turn_on": "mdi:video", 8 | "vacuum_go_to" : "mdi:map-marker", 9 | "vacuum_clean_zone" : "mdi:map-clock", 10 | "vacuum_clean_segments" : "mdi:map-marker-circle", 11 | "vacuum_map_save" : "mdi:file-document-edit", 12 | "vacuum_map_load" : "mdi:file-document-check", 13 | "obstacle_view" :"mdi:cast-connected" 14 | } 15 | } -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "mqtt_vacuum_camera", 3 | "name": "MQTT Vacuum Camera", 4 | "codeowners": ["@sca075"], 5 | "config_flow": true, 6 | "dependencies": ["mqtt"], 7 | "documentation": "https://github.com/sca075/mqtt_vacuum_camera", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/sca075/mqtt_vacuum_camera/issues", 10 | "requirements": [ 11 | "valetudo_map_parser==0.1.9.b54" 12 | ], 13 | "version": "2025.5.0b3" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/repairs.py: -------------------------------------------------------------------------------- 1 | """Repairs for the MQTT Vacuum Camera integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant import data_entry_flow 6 | from homeassistant.components.repairs import RepairsFlow 7 | from homeassistant.core import HomeAssistant 8 | import voluptuous as vol 9 | 10 | 11 | class Issue1RepairFlow(RepairsFlow): 12 | """Handler for an issue fixing flow.""" 13 | 14 | async def async_step_init( 15 | self, user_input: dict[str, str] | None = None 16 | ) -> data_entry_flow.FlowResult: 17 | """Handle the first step of a fix flow.""" 18 | 19 | return await self.async_step_confirm() 20 | 21 | async def async_step_confirm( 22 | self, user_input: dict[str, str] | None = None 23 | ) -> data_entry_flow.FlowResult: 24 | """Handle the confirm step of a fix flow.""" 25 | if user_input is not None: 26 | return self.async_create_entry(title="", data={}) 27 | 28 | return self.async_show_form(step_id="confirm", data_schema=vol.Schema({})) 29 | 30 | 31 | async def async_create_fix_flow( 32 | hass: HomeAssistant, 33 | issue_id: str, 34 | data: dict[str, str | int | float | None] | None, 35 | ) -> RepairsFlow: 36 | """Create flow. issue_1 is general purpose""" 37 | if issue_id == "issue_1": 38 | return Issue1RepairFlow() 39 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensors for Rand256. 2 | Version: 2025.3.0b10 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from collections.abc import Callable 8 | from dataclasses import dataclass 9 | from datetime import datetime, timedelta, timezone 10 | 11 | from homeassistant.components.sensor import ( 12 | SensorDeviceClass, 13 | SensorEntity, 14 | SensorEntityDescription, 15 | SensorStateClass, 16 | ) 17 | from homeassistant.const import PERCENTAGE, UnitOfArea, UnitOfTime 18 | from homeassistant.core import HomeAssistant, callback 19 | from homeassistant.helpers import config_validation as cv 20 | from homeassistant.helpers.entity import EntityCategory 21 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 22 | 23 | from .const import CONF_VACUUM_IDENTIFIERS, DOMAIN, LOGGER, SENSOR_NO_DATA 24 | from .coordinator import MQTTVacuumCoordinator 25 | 26 | SCAN_INTERVAL = timedelta(seconds=2) 27 | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 28 | 29 | 30 | @dataclass(frozen=True) 31 | class VacuumSensorDescription(SensorEntityDescription): 32 | """A class that describes vacuum sensor entities.""" 33 | 34 | attributes: tuple = () 35 | parent_key: str = None 36 | keys: list[str] = None 37 | value: Callable = None 38 | 39 | 40 | SENSOR_TYPES = { 41 | "consumable_main_brush": VacuumSensorDescription( 42 | native_unit_of_measurement=UnitOfTime.HOURS, 43 | key="mainBrush", 44 | icon="mdi:brush", 45 | name="Main brush", 46 | state_class=SensorStateClass.MEASUREMENT, 47 | entity_category=EntityCategory.DIAGNOSTIC, 48 | ), 49 | "consumable_side_brush": VacuumSensorDescription( 50 | native_unit_of_measurement=UnitOfTime.HOURS, 51 | key="sideBrush", 52 | icon="mdi:brush", 53 | name="Side brush", 54 | state_class=SensorStateClass.MEASUREMENT, 55 | entity_category=EntityCategory.DIAGNOSTIC, 56 | ), 57 | "consumable_filter": VacuumSensorDescription( 58 | native_unit_of_measurement=UnitOfTime.HOURS, 59 | key="filter", 60 | icon="mdi:air-filter", 61 | name="Filter", 62 | state_class=SensorStateClass.MEASUREMENT, 63 | entity_category=EntityCategory.DIAGNOSTIC, 64 | ), 65 | "clean_sensor": VacuumSensorDescription( 66 | native_unit_of_measurement=UnitOfTime.HOURS, 67 | key="sensor", 68 | icon="mdi:access-point", 69 | name="Clean sensors", 70 | state_class=SensorStateClass.MEASUREMENT, 71 | entity_category=EntityCategory.DIAGNOSTIC, 72 | ), 73 | "battery": VacuumSensorDescription( 74 | native_unit_of_measurement=PERCENTAGE, 75 | key="battery", 76 | icon="mdi:battery", 77 | name="Battery", 78 | device_class=SensorDeviceClass.BATTERY, 79 | entity_category=EntityCategory.DIAGNOSTIC, 80 | ), 81 | "current_clean_time": VacuumSensorDescription( 82 | native_unit_of_measurement=UnitOfTime.MINUTES, 83 | key="currentCleanTime", 84 | icon="mdi:timer-sand", 85 | name="Current clean time", 86 | device_class=SensorDeviceClass.DURATION, 87 | entity_category=EntityCategory.DIAGNOSTIC, 88 | ), 89 | "current_clean_area": VacuumSensorDescription( 90 | native_unit_of_measurement=UnitOfArea.SQUARE_METERS, 91 | key="currentCleanArea", 92 | icon="mdi:texture-box", 93 | name="Current clean area", 94 | state_class=SensorStateClass.TOTAL, 95 | entity_category=EntityCategory.DIAGNOSTIC, 96 | ), 97 | "clean_count": VacuumSensorDescription( 98 | key="cleanCount", 99 | icon="mdi:counter", 100 | name="Total clean count", 101 | device_class=SensorDeviceClass.ENUM, 102 | entity_category=EntityCategory.DIAGNOSTIC, 103 | ), 104 | "clean_time": VacuumSensorDescription( 105 | native_unit_of_measurement=UnitOfTime.HOURS, 106 | key="cleanTime", 107 | icon="mdi:timer-sand", 108 | name="Total clean time", 109 | state_class=SensorStateClass.TOTAL, 110 | device_class=SensorDeviceClass.DURATION, 111 | entity_category=EntityCategory.DIAGNOSTIC, 112 | ), 113 | "state": VacuumSensorDescription( 114 | key="state", 115 | icon="mdi:robot-vacuum", 116 | name="Vacuum state", 117 | entity_category=EntityCategory.DIAGNOSTIC, 118 | ), 119 | "last_run_start": VacuumSensorDescription( 120 | key="last_run_start", 121 | icon="mdi:clock-start", 122 | name="Last run start time", 123 | device_class=SensorDeviceClass.TIMESTAMP, 124 | entity_category=EntityCategory.DIAGNOSTIC, 125 | ), 126 | "last_run_end": VacuumSensorDescription( 127 | key="last_run_end", 128 | icon="mdi:clock-end", 129 | name="Last run end time", 130 | device_class=SensorDeviceClass.TIMESTAMP, 131 | entity_category=EntityCategory.DIAGNOSTIC, 132 | ), 133 | "last_run_duration": VacuumSensorDescription( 134 | native_unit_of_measurement=UnitOfTime.SECONDS, 135 | key="last_run_duration", 136 | icon="mdi:timer", 137 | name="Last run duration", 138 | device_class=SensorDeviceClass.DURATION, 139 | entity_category=EntityCategory.DIAGNOSTIC, 140 | ), 141 | "last_run_area": VacuumSensorDescription( 142 | native_unit_of_measurement=UnitOfArea.SQUARE_METERS, 143 | key="last_run_area", 144 | icon="mdi:texture-box", 145 | name="Last run area", 146 | entity_category=EntityCategory.DIAGNOSTIC, 147 | ), 148 | "last_bin_out": VacuumSensorDescription( 149 | key="last_bin_out", 150 | icon="mdi:delete", 151 | name="Last bin out time", 152 | device_class=SensorDeviceClass.TIMESTAMP, 153 | entity_category=EntityCategory.DIAGNOSTIC, 154 | ), 155 | "last_bin_full": VacuumSensorDescription( 156 | key="last_bin_full", 157 | icon="mdi:delete-alert", 158 | name="Last bin full time", 159 | device_class=SensorDeviceClass.TIMESTAMP, 160 | entity_category=EntityCategory.DIAGNOSTIC, 161 | ), 162 | "last_loaded_map": VacuumSensorDescription( 163 | key="last_loaded_map", 164 | icon="mdi:map", 165 | name="Last loaded map", 166 | device_class=SensorDeviceClass.ENUM, 167 | entity_category=EntityCategory.DIAGNOSTIC, 168 | value=lambda v, _: v if isinstance(v, str) else "Unknown", 169 | ), 170 | "robot_in_room": VacuumSensorDescription( 171 | key="robot_in_room", 172 | icon="mdi:location-enter", 173 | name="Current Room", 174 | entity_category=EntityCategory.DIAGNOSTIC, 175 | value=lambda v, _: v if isinstance(v, str) else "Unsupported", 176 | ), 177 | } 178 | 179 | 180 | class VacuumSensor(CoordinatorEntity, SensorEntity): 181 | """Representation of a vacuum sensor.""" 182 | 183 | entity_description: VacuumSensorDescription 184 | 185 | def __init__( 186 | self, 187 | coordinator: MQTTVacuumCoordinator, 188 | description: VacuumSensorDescription, 189 | sensor_type: str, 190 | vacuum_identifier, 191 | ): 192 | """Initialize the vacuum sensor.""" 193 | super().__init__(coordinator) 194 | self.entity_description = description 195 | self.coordinator = coordinator 196 | self._attr_native_value = None 197 | self._attr_unique_id = f"{coordinator.file_name}_{sensor_type}" 198 | self.entity_id = f"sensor.{coordinator.file_name}_{sensor_type}" 199 | self._identifiers = vacuum_identifier 200 | 201 | async def async_will_remove_from_hass(self) -> None: 202 | """Handle entity removal from Home Assistant.""" 203 | await super().async_will_remove_from_hass() 204 | 205 | @callback 206 | async def async_update(self): 207 | """Update the sensor's state.""" 208 | if self.coordinator.last_update_success: 209 | await self.async_handle_coordinator_update() 210 | 211 | @property 212 | def should_poll(self) -> bool: 213 | """Indicate if the sensor should poll for updates.""" 214 | return True 215 | 216 | @callback 217 | async def _extract_attributes(self): 218 | """Return state attributes with valid values.""" 219 | data = self.coordinator.sensor_data 220 | if self.entity_description.parent_key: 221 | data = getattr(data, self.entity_description.key) 222 | if data is None: 223 | return {} 224 | return { 225 | attr: getattr(data, attr) 226 | for attr in self.entity_description.attributes 227 | if hasattr(data, attr) 228 | } 229 | 230 | @callback 231 | async def async_handle_coordinator_update(self): 232 | """Fetch the latest state from the coordinator and update the sensor.""" 233 | data = self.coordinator.sensor_data 234 | if data is None: 235 | data = SENSOR_NO_DATA 236 | 237 | # Fetch the value based on the key in the description 238 | native_value = data.get(self.entity_description.key, 0) 239 | if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: 240 | # Convert the Unix timestamp to datetime 241 | try: 242 | native_value = process_timestamp(native_value) 243 | except (ValueError, TypeError): 244 | native_value = None 245 | elif self.entity_description.device_class == SensorDeviceClass.DURATION: 246 | # Convert the Unix timestamp to datetime 247 | try: 248 | native_value = convert_duration(native_value) 249 | except (ValueError, TypeError): 250 | native_value = None 251 | 252 | if native_value is not None: 253 | self._attr_native_value = native_value 254 | else: 255 | self._attr_native_value = ( 256 | 0 # Set to None if the value is missing or invalid 257 | ) 258 | 259 | self.async_write_ha_state() 260 | 261 | 262 | def convert_duration(seconds): 263 | """Convert seconds in days""" 264 | # Create a timedelta object from seconds 265 | time_delta = timedelta(seconds=float(seconds)) 266 | if not time_delta: 267 | return seconds 268 | return time_delta.total_seconds() 269 | 270 | 271 | def process_timestamp(native_value): 272 | """Convert vacuum times in local time""" 273 | if native_value is None or native_value <= 0: 274 | return datetime.fromtimestamp(0, timezone.utc) 275 | try: 276 | # Convert milliseconds to seconds 277 | utc_time = datetime.fromisoformat( 278 | datetime.fromtimestamp(float(native_value) / 1000, timezone.utc) 279 | .astimezone() 280 | .isoformat() 281 | ) 282 | 283 | return utc_time 284 | except ValueError: 285 | LOGGER.debug("Invalid timestamp: %s", native_value) 286 | return None 287 | 288 | 289 | async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): 290 | """Set up vacuum sensors based on a config entry.""" 291 | coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] 292 | vacuum_identifier = hass.data[DOMAIN][config_entry.entry_id][ 293 | CONF_VACUUM_IDENTIFIERS 294 | ] 295 | # Create and add sensor entities 296 | sensors = [] 297 | for sensor_type, description in SENSOR_TYPES.items(): 298 | sensors.append( 299 | VacuumSensor(coordinator, description, sensor_type, vacuum_identifier) 300 | ) 301 | async_add_entities(sensors, update_before_add=False) 302 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/services.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | reload: 3 | name: Reload 4 | description: Reload all entities of MQTT Vacuum Camera platform 5 | 6 | turn_off: 7 | target: 8 | entity: 9 | domain: camera 10 | 11 | turn_on: 12 | target: 13 | entity: 14 | domain: camera 15 | 16 | snapshot: 17 | target: 18 | entity: 19 | domain: camera 20 | fields: 21 | filename: 22 | required: true 23 | example: "config/www/snapshot_.png" 24 | selector: 25 | text: 26 | 27 | reset_trims: 28 | 29 | obstacle_view: 30 | name: Obstacle view 31 | description: Select the coordinates to Show the obstacle on the map. 32 | target: 33 | entity: 34 | domain: camera 35 | fields: 36 | coordinates_x: 37 | name: x 38 | description: Coordinate x for the obstacle view. 39 | required: true 40 | selector: 41 | number: 42 | min: 0 43 | max: 90000 44 | coordinates_y: 45 | name: y 46 | description: Coordinate y for the obstacle view. 47 | required: true 48 | selector: 49 | number: 50 | min: 0 51 | max: 90000 52 | 53 | vacuum_go_to: 54 | name: Vacuum go to 55 | description: Go to the specified coordinates 56 | target: 57 | entity: 58 | integration: mqtt 59 | domain: vacuum 60 | fields: 61 | x_coord: 62 | name: X coordinate 63 | description: x-coordinate 64 | required: true 65 | example: 26300 66 | selector: 67 | text: 68 | y_coord: 69 | name: Y coordinate 70 | description: y-coordinate 71 | required: true 72 | example: 22500 73 | selector: 74 | text: 75 | spot_id: 76 | name: Spot Id 77 | description: Rand256 pre-defined point. 78 | required: false 79 | example: "Somewhere" 80 | selector: 81 | text: 82 | 83 | vacuum_clean_zone: 84 | name: Vacuum clean zone 85 | description: Start the cleaning operation in the selected areas for the number of repeats indicated. 86 | target: 87 | entity: 88 | integration: mqtt 89 | domain: vacuum 90 | fields: 91 | zone: 92 | name: Zone 93 | description: Optional Array of zones. Each zone is an array of 4 integer values. 94 | required: false 95 | example: "[[23510,25311,25110,26362]]" 96 | selector: 97 | object: 98 | zone_ids: 99 | name: Zone Ids 100 | description: Optional Rand256 predefined zone_ids (array of strings). 101 | required: false 102 | example: "[\"Bar\", \"Trash Can\"]" 103 | selector: 104 | object: 105 | repeats: 106 | name: Repeats 107 | description: Optional Number of cleaning (default 1) repeats for each zone (max 3). 108 | selector: 109 | number: 110 | min: 1 111 | max: 3 112 | 113 | vacuum_clean_segments: 114 | name: Vacuum clean segment 115 | description: Start cleaning of the specified segment(s). 116 | target: 117 | entity: 118 | integration: mqtt 119 | domain: vacuum 120 | fields: 121 | segments: 122 | name: Segments 123 | description: Segments. 124 | required: true 125 | example: "[1,2]" 126 | selector: 127 | object: 128 | repeats: 129 | name: Repeats 130 | description: Number of cleaning repeats for each segment. 131 | selector: 132 | number: 133 | min: 1 134 | max: 3 135 | 136 | vacuum_map_save: 137 | name: Vacuum map save 138 | description: Save the current map. 139 | target: 140 | entity: 141 | integration: mqtt 142 | domain: vacuum 143 | fields: 144 | map_name: 145 | name: Map name 146 | description: Name of the map to save. 147 | required: true 148 | example: "My map" 149 | selector: 150 | text: 151 | 152 | vacuum_map_load: 153 | name: Vacuum map load 154 | description: Load the specified map. 155 | target: 156 | entity: 157 | integration: mqtt 158 | domain: vacuum 159 | fields: 160 | map_name: 161 | name: Map name 162 | description: Name of the map to load. 163 | required: true 164 | example: "My map" 165 | selector: 166 | text: 167 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/snapshots/log_files.py: -------------------------------------------------------------------------------- 1 | """Logs and files colloection 2 | MQTT Vacuum Camera component for Home Assistant 3 | Version: v2024.10.0""" 4 | 5 | import asyncio 6 | from asyncio import gather, get_event_loop 7 | import concurrent.futures 8 | import logging 9 | import os 10 | import shutil 11 | from typing import Any 12 | import zipfile 13 | 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.storage import STORAGE_DIR 16 | from valetudo_map_parser.config.types import SnapshotStore 17 | 18 | from ..const import CAMERA_STORAGE, DOMAIN 19 | from ..utils.files_operations import async_write_file_to_disk, async_write_json_to_disk 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_get_filtered_logs(base_path, directory_path: str, file_name): 25 | """Make a copy of home-assistant.log to home-assistant.tmp""" 26 | log_file_path = os.path.join(base_path, "home-assistant.log") 27 | tmp_log_file_path = os.path.join(directory_path, f"{file_name}.tmp") 28 | 29 | try: 30 | if os.path.exists(log_file_path): 31 | shutil.copyfile(log_file_path, tmp_log_file_path) 32 | 33 | filtered_logs = [] 34 | 35 | if os.path.exists(tmp_log_file_path): 36 | with open(tmp_log_file_path) as log_file: 37 | for line in log_file: 38 | if f"custom_components.{DOMAIN}" in line: 39 | filtered_logs.append(line.strip()) 40 | 41 | # Delete the temporary log file 42 | os.remove(tmp_log_file_path) 43 | 44 | return "\n".join(filtered_logs) 45 | 46 | except FileNotFoundError as e: 47 | _LOGGER.warning("Snapshot Error while processing logs: %s", str(e)) 48 | return "" 49 | 50 | 51 | def zip_logs(storage_dir: str, file_name: str) -> Any: 52 | """Create a ZIP archive""" 53 | zip_file_name = os.path.join(storage_dir, f"{file_name}.zip") 54 | 55 | try: 56 | with zipfile.ZipFile(zip_file_name, "w", zipfile.ZIP_DEFLATED) as zf: 57 | json_file_name = os.path.join(storage_dir, f"{file_name}.json") 58 | log_file_name = os.path.join(storage_dir, f"{file_name}.log") 59 | png_file_name = os.path.join(storage_dir, f"{file_name}.png") 60 | raw_file_name = os.path.join(storage_dir, f"{file_name}.raw") 61 | 62 | # Add the Vacuum JSON file to the ZIP archive 63 | if os.path.exists(json_file_name): 64 | _LOGGER.debug("Adding %s to the ZIP archive", json_file_name) 65 | zf.write(json_file_name, os.path.basename(json_file_name)) 66 | os.remove(json_file_name) 67 | 68 | # Add the HA filtered log file to the ZIP archive 69 | if os.path.exists(log_file_name): 70 | _LOGGER.debug("Adding %s to the ZIP archive", log_file_name) 71 | zf.write(log_file_name, os.path.basename(log_file_name)) 72 | os.remove(log_file_name) 73 | 74 | # Add the PNG file to the ZIP archive 75 | if os.path.exists(png_file_name): 76 | _LOGGER.debug("Adding %s to the ZIP archive", png_file_name) 77 | zf.write(png_file_name, os.path.basename(png_file_name)) 78 | 79 | # Check if the MQTT file_name.raw exists 80 | if os.path.exists(raw_file_name): 81 | _LOGGER.debug("Adding %s to the ZIP archive", raw_file_name) 82 | # Add the .raw file to the ZIP archive 83 | zf.write(raw_file_name, os.path.basename(raw_file_name)) 84 | # Remove the .raw file 85 | os.remove(raw_file_name) 86 | 87 | except Exception as e: 88 | _LOGGER.warning("Error while creating logs ZIP archive: %s", str(e)) 89 | 90 | 91 | async def async_get_data( 92 | base_path: str, storage_path: str, file_name: str, json_data: Any 93 | ) -> Any: 94 | """Get the data to compose the snapshot logs.""" 95 | try: 96 | # Save JSON data to a file 97 | if json_data: 98 | json_file_name = os.path.join(storage_path, f"{file_name}.json") 99 | await async_write_json_to_disk(json_file_name, json_data) 100 | 101 | # Save log data to a file 102 | log_data = await async_get_filtered_logs(base_path, storage_path, file_name) 103 | if log_data: 104 | log_file_name = os.path.join(storage_path, f"{file_name}.log") 105 | await async_write_file_to_disk(log_file_name, log_data) 106 | 107 | except Exception as e: 108 | _LOGGER.warning("Snapshot Error while saving data: %s", str(e)) 109 | 110 | 111 | async def async_logs_store(hass: HomeAssistant, file_name: str) -> None: 112 | """ 113 | Save Vacuum JSON data and filtered logs to a ZIP archive. 114 | """ 115 | # define paths and data 116 | storage_path = confirm_storage_path(hass) 117 | base_path = hass.config.path() 118 | vacuum_json = await SnapshotStore().async_get_vacuum_json(file_name) 119 | try: 120 | # When logger is active. 121 | if (_LOGGER.getEffectiveLevel() > 0) and (_LOGGER.getEffectiveLevel() != 30): 122 | await async_get_data(base_path, storage_path, file_name, vacuum_json) 123 | zip_logs(storage_path, file_name) 124 | if os.path.exists(f"{storage_path}/{file_name}.zip"): 125 | source_path = f"{storage_path}/{file_name}.zip" 126 | destination_path = f"{hass.config.path()}/www/{file_name}.zip" 127 | shutil.copy(source_path, destination_path) 128 | except Exception as e: 129 | _LOGGER.warning("Error while creating logs snapshot: %s", str(e)) 130 | 131 | 132 | def confirm_storage_path(hass) -> str: 133 | """Check if the storage path exists, if not create it.""" 134 | storage_path = hass.config.path(STORAGE_DIR, CAMERA_STORAGE) 135 | if not os.path.exists(storage_path): 136 | try: 137 | os.makedirs(storage_path) 138 | except Exception as e: 139 | _LOGGER.warning("Snapshot Error while creating storage folder: %s", str(e)) 140 | return hass.config.path(STORAGE_DIR) 141 | return storage_path 142 | 143 | 144 | def process_logs(hass: HomeAssistant, file_name: str): 145 | """Process logs for snapshot. 146 | 147 | This is a synchronous wrapper around the async logs storage function, 148 | designed to be called from a thread pool. 149 | """ 150 | # Use asyncio.run which properly manages the event loop lifecycle 151 | try: 152 | return asyncio.run(async_logs_store(hass, file_name)) 153 | except Exception as e: 154 | _LOGGER.error("Error in process_logs: %s", str(e), exc_info=True) 155 | return None 156 | 157 | 158 | async def run_async_save_logs(hass: HomeAssistant, file_name: str) -> None: 159 | """Thread function to process the image snapshots.""" 160 | loop = get_event_loop() 161 | 162 | with concurrent.futures.ThreadPoolExecutor( 163 | max_workers=1, thread_name_prefix=f"{file_name}_LogsSave" 164 | ) as executor: 165 | tasks = [ 166 | loop.run_in_executor( 167 | executor, 168 | process_logs, 169 | hass, 170 | file_name, 171 | ) 172 | ] 173 | logs_save = await gather(*tasks) 174 | 175 | result = ( 176 | logs_save[0] if isinstance(logs_save, list) and len(logs_save) > 0 else None 177 | ) 178 | 179 | return result 180 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/snapshots/snapshot.py: -------------------------------------------------------------------------------- 1 | """Snapshot Version: 2025.3.0b0""" 2 | 3 | import asyncio 4 | from asyncio import gather, get_event_loop 5 | import concurrent.futures 6 | import logging 7 | import os 8 | import shutil 9 | 10 | from homeassistant.helpers.storage import STORAGE_DIR 11 | from valetudo_map_parser.config.types import Any, PilPNG, SnapshotStore 12 | 13 | from ..const import CAMERA_STORAGE 14 | from ..utils.files_operations import async_populate_user_languages 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class Snapshots: 20 | """ 21 | Snapshots class to save the JSON data and the filtered logs to a ZIP archive. 22 | We will use this class to save the JSON data and the filtered logs to a ZIP archive. 23 | """ 24 | 25 | def __init__(self, hass, shared): 26 | self.hass = hass 27 | self._shared = shared 28 | self._directory_path = hass.config.path() 29 | self._store_all = SnapshotStore() 30 | self.storage_path = self.confirm_storage_path(hass) 31 | self.file_name = self._shared.file_name 32 | self.snapshot_img = f"{self.storage_path}/{self.file_name}.png" 33 | self._first_run = True 34 | 35 | @staticmethod 36 | def confirm_storage_path(hass) -> str: 37 | """Check if the storage path exists, if not create it.""" 38 | storage_path = hass.config.path(STORAGE_DIR, CAMERA_STORAGE) 39 | if not os.path.exists(storage_path): 40 | try: 41 | os.makedirs(storage_path) 42 | except Exception as e: 43 | _LOGGER.warning( 44 | "Snapshot Error while creating storage folder: %s", str(e) 45 | ) 46 | return hass.config.path(STORAGE_DIR) 47 | return storage_path 48 | 49 | async def async_take_snapshot(self, json_data: Any, image_data: PilPNG) -> None: 50 | """Camera Automatic Snapshots.""" 51 | # Save the users languages data if the Camera is the first run 52 | if self._first_run: 53 | self._first_run = False 54 | _LOGGER.info("Writing %s users languages data.", self.file_name) 55 | await async_populate_user_languages(self.hass) 56 | await self._store_all.async_set_vacuum_json(self.file_name, json_data) 57 | try: 58 | # Save image ready for snapshot. 59 | image_data.save(self.snapshot_img) 60 | # Copy the image in WWW if user want it. 61 | if self._shared.enable_snapshots: 62 | if os.path.isfile(self.snapshot_img): 63 | shutil.copy( 64 | f"{self.storage_path}/{self.file_name}.png", 65 | f"{self._directory_path}/www/snapshot_{self.file_name}.png", 66 | ) 67 | _LOGGER.debug( 68 | f"\n{self.file_name}: Snapshot image saved in WWW folder." 69 | ) 70 | except IOError as e: 71 | _LOGGER.warning(f"Error Saving {self.file_name}: Snapshot image, {str(e)}") 72 | 73 | def process_snapshot(self, json_data: Any, image_data: PilPNG): 74 | """Process the snapshot. 75 | 76 | This is a synchronous wrapper around the async snapshot function, 77 | designed to be called from a thread pool. 78 | """ 79 | # Use asyncio.run which properly manages the event loop lifecycle 80 | try: 81 | return asyncio.run(self.async_take_snapshot(json_data, image_data)) 82 | except Exception as e: 83 | _LOGGER.error("Error in process_snapshot: %s", str(e), exc_info=True) 84 | return None 85 | 86 | async def run_async_take_snapshot(self, json_data: Any, pil_img: PilPNG) -> None: 87 | """Thread function to process the image snapshots.""" 88 | num_processes = 1 89 | pil_img_list = [pil_img for _ in range(num_processes)] 90 | loop = get_event_loop() 91 | 92 | with concurrent.futures.ThreadPoolExecutor( 93 | max_workers=1, thread_name_prefix=f"{self.file_name}_snapshot" 94 | ) as executor: 95 | tasks = [ 96 | loop.run_in_executor( 97 | executor, 98 | self.process_snapshot, 99 | json_data, 100 | pil_img, 101 | ) 102 | for pil_img in pil_img_list 103 | ] 104 | images = await gather(*tasks) 105 | 106 | if isinstance(images, list) and len(images) > 0: 107 | result = images[0] 108 | else: 109 | result = None 110 | 111 | return result 112 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/translations/zh-Hant.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "vacuum_entity": "吸塵器實體 ID", 7 | "vacuum_config_entry": "吸塵器配置數據", 8 | "vacuum_map": "吸塵器主題", 9 | "vacuum_identifiers": "吸塵器 MQTT ID", 10 | "unique_id": "" 11 | }, 12 | "data_description": { 13 | "vacuum_entity": "吸塵器實體 ID" 14 | }, 15 | "description": "相機設置。", 16 | "title": "MQTT 相機設置" 17 | } 18 | } 19 | }, 20 | "options": { 21 | "step": { 22 | "init": { 23 | "title": "MQTT 相機選項", 24 | "description": "配置相機選項", 25 | "menu_options": { 26 | "image_opt": "圖像設置", 27 | "colours": "顏色配置", 28 | "advanced": "進階選項" 29 | } 30 | }, 31 | "image_opt": { 32 | "title": "圖像設置", 33 | "description": "配置圖像顯示設置", 34 | "menu_options": { 35 | "image_basic_opt": "基本設置", 36 | "status_text": "狀態文本", 37 | "draw_elements": "物件可見性", 38 | "segments_visibility": "分段可見性" 39 | } 40 | }, 41 | "colours": { 42 | "title": "顏色配置", 43 | "description": "配置地圖顏色", 44 | "menu_options": { 45 | "base_colours": "基本顏色", 46 | "rooms_colours_1": "房間 1-8", 47 | "rooms_colours_2": "房間 9-16", 48 | "floor_only": "僅地板", 49 | "transparency": "透明度設置", 50 | "rename_translations": "重命名房間" 51 | } 52 | }, 53 | "transparency": { 54 | "title": "透明度設置", 55 | "description": "配置透明度級別", 56 | "menu_options": { 57 | "base_transparency": "基本透明度", 58 | "rooms_transparency_1": "房間 1-8 透明度", 59 | "rooms_transparency_2": "房間 9-16 透明度", 60 | "floor_transparency": "地板透明度" 61 | } 62 | }, 63 | "advanced": { 64 | "title": "進階選項", 65 | "description": "進階配置選項", 66 | "menu_options": { 67 | "download_logs": "下載日誌", 68 | "map_trims": "地圖裁剪" 69 | } 70 | }, 71 | "map_trims": { 72 | "title": "地圖裁剪選項", 73 | "description": "管理地圖裁剪設置", 74 | "menu_options": { 75 | "reset_map_trims": "重置地圖裁剪", 76 | "save_map_trims": "保存地圖裁剪" 77 | } 78 | }, 79 | "download_logs": { 80 | "title": "下載日誌", 81 | "description": "管理日誌文件", 82 | "menu_options": { 83 | "logs_move": "複製日誌到 WWW", 84 | "logs_remove": "從 WWW 移除日誌" 85 | } 86 | }, 87 | "image_basic_opt": { 88 | "data": { 89 | "rotate_image": "旋轉", 90 | "margins": "圖像邊距。", 91 | "auto_zoom": "啟用自動縮放", 92 | "zoom_lock_ratio": "鎖定縱橫比。", 93 | "aspect_ratio": "圖像縱橫比。", 94 | "enable_www_snapshots": "導出 PNG 快照。" 95 | }, 96 | "data_description": { 97 | "rotate_image": "度數 0, 90, 180, 270", 98 | "margins": "自動邊距裁剪(像素)。", 99 | "aspect_ratio": "圖像縱橫比。" 100 | }, 101 | "description": "相機圖像選項", 102 | "title": "圖像選項" 103 | }, 104 | "image_offset": { 105 | "title": "圖像偏移設置", 106 | "description": "配置圖像偏移值", 107 | "data": { 108 | "offset_top": "頂部偏移", 109 | "offset_bottom": "底部偏移", 110 | "offset_left": "左側偏移", 111 | "offset_right": "右側偏移" 112 | }, 113 | "data_description": { 114 | "offset_top": "從頂部偏移的像素 (0-1000)", 115 | "offset_bottom": "從底部偏移的像素 (0-1000)", 116 | "offset_left": "從左側偏移的像素 (0-1000)", 117 | "offset_right": "從右側偏移的像素 (0-1000)" 118 | } 119 | }, 120 | "status_text": { 121 | "data": { 122 | "show_vac_status": "顯示吸塵器狀態。", 123 | "vac_status_font": "Fira Sans", 124 | "vac_status_size": "文本大小", 125 | "vac_status_position": "文本位置:底部(關閉)", 126 | "color_text": "[255, 255, 255]" 127 | }, 128 | "data_description": { 129 | "vac_status_font": "默認文本字體 Fira Serif。", 130 | "vac_status_size": "默認文本大小 50。", 131 | "color_text": "吸塵器狀態文本顏色。" 132 | }, 133 | "description": "圖像選項", 134 | "title": "狀態文本選項" 135 | }, 136 | "draw_elements": { 137 | "title": "物件可見性", 138 | "description": "配置地圖上可見的元素", 139 | "data": { 140 | "disable_floor": "隱藏地板", 141 | "disable_wall": "隱藏牆壁", 142 | "disable_robot": "隱藏機器人", 143 | "disable_charger": "隱藏充電器", 144 | "disable_virtual_walls": "隱藏虛擬牆", 145 | "disable_restricted_areas": "隱藏限制區域", 146 | "disable_no_mop_areas": "隱藏禁止拖地區域", 147 | "disable_obstacles": "隱藏障礙物", 148 | "disable_path": "隱藏路徑", 149 | "disable_predicted_path": "隱藏預測路徑", 150 | "disable_go_to_target": "隱藏前往目標" 151 | }, 152 | "data_description": { 153 | "disable_floor": "啟用時,地板將不會顯示在地圖上", 154 | "disable_wall": "啟用時,牆壁將不會顯示在地圖上", 155 | "disable_robot": "啟用時,機器人將不會顯示在地圖上", 156 | "disable_charger": "啟用時,充電器將不會顯示在地圖上", 157 | "disable_virtual_walls": "啟用時,虛擬牆將不會顯示在地圖上", 158 | "disable_restricted_areas": "啟用時,限制區域將不會顯示在地圖上", 159 | "disable_no_mop_areas": "啟用時,禁止拖地區域將不會顯示在地圖上", 160 | "disable_obstacles": "啟用時,障礙物將不會顯示在地圖上", 161 | "disable_path": "啟用時,清潔路徑將不會顯示在地圖上", 162 | "disable_predicted_path": "啟用時,預測路徑將不會顯示在地圖上", 163 | "disable_go_to_target": "啟用時,前往目標將不會顯示在地圖上" 164 | } 165 | }, 166 | "segments_visibility": { 167 | "title": "分段可見性", 168 | "description": "配置地圖上可見的房間分段", 169 | "data": { 170 | "disable_room_1": "隱藏房間 1", 171 | "disable_room_2": "隱藏房間 2", 172 | "disable_room_3": "隱藏房間 3", 173 | "disable_room_4": "隱藏房間 4", 174 | "disable_room_5": "隱藏房間 5", 175 | "disable_room_6": "隱藏房間 6", 176 | "disable_room_7": "隱藏房間 7", 177 | "disable_room_8": "隱藏房間 8", 178 | "disable_room_9": "隱藏房間 9", 179 | "disable_room_10": "隱藏房間 10", 180 | "disable_room_11": "隱藏房間 11", 181 | "disable_room_12": "隱藏房間 12", 182 | "disable_room_13": "隱藏房間 13", 183 | "disable_room_14": "隱藏房間 14", 184 | "disable_room_15": "隱藏房間 15" 185 | }, 186 | "data_description": { 187 | "disable_room_1": "啟用時,房間 1 將不會顯示在地圖上", 188 | "disable_room_2": "啟用時,房間 2 將不會顯示在地圖上", 189 | "disable_room_3": "啟用時,房間 3 將不會顯示在地圖上", 190 | "disable_room_4": "啟用時,房間 4 將不會顯示在地圖上", 191 | "disable_room_5": "啟用時,房間 5 將不會顯示在地圖上", 192 | "disable_room_6": "啟用時,房間 6 將不會顯示在地圖上", 193 | "disable_room_7": "啟用時,房間 7 將不會顯示在地圖上", 194 | "disable_room_8": "啟用時,房間 8 將不會顯示在地圖上", 195 | "disable_room_9": "啟用時,房間 9 將不會顯示在地圖上", 196 | "disable_room_10": "啟用時,房間 10 將不會顯示在地圖上", 197 | "disable_room_11": "啟用時,房間 11 將不會顯示在地圖上", 198 | "disable_room_12": "啟用時,房間 12 將不會顯示在地圖上", 199 | "disable_room_13": "啟用時,房間 13 將不會顯示在地圖上", 200 | "disable_room_14": "啟用時,房間 14 將不會顯示在地圖上", 201 | "disable_room_15": "啟用時,房間 15 將不會顯示在地圖上" 202 | } 203 | }, 204 | "base_colours": { 205 | "data": { 206 | "color_charger": "[255, 128, 0]", 207 | "color_move": "[238, 247, 255]", 208 | "color_wall": "[255, 255, 0]", 209 | "color_robot": "[255, 255, 204]", 210 | "color_go_to": "[0, 255, 0]", 211 | "color_no_go": "[255, 0, 0]", 212 | "color_zone_clean": "[255, 255, 255]", 213 | "color_background": "[0, 125, 255]", 214 | "add_base_alpha": "配置透明度:" 215 | }, 216 | "data_description": { 217 | "color_charger": "充電器顏色。", 218 | "color_move": "移動顏色。", 219 | "color_wall": "牆壁顏色。", 220 | "color_robot": "機器人顏色。", 221 | "color_go_to": "前往顏色。", 222 | "color_no_go": "禁止前往顏色。", 223 | "color_zone_clean": "區域清潔顏色。", 224 | "color_background": "背景顏色。" 225 | }, 226 | "description": "相機顏色選項。", 227 | "title": "基本顏色設置" 228 | }, 229 | "alpha_1": { 230 | "data": { 231 | "alpha_charger": "充電器透明度", 232 | "alpha_move": "移動透明度", 233 | "alpha_wall": "牆壁透明度", 234 | "alpha_robot": "機器人透明度", 235 | "alpha_go_to": "前往區域透明度", 236 | "alpha_no_go": "禁止前往區域透明度", 237 | "alpha_zone_clean": "區域清潔透明度", 238 | "alpha_background": "背景透明度", 239 | "alpha_text": "文本透明度" 240 | }, 241 | "description": "相機選項。透明度 1", 242 | "title": "基本顏色透明度通道" 243 | }, 244 | "floor_only": { 245 | "data": { 246 | "color_room_0": "[135, 206, 250]", 247 | "add_room_1_alpha": "配置透明度:" 248 | }, 249 | "data_description": { 250 | "color_room_0": "### **地板顏色**" 251 | } 252 | }, 253 | "rooms_colours_1": { 254 | "data": { 255 | "color_room_0": "[135, 206, 250]", 256 | "color_room_1": "[176, 226, 255]", 257 | "color_room_2": "[165, 105, 18]", 258 | "color_room_3": "[164, 211, 238]", 259 | "color_room_4": "[141, 182, 205]", 260 | "color_room_5": "[96, 123, 139]", 261 | "color_room_6": "[224, 255, 255]", 262 | "color_room_7": "[209, 238, 238]", 263 | "add_room_1_alpha": "配置透明度:" 264 | }, 265 | "data_description": { 266 | "color_room_0": "房間 1", 267 | "color_room_1": "房間 2", 268 | "color_room_2": "房間 3", 269 | "color_room_3": "房間 4", 270 | "color_room_4": "房間 5", 271 | "color_room_5": "房間 6", 272 | "color_room_6": "房間 7", 273 | "color_room_7": "房間 8" 274 | }, 275 | "description": "相機顏色選項。", 276 | "title": "房間 1 至 8 顏色" 277 | }, 278 | "rooms_colours_2": { 279 | "data": { 280 | "color_room_8": "[180, 205, 205]", 281 | "color_room_9": "[122, 139, 139]", 282 | "color_room_10": "[175, 238, 238]", 283 | "color_room_11": "[84, 153, 199]", 284 | "color_room_12": "[133, 193, 233]", 285 | "color_room_13": "[245, 176, 65]", 286 | "color_room_14": "[82, 190, 128]", 287 | "color_room_15": "[72, 201, 176]", 288 | "add_room_2_alpha": "配置透明度:" 289 | }, 290 | "data_description": { 291 | "color_room_8": "房間 9", 292 | "color_room_9": "房間 10", 293 | "color_room_10": "房間 11", 294 | "color_room_11": "房間 12", 295 | "color_room_12": "房間 13", 296 | "color_room_13": "房間 14", 297 | "color_room_14": "房間 15", 298 | "color_room_15": "房間 16" 299 | }, 300 | "description": "相機顏色選項。", 301 | "title": "房間 9 至 15 顏色" 302 | }, 303 | "alpha_floor": { 304 | "data": { 305 | "alpha_room_0": "地板顏色" 306 | } 307 | }, 308 | "alpha_2": { 309 | "data": { 310 | "alpha_room_0": "房間 1", 311 | "alpha_room_1": "房間 2", 312 | "alpha_room_2": "房間 3", 313 | "alpha_room_3": "房間 4", 314 | "alpha_room_4": "房間 5", 315 | "alpha_room_5": "房間 6", 316 | "alpha_room_6": "房間 7", 317 | "alpha_room_7": "房間 8" 318 | }, 319 | "description": "相機選項。透明度 2", 320 | "title": "房間顏色透明度通道" 321 | }, 322 | "alpha_3": { 323 | "data": { 324 | "alpha_room_8": "房間 9", 325 | "alpha_room_9": "房間 10", 326 | "alpha_room_10": "房間 11", 327 | "alpha_room_11": "房間 12", 328 | "alpha_room_12": "房間 13", 329 | "alpha_room_13": "房間 14", 330 | "alpha_room_14": "房間 15", 331 | "alpha_room_15": "房間 16" 332 | }, 333 | "description": "相機選項。透明度 3", 334 | "title": "房間顏色透明度通道" 335 | } 336 | } 337 | }, 338 | "selector": { 339 | "trim_actions": { 340 | "options": { 341 | "save": "保存更改", 342 | "reset": "重置為檢測到的值", 343 | "delete": "刪除所有裁剪" 344 | } 345 | }, 346 | "vacuum_status": { 347 | "options": { 348 | "connected": "已連接", 349 | "disconnected": "已斷開", 350 | "charging": "充電中", 351 | "cleaning": "清掃中", 352 | "docked": "已停靠", 353 | "idle": "空閒", 354 | "paused": "已暫停", 355 | "returning": "返回中" 356 | } 357 | } 358 | }, 359 | "services": { 360 | "reload": { 361 | "name": "重新載入", 362 | "description": "重新載入 MQTT 吸塵器相機平台。" 363 | }, 364 | "turn_off": { 365 | "name": "[%key:common::action::turn_off%]", 366 | "description": "關閉相機。" 367 | }, 368 | "turn_on": { 369 | "name": "[%key:common::action::turn_on%]", 370 | "description": "開啟相機。" 371 | }, 372 | "reset_trims": { 373 | "name": "重置圖像裁剪", 374 | "description": "重置地圖裁剪。" 375 | }, 376 | "snapshot": { 377 | "name": "拍攝快照", 378 | "description": "從相機拍攝快照。", 379 | "fields": { 380 | "filename": { 381 | "name": "文件名", 382 | "description": "文件名模板。可用變量是 `entity_id`。" 383 | } 384 | } 385 | } 386 | }, 387 | "exceptions": { 388 | "no_entity_id_provided": { 389 | "message": "配置中缺少 entity_id。" 390 | }, 391 | "missing_zone_or_zone_ids": { 392 | "message": "必須提供 'zone' 或 'zone_ids'。" 393 | }, 394 | "zone_must_be_list": { 395 | "message": "參數 'Zone' 必須是列表。" 396 | }, 397 | "zoneid_must_be_list": { 398 | "message": "參數 'Zone_Ids' 必須是列表。" 399 | }, 400 | "zone_list_length": { 401 | "message": "'Zone' 必須至少有 1 個元素,該元素包含 4 個整數的列表。" 402 | } 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/__init__.py -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/camera/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/camera/__init__.py -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/camera/camera_processing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Multiprocessing module 3 | Version: 2025.3.0b1 4 | This module provide the image multiprocessing in order to 5 | avoid the overload of the main_thread of Home Assistant. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import asyncio 11 | from io import BytesIO 12 | from typing import Any 13 | 14 | from PIL import Image 15 | import aiohttp 16 | from aiohttp.abc import HTTPException 17 | from valetudo_map_parser.config.drawable import Drawable as Draw 18 | from valetudo_map_parser.config.types import Color, JsonType, PilPNG 19 | from valetudo_map_parser.hypfer_handler import HypferMapImageHandler 20 | from valetudo_map_parser.rand25_handler import ReImageHandler 21 | 22 | from custom_components.mqtt_vacuum_camera.const import LOGGER, NOT_STREAMING_STATES 23 | from custom_components.mqtt_vacuum_camera.utils.language_cache import LanguageCache 24 | from custom_components.mqtt_vacuum_camera.utils.status_text import StatusText 25 | from custom_components.mqtt_vacuum_camera.utils.thread_pool import ThreadPoolManager 26 | 27 | LOGGER.propagate = True 28 | 29 | 30 | class CameraProcessor: 31 | """ 32 | CameraProcessor class to process the image data from the Vacuum Json data. 33 | """ 34 | 35 | def __init__(self, hass, camera_shared): 36 | self.hass = hass 37 | self._map_handler = HypferMapImageHandler(camera_shared) 38 | self._re_handler = ReImageHandler(camera_shared) 39 | self._shared = camera_shared 40 | self._file_name = self._shared.file_name 41 | self._translations_path = self.hass.config.path( 42 | "custom_components/mqtt_vacuum_camera/translations/" 43 | ) 44 | self._status_text = StatusText(self.hass, self._shared) 45 | self._thread_pool = ThreadPoolManager.get_instance() 46 | self._language_cache = LanguageCache.get_instance() 47 | 48 | async def async_process_valetudo_data(self, parsed_json: JsonType) -> PilPNG | None: 49 | """ 50 | Compose the Camera Image from the Vacuum Json data. 51 | :param parsed_json: 52 | :return pil_img: 53 | """ 54 | if parsed_json is not None: 55 | pil_img = await self._map_handler.async_get_image_from_json( 56 | m_json=parsed_json, 57 | ) 58 | 59 | if self._shared.export_svg: 60 | self._shared.export_svg = False 61 | 62 | if pil_img is not None: 63 | if self._shared.map_rooms is None: 64 | self._shared.map_rooms = ( 65 | await self._map_handler.async_get_rooms_attributes() 66 | ) 67 | if self._shared.map_rooms: 68 | LOGGER.debug( 69 | "%s: State attributes rooms updated", self._file_name 70 | ) 71 | 72 | if self._shared.attr_calibration_points is None: 73 | self._shared.attr_calibration_points = ( 74 | self._map_handler.get_calibration_data() 75 | ) 76 | 77 | self._shared.vac_json_id = self._map_handler.get_json_id() 78 | 79 | if not self._shared.charger_position: 80 | self._shared.charger_position = ( 81 | self._map_handler.get_charger_position() 82 | ) 83 | 84 | self._shared.current_room = self._map_handler.get_robot_position() 85 | self._shared.map_rooms = self._map_handler.room_propriety 86 | if self._shared.map_rooms: 87 | LOGGER.debug("%s: State attributes rooms updated", self._file_name) 88 | if not self._shared.image_size: 89 | self._shared.image_size = self._map_handler.get_img_size() 90 | 91 | update_vac_state = self._shared.vacuum_state 92 | if not self._shared.snapshot_take and ( 93 | update_vac_state in NOT_STREAMING_STATES 94 | ): 95 | # suspend image processing if we are at the next frame. 96 | if ( 97 | self._shared.frame_number 98 | != self._map_handler.get_frame_number() 99 | ): 100 | self._shared.image_grab = False 101 | LOGGER.info( 102 | "Suspended the camera data processing for: %s.", 103 | self._file_name, 104 | ) 105 | # take a snapshot 106 | self._shared.snapshot_take = True 107 | return pil_img 108 | LOGGER.debug("%s: No Json, returned None.", self._file_name) 109 | return None 110 | 111 | async def async_process_rand256_data(self, parsed_json: JsonType) -> PilPNG | None: 112 | """ 113 | Process the image data from the RAND256 Json data. 114 | :param parsed_json: 115 | :return: pil_img 116 | """ 117 | if parsed_json is not None: 118 | pil_img = await self._re_handler.get_image_from_rrm( 119 | m_json=parsed_json, 120 | destinations=self._shared.destinations, 121 | ) 122 | 123 | if pil_img is not None: 124 | if self._shared.map_rooms is None: 125 | destinations = self._shared.destinations 126 | if destinations is not None: 127 | ( 128 | self._shared.map_rooms, 129 | self._shared.map_pred_zones, 130 | self._shared.map_pred_points, 131 | ) = await self._re_handler.get_rooms_attributes(destinations) 132 | if self._shared.map_rooms: 133 | LOGGER.debug( 134 | "%s: State attributes rooms updated", self._file_name 135 | ) 136 | 137 | if self._shared.attr_calibration_points is None: 138 | self._shared.attr_calibration_points = ( 139 | self._re_handler.get_calibration_data(self._shared.image_rotate) 140 | ) 141 | 142 | self._shared.vac_json_id = self._re_handler.get_json_id() 143 | 144 | if not self._shared.charger_position: 145 | self._shared.charger_position = ( 146 | self._re_handler.get_charger_position() 147 | ) 148 | self._shared.current_room = self._re_handler.get_robot_position() 149 | if not self._shared.image_size: 150 | self._shared.image_size = self._re_handler.get_img_size() 151 | 152 | update_vac_state = self._shared.vacuum_state 153 | if not self._shared.snapshot_take and ( 154 | update_vac_state in NOT_STREAMING_STATES 155 | ): 156 | # suspend image processing if we are at the next frame. 157 | LOGGER.info( 158 | "Suspended the camera data processing for: %s.", self._file_name 159 | ) 160 | # take a snapshot 161 | self._shared.snapshot_take = True 162 | self._shared.image_grab = False 163 | return pil_img 164 | return None 165 | 166 | def process_valetudo_data(self, parsed_json: JsonType): 167 | """Process the image data from the Vacuum Json data. 168 | 169 | This is a synchronous wrapper around the async processing functions, 170 | designed to be called from a thread pool. 171 | """ 172 | # Use asyncio.run which properly manages the event loop lifecycle 173 | try: 174 | if self._shared.is_rand: 175 | return asyncio.run(self.async_process_rand256_data(parsed_json)) 176 | else: 177 | return asyncio.run(self.async_process_valetudo_data(parsed_json)) 178 | except Exception as e: 179 | LOGGER.error("Error in process_valetudo_data: %s", str(e), exc_info=True) 180 | return None 181 | 182 | async def run_async_process_valetudo_data( 183 | self, parsed_json: JsonType 184 | ) -> PilPNG | None: 185 | """Thread function to process the image data from the Vacuum Json data using persistent thread pool.""" 186 | try: 187 | # Use the persistent thread pool instead of creating a new one each time 188 | result = await self._thread_pool.run_in_executor( 189 | f"{self._file_name}_camera", self.process_valetudo_data, parsed_json 190 | ) 191 | 192 | if result is not None: 193 | LOGGER.debug("%s: Camera frame processed.", self._file_name) 194 | 195 | return result 196 | except Exception as e: 197 | LOGGER.error("Error processing vacuum data: %s", str(e), exc_info=True) 198 | return None 199 | 200 | def get_frame_number(self): 201 | """Get the frame number.""" 202 | return self._map_handler.get_frame_number() - 2 203 | 204 | # Functions to Thread the image text processing. 205 | async def async_draw_image_text( 206 | self, pil_img: PilPNG, color: Color, font: str, img_top: bool = True 207 | ) -> PilPNG: 208 | """Draw text on the image.""" 209 | if self._shared.user_language is None: 210 | self._shared.user_language = ( 211 | await self._language_cache.get_active_user_language(self.hass) 212 | ) 213 | if pil_img is not None: 214 | text, size = self._status_text.get_status_text(pil_img) 215 | Draw.status_text( 216 | image=pil_img, 217 | size=size, 218 | color=color, 219 | status=text, 220 | path_font=font, 221 | position=img_top, 222 | ) 223 | return pil_img 224 | 225 | def process_status_text( 226 | self, pil_img: PilPNG, color: Color, font: str, img_top: bool = True 227 | ): 228 | """Process the status text on the image. 229 | 230 | This is a synchronous wrapper around the async text drawing function, 231 | designed to be called from a thread pool. 232 | """ 233 | # Use asyncio.run which properly manages the event loop lifecycle 234 | try: 235 | return asyncio.run( 236 | self.async_draw_image_text(pil_img, color, font, img_top) 237 | ) 238 | except Exception as e: 239 | LOGGER.error("Error in process_status_text: %s", str(e), exc_info=True) 240 | return pil_img # Return original image if text processing fails 241 | 242 | async def run_async_draw_image_text(self, pil_img: PilPNG, color: Color) -> PilPNG: 243 | """Thread function to process the image text using persistent thread pool.""" 244 | try: 245 | # Use the persistent thread pool instead of creating a new one each time 246 | result = await self._thread_pool.run_in_executor( 247 | f"{self._file_name}_camera_text", 248 | self.process_status_text, 249 | pil_img, 250 | color, 251 | self._shared.vacuum_status_font, 252 | self._shared.vacuum_status_position, 253 | ) 254 | return result 255 | except Exception as e: 256 | LOGGER.error("Error processing image text: %s", str(e), exc_info=True) 257 | return pil_img # Return original image if text processing fails 258 | 259 | @staticmethod 260 | async def download_image(url: str): 261 | """ 262 | Asynchronously download an image without blocking. 263 | 264 | Args: 265 | url (str): The URL to download the image from. 266 | 267 | Returns: 268 | Image: The downloaded image in jpeg format. 269 | """ 270 | 271 | try: 272 | timeout = aiohttp.ClientTimeout(total=3) # Set the timeout to 3 seconds 273 | async with aiohttp.ClientSession(timeout=timeout) as session: 274 | async with session.get(url) as response: 275 | if response.status == 200: 276 | obstacle_image = await response.read() 277 | LOGGER.debug("Image downloaded successfully!") 278 | return obstacle_image 279 | raise HTTPException( 280 | text="Failed to download the Obstacle image.", 281 | reason=response.reason, 282 | ) 283 | except aiohttp.ClientTimeoutError as e: 284 | LOGGER.warning( 285 | "Timeout error occurred: %s", 286 | e, 287 | exc_info=True, 288 | ) 289 | return None 290 | except asyncio.TimeoutError as e: 291 | LOGGER.error("Error downloading image: %s", e, exc_info=True) 292 | return None 293 | 294 | # noinspection PyTypeChecker 295 | async def async_open_image(self, obstacle_image: Any) -> Image.Image: 296 | """ 297 | Asynchronously open an image file using the persistent thread pool. 298 | Args: 299 | obstacle_image (Any): image file bytes or jpeg format. 300 | 301 | Returns: 302 | Image.Image: PIL image. 303 | """ 304 | try: 305 | # Use BytesIO to convert bytes to a file-like object 306 | bytes_io = BytesIO(obstacle_image) 307 | 308 | # Use the persistent thread pool 309 | pil_img = await self._thread_pool.run_in_executor( 310 | f"{self._file_name}_camera", Image.open, bytes_io 311 | ) 312 | return pil_img 313 | except Exception as e: 314 | LOGGER.error("Error opening image: %s", str(e), exc_info=True) 315 | raise 316 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/camera/camera_services.py: -------------------------------------------------------------------------------- 1 | """Camera-related services for the MQTT Vacuum Camera integration.""" 2 | 3 | import asyncio 4 | 5 | import async_timeout 6 | from homeassistant.config_entries import ConfigEntryState 7 | from homeassistant.const import SERVICE_RELOAD 8 | from homeassistant.core import HomeAssistant, ServiceCall 9 | 10 | from ...common import get_entity_id 11 | from ...const import DOMAIN, LOGGER 12 | from ...utils.files_operations import async_clean_up_all_auto_crop_files 13 | 14 | 15 | async def reset_trims(call: ServiceCall, hass: HomeAssistant) -> None: 16 | """Action Reset Map Trims.""" 17 | LOGGER.debug("Resetting trims for %s", DOMAIN) 18 | try: 19 | await async_clean_up_all_auto_crop_files(hass) 20 | await hass.services.async_call(DOMAIN, SERVICE_RELOAD) 21 | hass.bus.async_fire(f"event_{DOMAIN}_reset_trims", context=call.context) 22 | except ValueError as err: 23 | LOGGER.error("Error resetting trims: %s", err, exc_info=True) 24 | 25 | 26 | async def reload_camera_config(call: ServiceCall, hass: HomeAssistant) -> None: 27 | """Reload the camera platform for all entities in the integration.""" 28 | 29 | LOGGER.debug("Reloading the config entry for all %s entities", DOMAIN) 30 | camera_entries = hass.config_entries.async_entries(DOMAIN) 31 | total_entries = len(camera_entries) 32 | processed = 0 33 | 34 | for camera_entry in camera_entries: 35 | processed += 1 36 | LOGGER.info("Processing entry %r / %r", processed, total_entries) 37 | if camera_entry.state == ConfigEntryState.LOADED: 38 | try: 39 | with async_timeout.timeout(10): 40 | LOGGER.debug("Reloading entry: %s", camera_entry.entry_id) 41 | hass.config_entries.async_schedule_reload(camera_entry.entry_id) 42 | except asyncio.TimeoutError: 43 | LOGGER.error( 44 | "Timeout processing entry %s", camera_entry.entry_id, exc_info=True 45 | ) 46 | except ValueError as err: 47 | LOGGER.error( 48 | "Error processing entry %s: %s", camera_entry.entry_id, err 49 | ) 50 | continue 51 | else: 52 | LOGGER.debug("Skipping entry %s as it is NOT_LOADED", camera_entry.entry_id) 53 | 54 | hass.bus.async_fire( 55 | f"event_{DOMAIN}_reloaded", 56 | event_data={ 57 | "processed": processed, 58 | "total": total_entries, 59 | }, 60 | context=call.context, 61 | ) 62 | 63 | 64 | async def obstacle_view(call: ServiceCall, hass: HomeAssistant) -> None: 65 | """Action to download and show the obstacles in the maps.""" 66 | coordinates_x = call.data.get("coordinates_x") 67 | coordinates_y = call.data.get("coordinates_y") 68 | 69 | # attempt to get the entity_id or device. 70 | entity_id = call.data.get("entity_id") 71 | device_id = call.data.get("device_id") 72 | # resolve the entity_id if not provided. 73 | camera_entity_id = get_entity_id(entity_id, device_id, hass, "camera")[0] 74 | 75 | LOGGER.debug("Obstacle view for %s", camera_entity_id) 76 | LOGGER.debug( 77 | "Firing event to search and view obstacle at coordinates %r, %r", 78 | coordinates_x, 79 | coordinates_y, 80 | ) 81 | hass.bus.async_fire( 82 | event_type=f"{DOMAIN}_obstacle_coordinates", 83 | event_data={ 84 | "entity_id": camera_entity_id, 85 | "coordinates": {"x": coordinates_x, "y": coordinates_y}, 86 | }, 87 | context=call.context, 88 | ) 89 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/connection/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/connection/__init__.py -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/fonts/Inter-VF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/fonts/Inter-VF.ttf -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/fonts/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/fonts/Lato-Regular.ttf -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/fonts/MPLUSRegular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/fonts/MPLUSRegular.ttf -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/fonts/NotoKufiArabic-VF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/fonts/NotoKufiArabic-VF.ttf -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/fonts/NotoSansCJKhk-VF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/fonts/NotoSansCJKhk-VF.ttf -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/fonts/NotoSansKhojki.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/fonts/NotoSansKhojki.ttf -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/room_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Room Manager for MQTT Vacuum Camera. 3 | This module provides optimized room renaming operations. 4 | Version: 2025.5.0 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import asyncio 10 | import copy 11 | from dataclasses import dataclass 12 | import json 13 | import logging 14 | import os 15 | from typing import List, Optional, Tuple 16 | 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers.storage import STORAGE_DIR 19 | from valetudo_map_parser.config.types import RoomStore 20 | 21 | from ..const import CAMERA_STORAGE 22 | from .language_cache import LanguageCache 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | @dataclass 28 | class RoomInfo: 29 | """Class to store room information.""" 30 | 31 | room_id: str 32 | name: str 33 | 34 | @classmethod 35 | def from_dict(cls, room_id: str, data: dict) -> RoomInfo: 36 | """Create a RoomInfo instance from a dictionary.""" 37 | return cls(room_id=room_id, name=data.get("name", f"Room {room_id}")) 38 | 39 | 40 | class RoomManager: 41 | """ 42 | A class that manages room operations with optimized I/O. 43 | """ 44 | 45 | def __init__(self, hass: HomeAssistant): 46 | """Initialize the RoomManager.""" 47 | self.hass = hass 48 | self.language_cache = LanguageCache.get_instance() 49 | 50 | async def write_vacuum_id(self, vacuum_id: str) -> None: 51 | """ 52 | Write the vacuum_id to a JSON file. 53 | 54 | Args: 55 | vacuum_id: The vacuum ID to write 56 | """ 57 | if not vacuum_id: 58 | _LOGGER.warning("No vacuum_id provided.") 59 | return 60 | 61 | json_path = f"{self.hass.config.path(STORAGE_DIR, CAMERA_STORAGE)}/rooms_colours_description.json" 62 | _LOGGER.debug("Writing vacuum_id: %s to %s", vacuum_id, json_path) 63 | 64 | # Data to be written 65 | data = {"vacuum_id": vacuum_id} 66 | 67 | # Write data to a JSON file 68 | try: 69 | await asyncio.to_thread( 70 | os.makedirs, os.path.dirname(json_path), exist_ok=True 71 | ) 72 | await self._write_json_file(json_path, data) 73 | 74 | if await asyncio.to_thread(os.path.exists, json_path): 75 | _LOGGER.info("vacuum_id saved: %s", vacuum_id) 76 | else: 77 | _LOGGER.warning("Error saving vacuum_id: %s", vacuum_id) 78 | except Exception as e: 79 | _LOGGER.warning("Error writing vacuum_id: %s", str(e), exc_info=True) 80 | 81 | async def rename_room_descriptions(self, vacuum_id: str) -> bool: 82 | """ 83 | Add room names to the room descriptions in the translations. 84 | This is an optimized version that reduces I/O operations. 85 | 86 | Args: 87 | vacuum_id: The vacuum ID 88 | 89 | Returns: 90 | True if successful, False otherwise 91 | """ 92 | # Load the room data 93 | rooms = RoomStore(vacuum_id) 94 | room_data = rooms.get_rooms() 95 | 96 | if not room_data: 97 | _LOGGER.warning( 98 | "Vacuum ID: %s does not support Rooms! Aborting room name addition.", 99 | vacuum_id, 100 | ) 101 | return False 102 | 103 | # Save the vacuum_id to a JSON file 104 | await self.write_vacuum_id(vacuum_id) 105 | 106 | # Initialize the language cache if needed - only during room renaming 107 | if not self.language_cache.is_initialized(): 108 | await self.language_cache.initialize(self.hass) 109 | _LOGGER.info("Language cache initialized for room renaming") 110 | 111 | # Get the languages to modify 112 | languages = await self.language_cache.get_all_languages() 113 | if not languages: 114 | languages = ["en"] 115 | 116 | edit_path = self.hass.config.path( 117 | "custom_components/mqtt_vacuum_camera/translations" 118 | ) 119 | _LOGGER.info("Editing the translations file for languages: %s", languages) 120 | 121 | # Load all translation files in one batch 122 | data_list = await self.language_cache.load_translations_json( 123 | self.hass, languages 124 | ) 125 | 126 | # If any translations are missing, fall back to English 127 | if None in data_list: 128 | _LOGGER.warning( 129 | "Translation for some languages not found. Falling back to English." 130 | ) 131 | en_translation = await self.language_cache.load_translation(self.hass, "en") 132 | if en_translation: 133 | # Replace None values with English translation 134 | data_list = [ 135 | data if data is not None else en_translation for data in data_list 136 | ] 137 | 138 | # Process room data 139 | room_list = list(room_data.items()) 140 | 141 | # Batch all modifications to reduce I/O 142 | modifications = [] 143 | 144 | for idx, data in enumerate(data_list): 145 | if data is None: 146 | continue 147 | 148 | lang = languages[idx] if isinstance(languages, list) else languages 149 | modified_data = await self._modify_translation_data(data, room_list) 150 | 151 | if modified_data: 152 | modifications.append((lang, modified_data)) 153 | 154 | # Write all modified files in one batch 155 | tasks = [] 156 | for lang, data in modifications: 157 | file_path = os.path.join(edit_path, f"{lang}.json") 158 | tasks.append(self._write_json_file(file_path, data)) 159 | 160 | if tasks: 161 | await asyncio.gather(*tasks) 162 | _LOGGER.info( 163 | "Room names added to the room descriptions in %d translations.", 164 | len(tasks), 165 | ) 166 | 167 | return True 168 | 169 | @staticmethod 170 | async def _modify_translation_data( 171 | data: dict, room_list: List[Tuple[str, dict]] 172 | ) -> Optional[dict]: 173 | """ 174 | Modify translation data with room information. 175 | 176 | Args: 177 | data: The translation data 178 | room_list: List of room information tuples 179 | 180 | Returns: 181 | Modified translation data or None if no modifications 182 | """ 183 | if data is None: 184 | return None 185 | 186 | # Make a deep copy to avoid mutating the cached object 187 | modified_data = copy.deepcopy(data) 188 | 189 | # Ensure the base structure exists 190 | options = modified_data.setdefault("options", {}) 191 | step = options.setdefault("step", {}) 192 | 193 | # Default room colors from en.json 194 | default_room_colors = { 195 | "color_room_0": "[135, 206, 250]", # Floor/Room 1 196 | "color_room_1": "[176, 226, 255]", # Room 2 197 | "color_room_2": "[165, 105, 18]", # Room 3 198 | "color_room_3": "[164, 211, 238]", # Room 4 199 | "color_room_4": "[141, 182, 205]", # Room 5 200 | "color_room_5": "[96, 123, 139]", # Room 6 201 | "color_room_6": "[224, 255, 255]", # Room 7 202 | "color_room_7": "[209, 238, 238]", # Room 8 203 | "color_room_8": "[180, 205, 205]", # Room 9 204 | "color_room_9": "[122, 139, 139]", # Room 10 205 | "color_room_10": "[175, 238, 238]", # Room 11 206 | "color_room_11": "[84, 153, 199]", # Room 12 207 | "color_room_12": "[133, 193, 233]", # Room 13 208 | "color_room_13": "[245, 176, 65]", # Room 14 209 | "color_room_14": "[82, 190, 128]", # Room 15 210 | "color_room_15": "[72, 201, 176]", # Room 16 211 | } 212 | 213 | # Default room descriptions 214 | default_room_descriptions = { 215 | "color_room_0": "Room 1", 216 | "color_room_1": "Room 2", 217 | "color_room_2": "Room 3", 218 | "color_room_3": "Room 4", 219 | "color_room_4": "Room 5", 220 | "color_room_5": "Room 6", 221 | "color_room_6": "Room 7", 222 | "color_room_7": "Room 8", 223 | "color_room_8": "Room 9", 224 | "color_room_9": "Room 10", 225 | "color_room_10": "Room 11", 226 | "color_room_11": "Room 12", 227 | "color_room_12": "Room 13", 228 | "color_room_13": "Room 14", 229 | "color_room_14": "Room 15", 230 | "color_room_15": "Room 16", 231 | } 232 | 233 | # Modify the "data_description" keys for rooms_colours_1 and rooms_colours_2 234 | for i in range(1, 3): 235 | room_key = f"rooms_colours_{i}" 236 | # For rooms_colours_1 use rooms 0-7, for rooms_colours_2 use 8-15 237 | start_index = 0 if i == 1 else 8 238 | end_index = 8 if i == 1 else 16 239 | 240 | # Ensure the room_key section exists 241 | room_section = step.setdefault(room_key, {}) 242 | 243 | # Ensure data section exists with default values 244 | data_section = room_section.setdefault("data", {}) 245 | for j in range(start_index, end_index): 246 | color_key = f"color_room_{j}" 247 | if color_key not in data_section and color_key in default_room_colors: 248 | data_section[color_key] = default_room_colors[color_key] 249 | 250 | # Ensure data_description section exists 251 | data_description = room_section.setdefault("data_description", {}) 252 | 253 | for j in range(start_index, end_index): 254 | color_key = f"color_room_{j}" 255 | if j < len(room_list): 256 | room_id, room_info = room_list[j] 257 | # Get the room name; if missing, fallback to a default name 258 | room_name = room_info.get("name", f"Room {room_id}") 259 | data_description[color_key] = ( 260 | f"### **RoomID {room_id} {room_name}**" 261 | ) 262 | else: 263 | # Use default description or empty string if no room data 264 | data_description[color_key] = default_room_descriptions.get( 265 | color_key, "" 266 | ) 267 | 268 | # Modify the "data" keys for alpha_2 and alpha_3 269 | for i in range(2, 4): 270 | alpha_key = f"alpha_{i}" 271 | start_index = 0 if i == 2 else 8 272 | end_index = 8 if i == 2 else 16 273 | 274 | # Ensure the alpha_key section exists 275 | alpha_section = step.setdefault(alpha_key, {}) 276 | alpha_data = alpha_section.setdefault("data", {}) 277 | 278 | for j in range(start_index, end_index): 279 | alpha_room_key = f"alpha_room_{j}" 280 | if j < len(room_list): 281 | room_id, room_info = room_list[j] 282 | room_name = room_info.get("name", f"Room {room_id}") 283 | alpha_data[alpha_room_key] = f"RoomID {room_id} {room_name}" 284 | else: 285 | # Use default description or empty string if no room data 286 | alpha_data[alpha_room_key] = default_room_descriptions.get( 287 | f"color_room_{j}", "" 288 | ) 289 | 290 | return modified_data 291 | 292 | async def _write_json_file(self, file_path: str, data: dict) -> None: 293 | """ 294 | Write JSON data to a file asynchronously. 295 | 296 | Args: 297 | file_path: The file path 298 | data: The data to write 299 | """ 300 | try: 301 | await asyncio.to_thread(self._write_json, file_path, data) 302 | _LOGGER.debug("Successfully wrote translation file: %s", file_path) 303 | except Exception as e: 304 | _LOGGER.warning( 305 | "Error writing translation file %s: %s", 306 | file_path, 307 | str(e), 308 | exc_info=True, 309 | ) 310 | 311 | @staticmethod 312 | def _write_json(file_path: str, data: dict) -> None: 313 | """ 314 | Write JSON data to a file (to be called via asyncio.to_thread). 315 | 316 | Args: 317 | file_path: The file path 318 | data: The data to write 319 | """ 320 | with open(file_path, "w") as file: 321 | json.dump(data, file, indent=2) 322 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/status_text.py: -------------------------------------------------------------------------------- 1 | """ 2 | Version: 2025.3.0b10 3 | Status text of the vacuum cleaners. 4 | Clas to handle the status text of the vacuum cleaners. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import json 10 | 11 | from valetudo_map_parser.config.types import JsonType, PilPNG 12 | 13 | from ..const import DOMAIN, LOGGER 14 | 15 | LOGGER.propagate = True 16 | 17 | 18 | class StatusText: 19 | """ 20 | Status text of the vacuum cleaners. 21 | """ 22 | 23 | def __init__(self, hass, camera_shared): 24 | self.hass = hass 25 | self._shared = camera_shared 26 | self.file_name = self._shared.file_name 27 | self._translations_path = hass.config.path( 28 | f"custom_components/{DOMAIN}/translations/" 29 | ) 30 | 31 | def load_translations(self, language: str) -> JsonType: 32 | """ 33 | Load the user selected language json file and return it. 34 | """ 35 | file_name = f"{language}.json" 36 | file_path = f"{self._translations_path}/{file_name}" 37 | try: 38 | with open(file_path) as file: 39 | translations = json.load(file) 40 | except FileNotFoundError: 41 | LOGGER.warning( 42 | "%s Not found. Report to the author that %s is missing.", 43 | file_path, 44 | language, 45 | ) 46 | return None 47 | except json.JSONDecodeError: 48 | LOGGER.warning("%s is not a valid JSON file.", file_path, exc_info=True) 49 | return None 50 | return translations 51 | 52 | def get_vacuum_status_translation(self, language: str) -> any: 53 | """ 54 | Get the vacuum status translation. 55 | @param language: String IT, PL, DE, ES, FR, EN. 56 | @return: Json data or None. 57 | """ 58 | translations = self.load_translations(language) 59 | 60 | # Check if the translations file is loaded. 61 | if translations is None: 62 | return None 63 | 64 | vacuum_status_options = ( 65 | translations.get("selector", {}).get("vacuum_status", {}).get("options", {}) 66 | ) 67 | return vacuum_status_options 68 | 69 | def translate_vacuum_status(self) -> str: 70 | """Return the translated status.""" 71 | status = self._shared.vacuum_state 72 | language = self._shared.user_language 73 | if not language: 74 | return status.capitalize() 75 | translations = self.get_vacuum_status_translation(language) 76 | if translations is not None and status in translations: 77 | return translations[status] 78 | return status.capitalize() 79 | 80 | def get_status_text(self, text_img: PilPNG) -> tuple[list[str], int]: 81 | """ 82 | Compose the image status text. 83 | :param text_img: Image to draw the text on. 84 | :return status_text, text_size: List of the status text and the text size. 85 | """ 86 | status_text = ["If you read me, something really went wrong.."] # default text 87 | text_size_coverage = 1.5 # resize factor for the text 88 | text_size = self._shared.vacuum_status_size # default text size 89 | charge_level = "\u03de" # unicode Koppa symbol 90 | charging = "\u2211" # unicode Charging symbol 91 | vacuum_state = self.translate_vacuum_status() 92 | if self._shared.show_vacuum_state: 93 | status_text = [f"{self.file_name}: {vacuum_state}"] 94 | if not self._shared.vacuum_connection: 95 | status_text = [f"{self.file_name}: Disconnected from MQTT?"] 96 | else: 97 | if self._shared.current_room: 98 | try: 99 | in_room = self._shared.current_room.get("in_room", None) 100 | except (ValueError, KeyError): 101 | LOGGER.debug("No in_room data.") 102 | else: 103 | if in_room: 104 | status_text.append(f" ({in_room})") 105 | if self._shared.vacuum_state == "docked": 106 | if int(self._shared.vacuum_battery) <= 99: 107 | status_text.append(" \u00b7 ") 108 | status_text.append(f"{charging}{charge_level} ") 109 | status_text.append(f"{self._shared.vacuum_battery}%") 110 | self._shared.vacuum_bat_charged = False 111 | else: 112 | status_text.append(" \u00b7 ") 113 | status_text.append(f"{charge_level} ") 114 | status_text.append("Ready.") 115 | self._shared.vacuum_bat_charged = True 116 | else: 117 | status_text.append(" \u00b7 ") 118 | status_text.append(f"{charge_level}") 119 | status_text.append(f" {self._shared.vacuum_battery}%") 120 | if text_size >= 50: 121 | text_pixels = sum(len(text) for text in status_text) 122 | text_size = int( 123 | (text_size_coverage * text_img.width) // text_pixels 124 | ) 125 | return status_text, text_size 126 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/thread_pool.py: -------------------------------------------------------------------------------- 1 | """ 2 | Thread Pool Manager for MQTT Vacuum Camera. 3 | This module provides a persistent thread pool for image processing operations. 4 | Version: 2025.5.0 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import asyncio 10 | import concurrent.futures 11 | from functools import lru_cache 12 | import logging 13 | from typing import Any, Callable, Dict, Optional, TypeVar 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | T = TypeVar("T") 18 | R = TypeVar("R") 19 | 20 | 21 | class ThreadPoolManager: 22 | """ 23 | A singleton class that manages thread pools for different components. 24 | This avoids creating and destroying thread pools for each operation. 25 | """ 26 | 27 | _instance: Optional[ThreadPoolManager] = None 28 | _pools: Dict[str, concurrent.futures.ThreadPoolExecutor] = {} 29 | 30 | def __new__(cls): 31 | if cls._instance is None: 32 | cls._instance = super(ThreadPoolManager, cls).__new__(cls) 33 | cls._instance._pools = {} 34 | return cls._instance 35 | 36 | def get_executor( 37 | self, name: str, max_workers: int = 1 38 | ) -> concurrent.futures.ThreadPoolExecutor: 39 | """ 40 | Get or create a thread pool executor for a specific component. 41 | 42 | Args: 43 | name: The name of the component requesting the executor 44 | max_workers: Maximum number of worker threads 45 | 46 | Returns: 47 | A ThreadPoolExecutor instance 48 | """ 49 | if name not in self._pools or self._pools[name]._shutdown: 50 | _LOGGER.debug( 51 | "Creating new thread pool for %s with %d workers", name, max_workers 52 | ) 53 | self._pools[name] = concurrent.futures.ThreadPoolExecutor( 54 | max_workers=max_workers, thread_name_prefix=f"{name}_pool" 55 | ) 56 | return self._pools[name] 57 | 58 | async def shutdown(self, name: Optional[str] = None): 59 | """ 60 | Shutdown a specific thread pool or all thread pools asynchronously. 61 | 62 | Args: 63 | name: The name of the component whose pool should be shut down. 64 | If None, all pools will be shut down. 65 | """ 66 | if name is not None and name in self._pools: 67 | _LOGGER.debug("Shutting down thread pool for %s", name) 68 | await asyncio.to_thread(self._shutdown_pool, self._pools[name]) 69 | del self._pools[name] 70 | elif name is None: 71 | _LOGGER.debug("Shutting down all thread pools") 72 | for pool_name, pool in list(self._pools.items()): 73 | await asyncio.to_thread(self._shutdown_pool, pool) 74 | del self._pools[pool_name] 75 | 76 | @staticmethod 77 | def _shutdown_pool(pool: concurrent.futures.ThreadPoolExecutor): 78 | """Shutdown a thread pool (to be called via asyncio.to_thread).""" 79 | pool.shutdown(wait=True) 80 | 81 | async def run_in_executor( 82 | self, name: str, func: Callable[[Any], R], *args, max_workers: int = 1 83 | ) -> R: 84 | """ 85 | Run a function in a thread pool executor. 86 | 87 | Args: 88 | name: The name of the component requesting the executor 89 | func: The function to run 90 | *args: Arguments to pass to the function 91 | max_workers: Maximum number of worker threads 92 | 93 | Returns: 94 | The result of the function 95 | """ 96 | executor = self.get_executor(name, max_workers) 97 | loop = asyncio.get_event_loop() 98 | return await loop.run_in_executor(executor, func, *args) 99 | 100 | @staticmethod 101 | @lru_cache(maxsize=32) 102 | def get_instance() -> ThreadPoolManager: 103 | """ 104 | Get the singleton instance of ThreadPoolManager. 105 | This method is cached to avoid creating multiple instances. 106 | 107 | Returns: 108 | The singleton instance 109 | """ 110 | return ThreadPoolManager() 111 | -------------------------------------------------------------------------------- /custom_components/mqtt_vacuum_camera/utils/vacuum/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/custom_components/mqtt_vacuum_camera/utils/vacuum/__init__.py -------------------------------------------------------------------------------- /docs/actions.md: -------------------------------------------------------------------------------- 1 | # Vacuum Services: Actions and Usage Guide 2 | 3 | This document describes the available services for the MQTT Vacuum Camera integration and provides examples of how to use them in your automations or scripts. 4 | 5 | --- 6 | 7 | ## 1. **Vacuum Go To** 8 | Moves the vacuum to specific coordinates or a predefined spot. 9 | 10 | ### Parameters: 11 | | Parameter | Type | Required | Description | 12 | |-----------|---------|----------|------------------------------------------------| 13 | | `x_coord` | Integer | Yes | X-coordinate for the vacuum to move to. | 14 | | `y_coord` | Integer | Yes | Y-coordinate for the vacuum to move to. | 15 | | `spot_id` | String | No | Predefined point ID for Rand256 vacuums. | 16 | 17 | ### YAML Example: 18 | ```yaml 19 | service: mqtt_vacuum_camera.vacuum_go_to 20 | data: 21 | entity_id: vacuum.my_vacuum 22 | x_coord: 26300 23 | y_coord: 22500 24 | ``` 25 | When using the spot_id: 26 | ```yaml 27 | service: mqtt_vacuum_camera.vacuum_go_to 28 | data: 29 | entity_id: vacuum.my_vacuum 30 | x_coord: 0 31 | y_coord: 0 32 | spot_id: "Dock" 33 | ``` 34 | 35 | ## 2. **Vacuum Clean Zone** 36 | Starts cleaning in specified zones. 37 | 38 | ### Parameters: 39 | | Parameter | Type | Required | Description | 40 | |------------|--------------|----------|--------------------------------------------------| 41 | | `zone` | List (Array) | Yes | List of zones defined as `[x1, y1, x2, y2]`. | 42 | | `zone_ids` | List | No | Predefined zone IDs for Rand256 vacuums. | 43 | | `repeats` | Integer | No | Number of cleaning repetitions (default: 1). | 44 | 45 | ### YAML Example: 46 | Cleaning specific coordinates: 47 | ```yaml 48 | service: mqtt_vacuum_camera.vacuum_clean_zone 49 | data: 50 | entity_id: vacuum.my_vacuum 51 | zone: [[23510, 25311, 25110, 26362]] 52 | repeats: 2 53 | ``` 54 | 55 | ## 3. **Vacuum Clean Segment** 56 | Starts cleaning in specified segments (rooms). 57 | 58 | ### Parameters: 59 | | Parameter | Type | Required | Description | 60 | |-------------|--------------|----------|--------------------------------------------------| 61 | | `segments` | List | Yes | List of segment IDs or names. | 62 | | `repeats` | Integer | No | Number of cleaning repetitions (default: 1). | 63 | 64 | ### YAML Example: 65 | Cleaning specific segments: 66 | ```yaml 67 | service: mqtt_vacuum_camera.vacuum_clean_segment 68 | data: 69 | entity_id: vacuum.my_vacuum 70 | segments: [1, 2, 3] 71 | repeats: 2 72 | ``` 73 | 74 | Cleaning predefined segment names (Rand256): 75 | ```yaml 76 | service: mqtt_vacuum_camera.vacuum_clean_segment 77 | data: 78 | entity_id: vacuum.my_vacuum 79 | segments: ["Bedroom", "Hallway"] 80 | repeats: 1 81 | ``` 82 | 83 | ## 4. **Vacuum Map Save** 84 | 85 | Save the current map with a specified name (at current Rand256 only). 86 | 87 | ### Parameters: 88 | | Parameter | Type | Required | Description | 89 | |-----------|--------|----------|-------------------------------------------------| 90 | | `name` | String | Yes | Name of the map to save. | 91 | 92 | ### YAML Example: 93 | ```yaml 94 | service: mqtt_vacuum_camera.vacuum_map_save 95 | data: 96 | entity_id: vacuum.my_vacuum 97 | map_name: "MY_MAP" 98 | ``` 99 | 100 | ### 5. **Vacuum Map Load** 101 | 102 | Load a saved map by name (at current Rand256 only). 103 | 104 | ### Parameters: 105 | | Parameter | Type | Required | Description | 106 | |-----------|--------|----------|-------------------------------------------------| 107 | | `name` | String | Yes | Name of the map to load. | 108 | 109 | ### YAML Example: 110 | ```yaml 111 | service: mqtt_vacuum_camera.vacuum_map_load 112 | data: 113 | entity_id: vacuum.my_vacuum 114 | map_name: "MY_MAP" 115 | ``` 116 | 117 | When invoking this service, the camera reset the trims and reload the map. 118 | 119 | ### 6. **Reset Trims** 120 | 121 | Resets the map trims for the camera component. 122 | 123 | ```yaml 124 | service: mqtt_vacuum_camera.reset_trims 125 | ``` 126 | 127 | ### 7. **Reload** 128 | 129 | Reloads the Integration. 130 | 131 | ```yaml 132 | service: mqtt_vacuum_camera.reload 133 | ``` 134 | -------------------------------------------------------------------------------- /docs/auto_zoom.md: -------------------------------------------------------------------------------- 1 | ### Auto Zooming the room (segment) when the vacuum is cleaning it. 2 | 3 | 4 | ***Category:*** Camera Configuration - Image Options - Auto Zoom. 5 | 6 | ![Screenshot 2024-08-19 at 12 27 07](https://github.com/user-attachments/assets/1ecffd2c-2c03-4631-82ca-e65690e82f18) 7 | 8 | ***Category:*** Camera Configuration - Image Options - Auto Zoom 9 | 10 | ![Screenshot 2024-03-13 at 17 14 10](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/390a5a85-3091-40b0-9846-c0bc9c6db93d) 11 | 12 | ***Default:*** Disable 13 | 14 | ***Description:*** If the vacuum supports the segments and those are properly configured also in the card, when the 15 | vacuum enters the room to clean the Camera will zoom the image on that room. The full zoom will change the aspect ratio of the image. 16 | Below exaple of how looks the dashboad when the ratio isn't locked. 17 | 18 | https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/99fc5b9b-20a5-4458-8f5c-1dda874a9da5 19 | 20 | With the Lock Aspect Ratio function the image is displayed with the selected ratio (this is at orinal image ratio). 21 | 22 | https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/6930e76a-9c66-4f81-b824-003698160ffd 23 | 24 | While auto zooming the segments the images will change aspect ratio if the lock aspect ratio is disabled. 25 | If enabled the aspect ratio will be kept and the image will be padded to fit the aspect ratio. 26 | It is also possible to select the desired aspect ratio of the images independently of the auto zoom. 27 | See the Image Options guide for more details. 28 | 29 | ***Note:*** The zoom works only when the vacuum is in “cleaning” state. In all other states the image returns 30 | automatically to the floor plan. If the auto zoom is disabled the floor map is displayed normally, cleaned or in clean 31 | mode segments are highlighted on the map by a faded selected color. 32 | -------------------------------------------------------------------------------- /docs/colours.md: -------------------------------------------------------------------------------- 1 | ### Base and Rooms Colours. 2 | 3 | ***Category:*** Camera Configuration - Colours - From General to Rooms colours. 4 | 5 | ![Screenshot 2024-08-19 at 12 05 14](https://github.com/user-attachments/assets/e0751fb5-51c8-40cf-bfea-2c730d9ef92e) 6 | 7 | ***Description:*** 8 | By selecting the option to be configured (submitting the operation to do) is possible to: 9 | 10 | - Change the General Colours. 11 | 12 | ![Screenshot 2023-12-18 at 23 33 42](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/e301ecba-2608-499f-92c5-197b62400d70) 13 | 14 | - Change the Rooms Colours (in total 16 colours) if you use a vacuum that do not support the segments (rooms) the Room 1 15 | is the floor colour. The number of rooms setup in the vacuum is the number of colours available to be configured. 16 | 17 | ![Screenshot 2023-12-18 at 23 34 05](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/24fbad4d-3cef-474f-9a27-9ada411ad6d3) 18 | 19 | - It is possible to set up the [transparency](./transparency.md) for each colour at the end of the page by clicking on 20 | submit. 21 | 22 | ![Screenshot 2024-03-16 at 09 58 35](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/6d276689-cd8e-4948-ba82-5027a9be3902) 23 | 24 | It is also possible to customize the rooms description of the menu by the advanced options. This customization will apply to all cameras; therefore, it is not possible to have different room descriptions for each camera. 25 | 26 | ![Screenshot 2024-08-19 at 12 11 22](https://github.com/user-attachments/assets/f406b4b8-3766-47a9-9d5d-b65a1e52cb25) 27 | 28 | The V1 vacuums support only the floor colour. 29 | 30 | ![Screenshot 2024-08-19 at 12 22 52](https://github.com/user-attachments/assets/74ae1aeb-b470-4338-bb9c-e92ee60236d7) 31 | 32 | -------------------------------------------------------------------------------- /docs/croping_trimming.md: -------------------------------------------------------------------------------- 1 | # Automating Trimming Function: 2 | Valetudo images are normally quite huge, this results in a heavy handling of the images. 3 | Some vacuums also produces so big images that could seriously affect the way HA works (sometimes also crash it). 4 | For a practical and more convenient way to automate the vacuums with HA this integration will automatically trim the images. 5 | 6 | This is an image at 100% (full size): 7 | 8 | ![Screenshot 2023-08-18 at 10 33 05](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/983d0848-e3b5-4db6-8957-f25bc6cd073f) 9 | 10 | For this reason the camera trim automatically the images. By default the image have 100 pixel of margins, the trimming factor is automatically calculated at camera startup and will be saved to avoid to recompute it at each reboot. 11 | 12 | ![Screenshot 2023-08-18 at 11 18 24](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/b6d57424-a9f2-4d67-964e-693718cc66a9) 13 | 14 | ### How this works: 15 | 1) Trims are automatically calculated by searching the fist pixel not having the background colour in the image. This could be possible that the trims are not perfectly calculated because of the lidar data (above image is an example). 16 | 2) The result image can be rotated by 0, 90, 180, 270 degrees. 17 | 3) It is possible to enlarge or set the margins to be smaller, default the margins of the images are 100 pixels. 18 | 4) After the frame 0, from frame 1 actually, as the trims are stored in memory will be re-use each time the image is composed. 19 | 5) The trims are then saved in json format in the .storage folder of Home Assistant. 20 | It is possible therefore to optimize the view to display the map without, virtually, empty spaces around currently using the card. 21 | 6) Is also possible to select the image aspect ratio as desired. 22 | 23 | ![Screenshot 2024-03-13 at 17 18 30](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/016e4282-2d4a-4cee-a4b7-b3dbf8558898) 24 | 25 | If this isn't giving the expected result, there is the possibility to add a Trim offset to the image. This will allow to move the trims to the desired position. 26 | It is considered that the image is at 0 degrees rotation, the trims are calculated from the top left corner of the image. 27 | Camera Options -> Advanced -> Configure Offset Image 28 | 29 | ![Screenshot 2024-08-19 at 11 26 04](https://github.com/user-attachments/assets/7a91da26-1dff-446c-bd79-0a6ab952630d) 30 | 31 | As per the trims are saved and reloaded at each startup, when you need to change the map of the vacuums (using [maploader](https://github.com/pkoehlers/maploader) or as [per reported for Rand256](https://github.com/sca075/mqtt_vacuum_camera/discussions/236)) 32 | you can reset the trims with the Action "reset_trims" available in the Camera. 33 | 34 | ```yaml 35 | action: mqtt_vacuum_camera.reset_trims 36 | data: {} 37 | ``` 38 | 39 | This Action will delete the stored trims and when the vacuum is __Docked__ re-store them from the new image and will also reload the Camera to apply the new trims. 40 | 41 | The calibrations points of the maps will be automatically updated at each map transformations. 42 | The robot position and coordinates will not change, meaning that there will be no functional changes for the pre-defined cleaning areas or segments (rooms). 43 | 44 | ### Note: 45 | If you want to get the most from the auto cropping and your vacuum support segments, setting the room_0 to the same colour of the background should remove the lidar imperfections. 46 | 47 | ![Screenshot 2023-12-27 at 13 21 52](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/b830f3d9-9e60-4206-a03c-146c14f89121) 48 | -------------------------------------------------------------------------------- /docs/images_options.md: -------------------------------------------------------------------------------- 1 | ### Image Options. 2 | 3 | ***Category:*** Camera Configuration 4 | 5 | ![Screenshot 2024-08-19 at 12 27 07](https://github.com/user-attachments/assets/1ecffd2c-2c03-4631-82ca-e65690e82f18) 6 | 7 | ***Description:*** The camera configuration is a set of options that can be used to customize the camera image. The 8 | options are: 9 | 10 | ***1.*** Image Rotation. 11 | 12 | The image can be rotated in 90 degrees steps. The dropdown list allows to select the desired rotation. 13 | 14 | ![Screenshot 2024-03-13 at 17 16 40](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/8e1a9716-de6e-4f8f-bb66-fdb9c6ad8834) 15 | 16 | ***2.*** Margins. 17 | 18 | The images are pre-processed to remove unused maps areas, there is a function that automatically trims the images to get 19 | the optimal view of the maps. This function will search for non background color pixels, resulting basically without 20 | margins. This value add the pixels at each side of the image. Default is 100 pixels. 21 | 22 | ![Screenshot 2024-03-13 at 17 17 13](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/e228fc96-8e95-4be9-af9e-b21a259e8289) 23 | 24 | ***3.*** Image offset 25 | 26 | The image offset option, will cut the Lidar impefections from the images. This option used with the auto-trim will reduce the image size, that anyway can be keep at desired aspect ratio. The below video explain how to resize the image, the menu values are design to _work at rotation 0_ . 27 | 28 | https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/4e1cee93-2ccd-413e-8cfa-79f4dcf76635 29 | 30 | 31 | ***4.*** Aspect Ratio. 32 | 33 | The aspect ratio of an image after [pre-processing](https://github.com/sca075/valetudo_vacuum_camera/blob/main/docs/croping_trimming.md) should be 2:1. Anyway as per the layout 34 | differ on each floor, it could be possible it would result on different aspect ratio. This option allows to select the 35 | desired aspect ratio of the images. The aspect ratio is the ratio of the width to the height of the image. 36 | By default, the Camera use the Original Ratio of the image. 37 | Be sure to **_lock the aspect ratio_** if you want to keep the selected aspect ratio. 38 | 39 | ![Screenshot 2024-03-13 at 17 18 30](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/016e4282-2d4a-4cee-a4b7-b3dbf8558898) 40 | 41 | ***5.*** Auto Zoom and Lock Aspect Ratio. 42 | 43 | [Auto Zooming the room (segment)]( 44 | https://github.com/sca075/valetudo_vacuum_camera/blob/main/docs/auto_zoom.md) when the vacuum is cleaning it. The full zoom will change the 45 | aspect ratio of the image. This why it is possible to lock the aspect ratio of the image to keep the selected ratio and 46 | preserve the Dashboards layout. 47 | Independently from the Auto Zoom as above **Lock Aspect Ratio** should be active when selecting the desired image Aspect 48 | Ratio. 49 | 50 | ![Screenshot 2024-03-13 at 17 14 10](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/fb283c47-12e3-42db-b86e-47f3d0f77efa) 51 | 52 | ***6.*** Export PNG snapshots. 53 | 54 | The camera will shoot automatically snapshots of the maps and save them in the folder `www` of the Home Assistant. The 55 | images are saved in PNG format and named like this example `snapshot_your_vacuum.png`. Disabling this function will 56 | delete and do not save in the images in `www` folder. See [Snapshots](https://github.com/sca075/valetudo_vacuum_camera/blob/main/docs/docs/snapshots.md) for more details. 57 | 58 | ![Screenshot 2024-03-13 at 17 19 15](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/52a47822-9588-4a8d-9d7f-adaf1a6e2f90) 59 | 60 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | ### How to Install and Configure the Camera: 2 | 3 | ## Via HACS 4 | 5 | ## If you click this button below we can go to step #2. 6 | [![Open HACS repository in Home Assistant](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=sca075&repository=mqtt_vacuum_camera&category=integration) 7 | 8 | ## Step 1 Download the Component. 9 | Using [HACS](https://hacs.xyz/) add custom repositories by clicking on the three dots on the top right of the HACS page and select **Integrations**: 10 | 11 | ![Screenshot 2023-08-12 at 17 06 17](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/4abdf05a-eb50-4317-a0e9-8c6984bdba05) 12 | 13 | please copy the repository link below in ***Custom repositories*** section. 14 | 15 | ```link 16 | https://github.com/sca075/mqtt_vacuum_camera.git 17 | ``` 18 | ![Screenshot 2024-08-24 at 10 41 16](https://github.com/user-attachments/assets/e53de9b2-a9a5-4ce9-a9e3-8809faff0c48) 19 | 20 | Select **Integration** as _Category_ and click the **Add** button. 21 | 22 | 23 | Once the repository is added, please click on the repository and the home page will be display. From there you need to 24 | **Download** the integration with [HACS](https://hacs.xyz/) that will install it for you. (Note: You can select here if you want to be notified for beta releases that some time are contain instant fixes). 25 | 26 | ![Screenshot 2024-07-19 at 10 24 32](https://github.com/user-attachments/assets/57a22bb7-f9d5-40fc-abda-1a2bd34265da) 27 | 28 | 29 | ## Step 2 Restart HA to finalize the component installation. 30 | **You will need to restart Home Assistant at this point** to have the integration available. Once Home Assistant will reload, please go in (please press CTRL clicking the link this would open the link in a different tab of your browser) [**Settings** -> **Devices & Services**](https://my.home-assistant.io/redirect/config_flow_start/?domain=mqtt_vacuum_camera) then please confirm to add the integration. 31 | The setup will start, you just select here the vacuum and the camera will be configured. 32 | 33 | ![Screenshot 2024-07-18 at 13 11 04](https://github.com/user-attachments/assets/871bb739-ce32-4ee4-bccf-05d597afd399) 34 | 35 | The configuration of the colours you would prefer for each element in the maps can be done via Options. The camera will connect automatically to the HA MQTT (whatever setup you use), for each vacuum you configured a new entity will be added to the configuration. 36 | 37 | ![Screenshot 2023-08-30 at 07 23 30](https://github.com/sca075/mqtt_vacuum_camera/assets/82227818/5587ecc0-859e-4bd4-ba18-0f96df0c55a5) 38 | 39 | 40 | The camera entity created will have the same friendly name of your **vacuum** + "camera" at the end. For example vacuum.robot1 = camera.robot1_camera. 41 | 42 | ![Screenshot 2023-08-30 at 07 32 54](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/c4c054a5-e021-4c68-804b-9484d35a42ae) 43 | 44 | ### Manual Setup: 45 | If you want to install this camera manually without HACS: 46 | Check the last release available and REPLACE_IT (at current v1.5.9) 47 | To install this integration manually you have to download mqtt_vacuum_camera.zip and extract its contents to config/custom_components/mqtt_vacuum_camera directory: 48 | 49 | ```shell 50 | mkdir -p custom_components/mqtt_vacuum_camera 51 | cd custom_components/mqtt_vacuum_camera 52 | wget https://github.com/sca075/mqtt_vacuum_camera/archive/refs/tags/v.1.5.9.zip 53 | unzip mqtt_vacuum_camera_v1.5.9.zip 54 | rm mqtt_vacuum_camera_v1.5.9.zip 55 | ``` 56 | 57 | Once the files are in the right place, you will need to restart Home Assistant to have the integration available. Once Home Assistant will reload, please go in (plase press CTRL clicking the link this would open the link in a different tab of your browser) [**Settings** -> **Devices & Services **](https://my.home-assistant.io/redirect/config_flow_start/?domain=valetudo_vacuum_camera) then please confirm to add the integration. 58 | 59 | ### Card Configuration: 60 | 61 | Configuration of the [card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card) (thanks to [@PiotrMachowski](https://github.com/PiotrMachowski)) once the camera is installed requires: 62 | 63 | *calibration source will be set to camera **not to identity** as the camera is providing the calibration points to the card.* 64 | ```yaml 65 | calibration_source: 66 | camera: true 67 | ``` 68 | 69 | **Warning: You need to use the internal_variables**: As Valetudo is using MQTT is necessary to set in the card the 70 | topic. 71 | *Your topic can be obtained also from the camera attributes vacuum_topic.* 72 | 73 | ![Screenshot 2023-10-24 at 18 25 59](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/080b7bcb-19f1-4415-870f-2285329e7ce9) 74 | 75 | ***Note: "YOUR_TOPIC_HERE" must be replaced with what you can find it in the camera attributes. The value is Case 76 | Sensitive.*** 77 | ```yaml 78 | internal_variables: 79 | topic: valetudo/YOUR_TOPIC_HERE 80 | ``` 81 | 82 | We did agree and work with the author of the card, we guess a [new version of the card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card/actions/runs/7005593157) will be released. 83 | Those settings for the internal_variables will be, probably, automatically setup in the card as soon the vacuum and camera will be setup in the card. 84 | 85 | ### Camera Configuration: 86 | 87 | **This integration is not configuring the Vacuums**, you need to configure the vacuum in the vacuum UI. 88 | This project runs in parallel, is not a fork of the original Valetudo project you selected. 89 | 90 | It is possible to **configure the camera via the Home Assistant UI**, as we aim to extract the Vacuums maps in the Home 91 | Assistant UI. 92 | The camera entity created will have the same friendly name of **YOUR_VACUUM**"_camera" at the end. 93 | 94 | To configure the Camera Options use Home Assistant "Settings" -> "Devices & Services" -> "MQTT Vacuum Camera" in 95 | the "Integration" tab. 96 | 97 | The setup of the options of the camera include: 98 | - [**Image Options**](https://github.com/sca075/mqtt_vacuum_camera/blob/main/docs/images_options.md) 99 | - [**Configure Status Text**](https://github.com/sca075/mqtt_vacuum_camera/blob/main/docs/status_text.md) 100 | - [**Configure the Colours**](https://github.com/sca075/mqtt_vacuum_camera/blob/main/docs/colours.md) 101 | - [**Export the logs of this integration**](https://github.com/sca075/mqtt_vacuum_camera/blob/main/docs/snapshots.md) 102 | 103 | We filter the logs of HA. Only the Camera entries on the logs are important to us, so we can help you better. 104 | The logs are stored in the .storage folder of Home Assistant. Can be export to WWW from the options of the camera. 105 | The Camera will delete this zip file from WWW if restarted. 106 | 107 | ***What is in the zipped logs:*** 108 | - Home Assistant logs of MQTT Vacuum Camera (filtered). 109 | - json file of the Vacuum. 110 | - PNG file of output the map. 111 | -------------------------------------------------------------------------------- /docs/obstacles_detection.md: -------------------------------------------------------------------------------- 1 | # Obstacle Detection and Image Processing 2 | 3 | ## Overview 4 | The Obstacle Detection and Image Processing feature allows users to visualize obstacles detected by their vacuum directly in Home Assistant. This feature is designed for vacuums supporting Obstacles and the `ObstacleImagesCapability` and enables a dynamic experience by switching between map and obstacle views (if the vacuum support it). 5 | 6 | ## Key Features 7 | - **Dynamic ObstacleImages Interaction**: 8 | - Click on detected obstacles in the map view to display their corresponding images. 9 | - Switch back to the map view by clicking anywhere on the obstacle image. 10 | 11 | - **Seamless View Switching**: 12 | - Switch between map and obstacle views in near real-time is possible thanks to the [Xaiomi Vacuum Map Card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card). 13 | - In order to select the obstacle that is highlight in a red dot drawn on the map, it is necessary to add this code to the card configuration: 14 | ```yaml 15 | map_modes: 16 | - other map modes 17 | ... 18 | - name: Obstacles View 19 | icon: mdi:map-marker 20 | run_immediately: false 21 | coordinates_rounding: true 22 | coordinates_to_meters_divider: 100 23 | selection_type: MANUAL_POINT 24 | max_selections: 999 25 | repeats_type: NONE 26 | max_repeats: 1 27 | service_call_schema: 28 | service: mqtt_vacuum_camera.obstacle_view 29 | service_data: 30 | coordinates_x: "[[point_x]]" 31 | coordinates_y: "[[point_y]]" 32 | target: 33 | entity_id: camera.YOUR_MQTT_CAMERA_camera 34 | variables: {} 35 | ``` 36 | 37 | ## How It Works 38 | 1. **Triggering the Event**: 39 | - When a user clicks on the map, the frontend card will trigger the action "obstacle_view": 40 | - `entity_id`: The camera entity to handle the request. 41 | - `coordinates`: The map coordinates of the clicked point. 42 | - The below video demonstrates the feature in action: 43 | 44 | https://github.com/user-attachments/assets/0815fa06-4e19-47a1-9fdc-e12d22449acc 45 | 46 | 2. **Finding the Nearest Obstacle**: 47 | - The system locates the nearest obstacle to the given coordinates it isn't necessary to point directly on it. 48 | 3. **Image Download and Processing**: 49 | - If an obstacle is found, the integration: 50 | 1. Downloads the image from the vacuum. 51 | 2. Resizes it to fit the UI (this will be later improved). 52 | 3. Displays it in the camera view. 53 | 54 | 4. **Switching Views**: 55 | - Clicking on an obstacle switches the camera to `Obstacle View`. 56 | - Clicking any ware obstacle image switches back to `Map View`. 57 | 58 | In order to monitor from the card in what state is the camera, it is necessary to add the following code to the card configuration: 59 | ```yaml 60 | tiles: 61 | ... 62 | - tile_id: camera_mode 63 | label: Camera Mode 64 | icon: mdi:map 65 | entity: camera.valetudo_v1_silenttepidstinkbug_camera 66 | attribute: camera_mode 67 | translations: {} 68 | ``` 69 | 70 | ## Configuration 71 | 1. Ensure your vacuum supports `ObstacleImagesCapability` and is integrated into Home Assistant. 72 | 2. Use a compatible frontend card that allows interaction with map coordinates such the [Xaiomi Vacuum Map Card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card). 73 | 74 | ## Notes 75 | ### Supported Vacuums 76 | When the Vacuum support Obstacle Detections and has no oboard camera. 77 | The vacuum **do not support** the `ObstacleImagesCapability`, then the Camera will simply display the obstacles as a Red Dot on the map. 78 | If the vacuum supports the `ObstacleImagesCapability` capability, the user can interact with the map and view obstacles in near real-time. 79 | This feature was tested on Supervised HA-OS on Pi4 with 8GB RAM and 64GB disk. 80 | It is possible of course to use this future while the Vacuum works, but **it is not recommended to use it while the vacuum is working**. 81 | 82 | -------------------------------------------------------------------------------- /docs/snapshots.md: -------------------------------------------------------------------------------- 1 | ### Snapshots ### 2 | 3 | ***Category:*** Camera Configuration - Image Options - Export PNG Snapshot. 4 | 5 | ***Default:*** Enabled 6 | 7 | **Waring: Please keep in mind that the snapshot image will be not automatically deleted from your WWW folder while the 8 | PNG export is enable** 9 | 10 | The snapshots images are automatically stored in www (local) folder of HA, by default this function is enable. 11 | The Camera take a snapshot of the maps when the vacuum is "idle", "docked" or in "error" state if this option is enabled. 12 | It is possible to disable the PNG export from the camera options thanks [@gunjambi](https://github.com/gunjambi) 13 | as soon this option is OFF the PNG will be deleted from the WWW folder. 14 | 15 | ![Screenshot 2024-03-13 at 17 19 15](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/3ab2558b-1ec6-4703-8024-2662d3637206) 16 | 17 | When the vacuum battery get empty because of an error during a cleaning cycle, or in different conditions as per the below example. 18 | If the option is enable will be possible to create an automation to send the screenshot to your mobile. 19 | When using the below example HA editor please edit the automation in yaml because the snapshot attribute (used in this example) 20 | provides boolean values True or False. 21 | HA editor will translate to "True" (string) from the UI editor, home assistant will not notify you in this case. 22 | 23 | **Warning: Please keep in mind that the snapshot image will be not automatically deleted from your WWW folder while the 24 | PNG export is enabl)** 25 | 26 | ``` 27 | 28 | alias: vacuum notification 29 | description: "" 30 | trigger: 31 | - platform: state 32 | entity_id: 33 | - camera.v1_your_vacuum_camera 34 | attribute: snapshot 35 | from: false 36 | to: true 37 | condition: [] 38 | action: 39 | - service: notify.mobile_app_your_phone 40 | data: 41 | message: Vacuum {{states("vacuum.valetudo_your_vacuum")}} 42 | data: 43 | image: /local/snapshot_your_vacuum.png 44 | mode: single 45 | 46 | ``` 47 | 48 | *Aside the image, this function store also a zip file with diagnostic data, we filter the data relative to this integration from the HA logs and those data are stored only if the 49 | log debug function in HA is active ***(we don't store any data in the www folder if the you do not log the data and export them)***. 50 | 51 | ![Screenshot 2023-10-03 at 06 55 36](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/6aedcdd3-6f39-4b11-8c0f-6da99f5490e9) 52 | 53 | Once enabled the Debug log in the home assistant GUI home assistant collect the logs of the camera and other intregrations and add-on's in the instance. 54 | 55 | ## Example Home Assistant log. ## 56 | ```log 57 | 2024-01-26 09:27:39.930 DEBUG (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal System CPU usage stat (1/2): 0.0% 58 | 2024-01-26 09:27:39.930 INFO (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal: Image not processed. Returning not updated image. 59 | 2024-01-26 09:27:39.935 DEBUG (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal System CPU usage stat (2/2): 0.0% 60 | 2024-01-26 09:27:39.935 DEBUG (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal Camera Memory usage in GB: 0.93, 11.98% of Total. 61 | ***2024-01-26 09:27:59.524 ERROR (MainThread) [homeassistant.components.androidtv.media_player] Failed to execute an ADB command. ADB connection re-establishing attempt in the next update. Error: Reading from 192.1**.1**.2**:5555 timed out (9.0 seconds) 62 | 2024-01-26 09:28:01.524 WARNING (MainThread) [androidtv.adb_manager.adb_manager_async] Couldn't connect to 192.1**.1**.2**:5555. TcpTimeoutException: Connecting to 192.1**.1**.2**:5555 timed out (1.0 seconds) 63 | 2024-01-26 09:28:02.967 WARNING (MainThread) [custom_components.localtuya.common] [204...991] Failed to connect to 192.1**.1**.1**: [Errno 113] Connect call failed ('192.1**.1**.1**', 6668)** 64 | 2024-01-26 09:28:37.649 INFO (MainThread) [custom_components.valetudo_vacuum_camera.valetudo.MQTT.connector] Received valetudo/GlossyHardToFindNarwhal image data from MQTT 65 | 2024-01-26 09:28:39.943 INFO (MainThread) [custom_components.valetudo_vacuum_camera.valetudo.MQTT.connector] No data from valetudo/GlossyHardToFindNarwhal or vacuum docked 66 | ``` 67 | 68 | The filtered logs are in a zip file that will be created in the .storage of the home assistant, this file will be not acceseble on the .config folder unless you select the oprtion to export the logs from the Camera Options. 69 | 70 | ![Screenshot 2024-01-26 at 10 01 40](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/4d4fb7e3-16a5-4994-9f61-ad71c50ddb61) 71 | 72 | And then download it with the file editor of your coise or via SAMBA add-on. 73 | 74 | ![Screenshot 2023-10-03 at 06 58 36](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/363881f5-bca6-462f-80d8-9a6351bcf285) 75 | 76 | The filtered logs will be as per the example below not containing other integrations or add-on logs such as androidtv, custom_components.localtuya (see above) only the custom_components.valetudo_vacuum_camera logs are exported.. 77 | 78 | ## Example Valetudo Camera log. ## 79 | ```log 80 | 2024-01-26 09:27:39.930 DEBUG (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal System CPU usage stat (1/2): 0.0% 81 | 2024-01-26 09:27:39.930 INFO (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal: Image not processed. Returning not updated image. 82 | 2024-01-26 09:27:39.935 DEBUG (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal System CPU usage stat (2/2): 0.0% 83 | 2024-01-26 09:27:39.935 DEBUG (MainThread) [custom_components.valetudo_vacuum_camera.camera] glossyhardtofindnarwhal Camera Memory usage in GB: 0.93, 11.98% of Total. 84 | 2024-01-26 09:28:37.649 INFO (MainThread) [custom_components.valetudo_vacuum_camera.valetudo.MQTT.connector] Received valetudo/GlossyHardToFindNarwhal image data from MQTT 85 | 2024-01-26 09:28:39.943 INFO (MainThread) [custom_components.valetudo_vacuum_camera.valetudo.MQTT.connector] No data from valetudo/GlossyHardToFindNarwhal or vacuum docked 86 | ``` 87 | 88 | 89 | -------------------------------------------------------------------------------- /docs/status_text.md: -------------------------------------------------------------------------------- 1 | ### Vacuums Status Text on the Images. 2 | 3 | ***Category:*** Camera Configuration - Configure Status Text. 4 | 5 | ***Description:*** It is a set of options to customize the status text to be display on the images. 6 | 7 | ![Screenshot 2024-03-13 at 20 35 25](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/a9dd68cd-e41a-4f7e-8e0a-08567e80eb4e) 8 | 9 | ***1.*** Show Vacuum Status Text. 10 | 11 | ***Default:*** OFF. 12 | 13 | ***Description:*** If enabled the status text will be displayed on the images. 14 | 15 | Information provide from the status text are: 16 | 17 | - Vacuum Friendly Name: The friendly name of the vacuum. 18 | - Vacuum State: The state of the vacuum. 19 | - Vacuum Current Room: The room where the vacuum is located. 20 | - Vacuum Battery Level: The battery level of the vacuum. 21 | 22 | ***2.*** Text Font. 23 | 24 | ***Default:*** Fira Sans. 25 | 26 | ![Screenshot 2024-03-16 at 10 33 22](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/dd91617f-aa02-40d0-a1d5-dbb7dda50e25) 27 | 28 | ***Description:*** The font to be used for the status text. Sometimes we can name the rooms in a different language this 29 | option allows to select the font that better fit the language. 30 | 31 | ***3.*** Font Size. 32 | 33 | ***Default:*** 50. 34 | 35 | ![Screenshot 2024-03-16 at 10 34 17](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/d9db6894-4c9b-4e2c-a2e5-f317cdcdee2e) 36 | 37 | ***Description:*** Give the possibility to scale down the font. At 50 the font is automatically scaled to keep the text 38 | inside the 70% of image width. 39 | 40 | ***4.*** Text Position. 41 | 42 | ***Default:*** ON. 43 | 44 | ![Screenshot 2024-03-16 at 10 35 42](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/628859ae-4c11-4261-833a-ac469e0f65a9) 45 | 46 | ***Description:*** When ON the status text is regularly displayed at the top left of the image. When OFF the text is 47 | displayed at the bottom left of the image. OFF the text is displayed at the bottom left of the image. 48 | 49 | ***5.*** Text Color. 50 | 51 | ***Default:*** White. 52 | 53 | ![Screenshot 2024-03-16 at 10 37 03](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/b588f513-727e-4fce-a290-701f66f48e27) 54 | 55 | 56 | ***Description:*** It is possible to change the color of the status text. 57 | 58 | If you want to customize the transparency of the text, you can use the to set the transparency of the text in the 59 | General [Colours](./docs/colours) options. 60 | 61 | ![Screenshot 2024-03-16 at 09 58 35](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/b6cc76dc-384a-4586-87b2-6a248aa5cf3a) 62 | 63 | -------------------------------------------------------------------------------- /docs/transparency.md: -------------------------------------------------------------------------------- 1 | ### Colours Transparency ### 2 | 3 | ***Category:*** Camera Configuration - Colours - From General to Rooms colours. 4 | 5 | The colour used in the creation of each element of the maps are RGBA. 6 | The Alpha chanel of the colour "A" (transparency) is customizable from the camera options. 7 | At the initial setup of the camera, the colours Alpha data load in the configration are default. 8 | 9 | Those values are: 10 | - 255 = Full color no transparency. 11 | - 0 = Transparent = not visible. 12 | 13 | As per we build several layers one on the top of the other applying transparency to one of the colour used can result 14 | in interesting colour mixtures :) 15 | 16 | At the bottom of the colours configuration pages there is a switch that will display the transparency settings relative 17 | to the colours you just edited. By turning on the "Configure Transparency" this page will be shown. 18 | 19 | ![Screenshot 2023-10-22 at 08 22 44](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/da1d5b24-97b9-4529-acd2-7e3c37854b62) 20 | 21 | It is possible to use the slide bar or simply input the value. 22 | 23 | ![Screenshot 2023-10-22 at 09 21 15](https://github.com/sca075/valetudo_vacuum_camera/assets/82227818/9a49c8ca-6c08-4af0-a099-36a40ded365c) 24 | 25 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MQTT Vacuum Camera", 3 | "zip_release": true, 4 | "render_readme": true, 5 | "homeassistant": "2024.7.0b0", 6 | "filename": "mqtt_vacuum_camera.zip" 7 | } 8 | -------------------------------------------------------------------------------- /repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "https://github.com/sca075/mqtt_vacuum_camera", 3 | "name": "MQTT Vacuum Camera", 4 | "description": "A Home Assistant custom component to export and render maps of vacuum cleaners connected via MQTT, including Valetudo Hypfer and RE(rand256) firmwares.", 5 | "author": "sca075", 6 | "topics": ["home-assistant", "mqtt", "vacuum", "camera", "valetudo"], 7 | "labels": ["integration", "custom_component"], 8 | "homepage": "https://github.com/sca075/mqtt_vacuum_camera", 9 | "license": "Apache-2.0", 10 | "languages": ["Python"] 11 | } 12 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | # From our manifest.json for our custom component 2 | pillow<=11.2.1 3 | numpy 4 | isal==1.7.2 5 | psutil-home-assistant==0.0.1 6 | janus==2.0.0 7 | 8 | 9 | # Strictly for tests 10 | # We should always use the latest version to make sure we're testing against the latest version of Home Assistant 11 | pytest-homeassistant-custom-component>=0.13.128 12 | # By avoiding to pin to any of these, they can be automatically be managed by pytest-homeassistant-custom-component (above) 13 | pytest-asyncio 14 | pytest-aiohttp 15 | coverage 16 | pytest 17 | pytest-cov 18 | pytest-socket 19 | pytest-mqtt 20 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m ensurepip --upgrade 8 | python -m pip install --upgrade setuptools 9 | python3 -m pip install --requirement requirements.test.txt -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | fail_under = 84 12 | show_missing = true 13 | 14 | [tool:pytest] 15 | testpaths = tests 16 | norecursedirs = .git 17 | addopts = 18 | --cov=custom_components, 19 | --disable-socket --allow-unix-socket 20 | asyncio_mode = auto 21 | 22 | [flake8] 23 | # https://github.com/ambv/black#line-length 24 | max-line-length = 88 25 | # E501: line too long 26 | # W503: Line break occurred before a binary operator 27 | # E203: Whitespace before ':' 28 | # D202 No blank lines allowed after function docstring 29 | # W504 line break after binary operator 30 | ignore = 31 | E501, 32 | W503, 33 | E203, 34 | D202, 35 | W504 36 | 37 | [isort] 38 | # https://github.com/timothycrosley/isort 39 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 40 | # splits long import on multiple lines indented by 4 spaces 41 | multi_line_output = 3 42 | include_trailing_comma=True 43 | force_grid_wrap=0 44 | use_parentheses=True 45 | line_length=88 46 | indent = " " 47 | # will group `import x` and `from x import` of the same module. 48 | force_sort_within_sections = true 49 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 50 | default_section = THIRDPARTY 51 | known_first_party = custom_components,tests 52 | forced_separate = tests 53 | combine_as_imports = true 54 | 55 | 56 | [mypy] 57 | python_version = 3.7 58 | ignore_errors = true 59 | follow_imports = silent 60 | ignore_missing_imports = true 61 | warn_incomplete_stub = true 62 | warn_redundant_casts = true 63 | warn_unused_configs = true 64 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """""" 2 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """pytest fixtures.""" 2 | 3 | import json 4 | import os 5 | import pytest 6 | from unittest.mock import AsyncMock, MagicMock, patch 7 | 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.setup import async_setup_component 10 | from homeassistant.helpers.device_registry import DeviceEntry 11 | 12 | from custom_components.mqtt_vacuum_camera.const import DOMAIN, CameraModes 13 | 14 | 15 | async def test_async_setup(hass): 16 | """Test the component get setup.""" 17 | assert await async_setup_component(hass, DOMAIN, {}) is True 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def auto_enable_custom_integrations(enable_custom_integrations): 22 | """Enable custom integrations defined in the test dir.""" 23 | yield 24 | 25 | 26 | @pytest.fixture 27 | def mock_hass(): 28 | """Mock Home Assistant instance.""" 29 | hass = MagicMock(spec=HomeAssistant) 30 | hass.config.path.return_value = "/config" 31 | return hass 32 | 33 | 34 | @pytest.fixture 35 | def mock_device_entry_hypfer(): 36 | """Mock device entry for Hypfer firmware.""" 37 | device = MagicMock(spec=DeviceEntry) 38 | device.sw_version = "Valetudo 2023.01.0" 39 | return device 40 | 41 | 42 | @pytest.fixture 43 | def mock_device_entry_rand256(): 44 | """Mock device entry for Rand256 firmware.""" 45 | device = MagicMock(spec=DeviceEntry) 46 | device.sw_version = "RandoFirmware 1.0" 47 | return device 48 | 49 | 50 | @pytest.fixture 51 | def hypfer_sample_data(): 52 | """Load Hypfer sample data.""" 53 | with open("tests/json_samples/hypfer_sample.json", "r") as f: 54 | return json.load(f) 55 | 56 | 57 | @pytest.fixture 58 | def rand256_sample_data(): 59 | """Load Rand256 sample data.""" 60 | with open("tests/json_samples/rand256_sample.json", "r") as f: 61 | return json.load(f) 62 | 63 | 64 | @pytest.fixture 65 | def mock_shared_data(): 66 | """Mock shared data object.""" 67 | shared = MagicMock() 68 | shared.camera_mode = CameraModes.MAP_VIEW 69 | shared.file_name = "test_vacuum" 70 | shared.is_rand = False 71 | shared.image_grab = False 72 | shared.snapshot_take = False 73 | shared.enable_snapshots = False 74 | shared.reload_config = False 75 | shared.obstacle_view = False 76 | shared.obstacle_x = 0 77 | shared.obstacle_y = 0 78 | shared.destinations = [] 79 | shared.disable_floor = False 80 | shared.disable_wall = False 81 | shared.disable_robot = False 82 | shared.disable_charger = False 83 | shared.disable_path = False 84 | shared.disable_segments = False 85 | shared.disable_no_go_areas = False 86 | shared.disable_no_mop_areas = False 87 | shared.disable_virtual_walls = False 88 | shared.disable_obstacles = False 89 | return shared 90 | -------------------------------------------------------------------------------- /tests/mqtt_data.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sca075/mqtt_vacuum_camera/33fa1e46b509d99d7799bf1ab4e2340484ea5edf/tests/mqtt_data.raw -------------------------------------------------------------------------------- /tests/test_camera.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import pytest 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | from custom_components.mqtt_vacuum_camera.camera import ValetudoCamera 5 | from homeassistant.components.camera import Camera 6 | 7 | 8 | @pytest.fixture 9 | def mock_mqtt(hass, mqtt_mock): 10 | """Mock the MQTT component.""" 11 | mqtt_mock().async_subscribe.return_value = AsyncMock() 12 | return mqtt_mock 13 | 14 | 15 | def load_mqtt_topic_from_file(file_path): 16 | """Load MQTT topic from a file.""" 17 | with open(file_path, "r") as file: 18 | return file.read().strip() 19 | 20 | 21 | @pytest.mark.asyncio 22 | @pytest.mark.enable_socket 23 | async def test_update_success( 24 | hass, aioclient_mock, socket_enabled, enable_custom_integrations, mock_mqtt 25 | ): 26 | """Tests a fully successful async_update.""" 27 | # Load MQTT topic from file 28 | mqtt_topic = load_mqtt_topic_from_file("tests/mqtt_data.raw") 29 | 30 | camera = MagicMock() 31 | camera.getitem = AsyncMock( 32 | side_effect=[ 33 | {"vacuum_entity": "vacuum.my_vacuum", "vacuum_map": "valetudo/my_vacuum"}, 34 | ] 35 | ) 36 | 37 | with patch( 38 | "custom_components.mqtt_vacuum_camera.camera.ConfigFlowHandler.async_step_user", 39 | return_value={"title": "My Vacuum Camera"}, 40 | ): 41 | camera = ValetudoCamera(Camera, {"path": "homeassistant/core"}) 42 | camera.camera_image() 43 | 44 | expected = { 45 | "calibration_points": None, 46 | "json_data": None, 47 | "listen_to": None, 48 | "robot_position": None, 49 | "snapshot": None, 50 | "snapshot_path": "/local/snapshot_" + "my_vacuum" + ".png", 51 | "vacuum_json_id": None, 52 | "vacuum_status": None, 53 | } 54 | 55 | assert socket.socket(socket.AF_INET, socket.SOCK_STREAM) 56 | assert camera.available is True 57 | assert camera.state == "idle" 58 | assert expected == camera.extra_state_attributes 59 | assert camera.name == "Camera" 60 | 61 | # Assert that the MQTT topic is as expected 62 | assert mqtt_topic == "valetudo/my_vacuum/MapData/map-data-hass" 63 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # from homeassistant import config_entries, core 4 | from custom_components.mqtt_vacuum_camera import config_flow 5 | 6 | # from unittest.mock import patch 7 | from unittest import mock 8 | 9 | 10 | @pytest.fixture 11 | def vacuum_user_input(): 12 | return { 13 | config_flow.CONF_VACUUM_ENTITY_ID: "vacuum.entity_id", 14 | } 15 | 16 | 17 | async def test_flow_user_init(hass): 18 | """Test the initialization of the form for step of the config flow.""" 19 | result = await hass.config_entries.flow.async_init( 20 | config_flow.DOMAIN, context={"source": "user"} 21 | ) 22 | expected = { 23 | "data_schema": config_flow.VACUUM_SCHEMA, 24 | "description_placeholders": None, 25 | "errors": None, 26 | "flow_id": mock.ANY, 27 | "handler": config_flow.ValetudoCameraFlowHandler, 28 | "last_step": None, 29 | "step_id": "user", 30 | "type": "form", 31 | } 32 | assert expected == result 33 | 34 | 35 | async def test_flow_user_creates_config_entry(hass, vacuum_user_input): 36 | """Test the config entry is successfully created.""" 37 | result = await hass.config_entries.flow.async_init( 38 | config_flow.DOMAIN, context={"source": "user"} 39 | ) 40 | await hass.config_entries.flow.async_configure( 41 | result["flow_id"], 42 | user_input={**vacuum_user_input}, 43 | ) 44 | await hass.async_block_till_done() 45 | 46 | # Retrieve the created entry and verify data 47 | entries = hass.config_entries.async_entries(config_flow.DOMAIN) 48 | assert len(entries) == 1 49 | assert entries[0].data == { 50 | "vacuum_entity": "vacuum.entity_id", 51 | } 52 | --------------------------------------------------------------------------------