├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── labels.yml ├── pre-commit-config.yaml ├── release-drafter-beta.yml ├── release-drafter.yml ├── scripts │ ├── get_new_version.py │ └── pr_extract_labels.py └── workflows │ ├── bandit.yml │ ├── bump_version_and_prerelease.yml │ ├── bump_version_and_release.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── pr-checker.yml │ ├── stale.yaml │ ├── sync-dev-current.yml │ ├── synchronize-labels.yml │ └── validate.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .ruff.toml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── VERSION ├── custom_components └── robonect │ ├── __init__.py │ ├── binary_sensor.py │ ├── button.py │ ├── client │ ├── __init__.py │ ├── __init__.pyc │ ├── client.py │ ├── const.py │ └── utils.py │ ├── config_flow.py │ ├── const.py │ ├── definitions.py │ ├── device_tracker.py │ ├── entity.py │ ├── exceptions.py │ ├── icons.json │ ├── lawn_mower.py │ ├── manifest.json │ ├── models.py │ ├── sensor.py │ ├── services.yaml │ ├── switch.py │ ├── translations │ ├── de.json │ ├── en.json │ ├── fr.json │ └── nl.json │ └── utils.py ├── hacs.json ├── images ├── brand │ ├── icon.png │ ├── icon@2x.png │ ├── logo.png │ ├── logo@2x.png │ └── original.jpg └── screenshots │ ├── config_flow_1.png │ ├── config_flow_2.png │ ├── diagnostic_1.png │ ├── diagnostic_2.png │ ├── diagnostic_3.png │ ├── disable-debug-logging.gif │ ├── enable-debug-logging.gif │ ├── integration_device.png │ ├── lovelace_card.png │ ├── mowing_job.png │ ├── options_1.png │ ├── options_2.png │ ├── options_3.png │ ├── options_4.png │ ├── options_5.png │ ├── options_6.png │ ├── options_7.png │ ├── rest_category.png │ └── timer.png ├── requirements.txt └── setup.cfg /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @geertmeersman 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: geertmeersman # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ["https://www.buymeacoffee.com/geertmeersman"] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: geertmeersman 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Log information** 27 | If possible activate debug logging on the component as mentioned below. 28 | 29 | 30 | 31 | ```yaml 32 | logger: 33 | default: warning 34 | logs: 35 | custom_components.robonect: debug 36 | ``` 37 | 38 | **Additional context** 39 | Add any other context about the problem here. 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement, feature 6 | assignees: geertmeersman 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" 16 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | - name: Stale 2 | description: Stale 3 | color: 393C41 4 | - name: feat 5 | description: A new feature 6 | color: a2eeef 7 | - name: fix 8 | description: A bug fix 9 | color: d3fc03 10 | - name: docs 11 | description: Documentation only changes 12 | color: D4C5F9 13 | - name: documentation 14 | description: This issue relates to writing documentation 15 | color: D4C5F9 16 | - name: doc 17 | description: This issue relates to writing documentation 18 | color: D4C5F9 19 | - name: style 20 | description: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 21 | color: D4C5F9 22 | - name: refactor 23 | description: A code change that neither fixes a bug nor adds a feature 24 | color: D4C5F9 25 | - name: perf 26 | description: A code change that improves performance 27 | color: d3fc03 28 | - name: test 29 | description: Adding missing or correcting existing tests 30 | color: d3fc03 31 | - name: chore 32 | description: Changes to the build process or auxiliary tools and libraries such as documentation generation 33 | color: d3fc03 34 | - name: major 35 | description: A change requiring a major version bump 36 | color: 6b230e 37 | - name: minor 38 | description: A change requiring a minor version bump 39 | color: cc6749 40 | - name: patch 41 | description: A change requiring a patch version bump 42 | color: f9d0c4 43 | - name: breaking 44 | description: A breaking change 45 | color: d30000 46 | - name: breaking change 47 | description: A breaking change 48 | color: d30000 49 | -------------------------------------------------------------------------------- /.github/pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.2.0 4 | hooks: 5 | - id: pyupgrade 6 | stages: [manual] 7 | args: 8 | - "--py38-plus" 9 | 10 | - repo: https://github.com/psf/black 11 | rev: 22.10.0 12 | hooks: 13 | - id: black 14 | stages: [manual] 15 | args: 16 | - --safe 17 | files: ^((custom_components|script|tests)/.+)?[^/]+\.py$ 18 | 19 | - repo: https://github.com/codespell-project/codespell 20 | rev: v2.2.2 21 | hooks: 22 | - id: codespell 23 | stages: [manual] 24 | args: 25 | - --quiet-level=2 26 | - --ignore-words-list=hass 27 | - --skip=pre-commit-config.yaml 28 | 29 | - repo: https://github.com/pre-commit/pre-commit-hooks 30 | rev: v4.3.0 31 | hooks: 32 | - id: check-executables-have-shebangs 33 | stages: [manual] 34 | - id: check-json 35 | stages: [manual] 36 | - id: requirements-txt-fixer 37 | stages: [manual] 38 | - id: check-ast 39 | stages: [manual] 40 | - id: mixed-line-ending 41 | stages: [manual] 42 | args: 43 | - --fix=lf 44 | -------------------------------------------------------------------------------- /.github/release-drafter-beta.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | 3 | ## This is a beta release 4 | 5 | categories: 6 | - title: 🚀 Features 7 | labels: 8 | - feature 9 | - enhancement 10 | - title: 🐛 Bug Fixes 11 | labels: 12 | - fix 13 | - bug 14 | - title: ⚠️ Breaking 15 | labels: 16 | - breaking 17 | - title: ⚠️ Changes 18 | labels: 19 | - changed 20 | - title: ⛔️ Deprecated 21 | labels: 22 | - deprecated 23 | - title: 🗑 Removed 24 | labels: 25 | - removed 26 | - title: 🔐 Security 27 | labels: 28 | - security 29 | - title: 📄 Documentation 30 | labels: 31 | - docs 32 | - documentation 33 | - title: 🧩 Dependency Updates 34 | labels: 35 | - deps 36 | - dependencies 37 | 38 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 39 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 40 | exclude-labels: 41 | - skip-changelog 42 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "Release v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | template: | 4 | [![maintainer](https://img.shields.io/badge/maintainer-Geert%20Meersman-green?style=for-the-badge&logo=github)](https://github.com/geertmeersman) 5 | [![buyme_coffee](https://img.shields.io/badge/Buy%20me%20an%20Omer-donate-yellow?style=for-the-badge&logo=buymeacoffee)](https://www.buymeacoffee.com/geertmeersman) 6 | 7 | ## What Changed 👀 8 | 9 | $CHANGES 10 | **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 11 | categories: 12 | - title: 🚀 Features 13 | labels: 14 | - feat 15 | - feature 16 | - enhancement 17 | - title: 🐛 Bug Fixes 18 | labels: 19 | - fix 20 | - bug 21 | - title: ⚠️ Breaking 22 | labels: 23 | - breaking 24 | - breaking change 25 | - title: ⚠️ Changes 26 | labels: 27 | - changed 28 | - title: ⛔️ Deprecated 29 | labels: 30 | - deprecated 31 | - title: 🗑 Removed 32 | labels: 33 | - removed 34 | - title: 🔐 Security 35 | labels: 36 | - security 37 | - title: 📄 Documentation 38 | labels: 39 | - docs 40 | - doc 41 | - documentation 42 | - title: 🛠 Refactoring 43 | labels: 44 | - refactor 45 | - style 46 | - title: 🚀 Performance 47 | labels: 48 | - perf 49 | - title: 🧪 Test 50 | labels: 51 | - test 52 | - title: 👷 Chore 53 | labels: 54 | - chore 55 | - title: 🧩 Dependency Updates 56 | labels: 57 | - deps 58 | - dependencies 59 | collapse-after: 5 60 | 61 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 62 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 63 | version-resolver: 64 | major: 65 | labels: 66 | - major 67 | minor: 68 | labels: 69 | - minor 70 | patch: 71 | labels: 72 | - patch 73 | default: patch 74 | 75 | exclude-labels: 76 | - skip-changelog 77 | -------------------------------------------------------------------------------- /.github/scripts/get_new_version.py: -------------------------------------------------------------------------------- 1 | """Script to calculate the next beta version.""" 2 | 3 | import os 4 | import sys 5 | 6 | import requests 7 | 8 | # Get repository owner and repository name from the environment variable 9 | repository = os.environ["GITHUB_REPOSITORY"] 10 | owner, repo = repository.split("/") 11 | 12 | # print(f"Repository: {repo}") 13 | # print(f"Owner: {owner}") 14 | 15 | # Get the latest release information 16 | response = requests.get( 17 | f"https://api.github.com/repos/{owner}/{repo}/releases/latest", timeout=10 18 | ) 19 | latest_release = response.json() 20 | latest_version = latest_release["tag_name"] 21 | 22 | ref = os.environ["GITHUB_REF"] 23 | 24 | # Get the commit count since the latest release 25 | response = requests.get( 26 | f"https://api.github.com/repos/{owner}/{repo}/compare/{latest_version}...{ref}", 27 | timeout=10, 28 | ) 29 | compare_info = response.json() 30 | commit_count = compare_info["total_commits"] 31 | 32 | 33 | def get_semver_level(commit_messages): 34 | """Extract SemVer level.""" 35 | major_keywords = ["breaking change", "major"] 36 | minor_keywords = ["feat", "minor"] 37 | for message in commit_messages: 38 | if any(keyword in message for keyword in major_keywords): 39 | return "major" 40 | for message in commit_messages: 41 | if any(keyword in message for keyword in minor_keywords): 42 | return "minor" 43 | return "patch" 44 | 45 | 46 | # Determine version components based on commit messages 47 | commit_messages = [] 48 | for commit in compare_info["commits"]: 49 | commit_messages.append(commit["commit"]["message"]) 50 | 51 | bump = get_semver_level(commit_messages) 52 | 53 | major, minor, patch = map(int, latest_version[1:].split(".")) 54 | 55 | if bump == "major": 56 | major += 1 57 | elif bump == "minor": 58 | minor += 1 59 | else: 60 | patch += 1 61 | 62 | # Create the next version 63 | next_version = f"v{major}.{minor}.{patch}" 64 | 65 | # Check if there are any commits since the latest release 66 | if commit_count > 0: 67 | next_version += f"-beta.{commit_count}" 68 | 69 | print(next_version) 70 | sys.exit(0) 71 | -------------------------------------------------------------------------------- /.github/scripts/pr_extract_labels.py: -------------------------------------------------------------------------------- 1 | """Script to extract the labels for a PR.""" 2 | 3 | import os 4 | import re 5 | import sys 6 | 7 | 8 | def extract_semver_types(commit_messages): 9 | """Extract SemVer types.""" 10 | types = [] 11 | for message in commit_messages: 12 | pattern = r"^(feat|fix|chore|docs|style|refactor|perf|test)(?:\(.+?\))?:\s(.+)$" 13 | match = re.match(pattern, message) 14 | if match and match.group(1) not in types: 15 | types.append(match.group(1)) 16 | return types 17 | 18 | 19 | def get_semver_level(commit_messages): 20 | """Extract SemVer level.""" 21 | major_keywords = ["breaking change", "major"] 22 | minor_keywords = ["feat", "minor"] 23 | for message in commit_messages: 24 | if any(keyword in message for keyword in major_keywords): 25 | return "major" 26 | for message in commit_messages: 27 | if any(keyword in message for keyword in minor_keywords): 28 | return "minor" 29 | return "patch" 30 | 31 | 32 | file_path = "COMMIT_MESSAGES" 33 | if os.path.exists(file_path): 34 | with open(file_path) as file: 35 | messages = [] 36 | for line in file: 37 | messages.append(line.strip()) 38 | semver_level = get_semver_level(messages) 39 | types = extract_semver_types(messages) 40 | types.append(semver_level) 41 | print(types) 42 | sys.exit(0) 43 | else: 44 | sys.exit(f"ERROR: {file_path} does not exist") 45 | -------------------------------------------------------------------------------- /.github/workflows/bandit.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # Bandit is a security linter designed to find common security issues in Python code. 7 | # This action will run Bandit on your codebase. 8 | # The results of the scan will be found under the Security tab of your repository. 9 | 10 | # https://github.com/marketplace/actions/bandit-scan is ISC licensed, by abirismyname 11 | # https://pypi.org/project/bandit/ is Apache v2.0 licensed, by PyCQA 12 | 13 | name: Analysis - Security - Bandit 14 | on: 15 | push: 16 | branches: ["main"] 17 | paths: 18 | - "**.py" 19 | pull_request: 20 | # The branches below must be a subset of the branches above 21 | branches: ["main"] 22 | paths: 23 | - "**.py" 24 | schedule: 25 | - cron: "22 19 * * 0" 26 | 27 | jobs: 28 | bandit: 29 | permissions: 30 | contents: read # for actions/checkout to fetch code 31 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 32 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 33 | 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Bandit Scan 38 | uses: shundor/python-bandit-scan@9cc5aa4a006482b8a7f91134412df6772dbda22c 39 | with: # optional arguments 40 | # exit with 0, even with results found 41 | exit_zero: true # optional, default is DEFAULT 42 | # Github token of the repository (automatically created by Github) 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information. 44 | # File or directory to run bandit on 45 | # path: # optional, default is . 46 | # Report only issues of a given severity level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) 47 | # level: # optional, default is UNDEFINED 48 | # Report only issues of a given confidence level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) 49 | # confidence: # optional, default is UNDEFINED 50 | # comma-separated list of paths (glob patterns supported) to exclude from scan (note that these are in addition to the excluded paths provided in the config file) (default: .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg) 51 | # excluded_paths: # optional, default is DEFAULT 52 | # comma-separated list of test IDs to skip 53 | # skips: # optional, default is DEFAULT 54 | # path to a .bandit file that supplies command line arguments 55 | # ini_path: # optional, default is DEFAULT 56 | -------------------------------------------------------------------------------- /.github/workflows/bump_version_and_prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Release - Bump and BETA 2 | on: 3 | workflow_dispatch: 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | create_beta_release: 11 | name: Bump and BETA release 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | steps: 17 | - name: ⤵️ Checkout code 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | ref: ${{ github.ref }} 22 | 23 | - name: Determine next version 24 | id: next-version 25 | run: | 26 | pip install git-conventional-version semantic_version semver 27 | git pull 28 | next_version=$(python .github/scripts/get_new_version.py) 29 | last_tag=$(git describe --tags --abbrev=0 origin/main) 30 | echo Last tag: $last_tag 31 | changelog=$(echo -e "## Commits\n";git log --pretty=format:%s $last_tag..refs/heads/dev-current|while read i; do echo "- $i";done) 32 | echo "Calculated next version: $next_version" 33 | echo "Changelog: $changelog" 34 | echo "NEW_VERSION=$next_version" >> "$GITHUB_OUTPUT" 35 | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) 36 | echo "CHANGELOG<<$EOF" >> $GITHUB_OUTPUT 37 | echo "$changelog" >> $GITHUB_OUTPUT 38 | echo "$EOF" >> $GITHUB_OUTPUT 39 | 40 | - name: 🗑 Delete drafts 41 | uses: hugo19941994/delete-draft-releases@v1.0.1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: 🔄 Update version in 'VERSION' and 'manifest.json' and push changes 46 | env: 47 | GITHUB_REPO: ${{ github.event.repository.name }} 48 | run: | 49 | echo "** Manifest before replace **" 50 | cat custom_components/$GITHUB_REPO/manifest.json 51 | sed -i 's/"version": ".*"/"version": "'${{ steps.next-version.outputs.NEW_VERSION }}'"/g' custom_components/$GITHUB_REPO/manifest.json 52 | echo "** Manifest after replace **" 53 | cat custom_components/$GITHUB_REPO/manifest.json 54 | echo ${{ steps.next-version.outputs.NEW_VERSION }} > VERSION 55 | 56 | - name: 🚀 Add and commit changes 57 | uses: EndBug/add-and-commit@v9 58 | with: 59 | message: Bump version 60 | 61 | - name: 📝 Publish release 62 | uses: release-drafter/release-drafter@v6 63 | id: release_published 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | config-name: release-drafter-beta.yml 68 | prerelease: true 69 | publish: true 70 | commitish: ${{ github.ref }} 71 | tag: ${{ steps.next-version.outputs.NEW_VERSION }} 72 | name: Prerelease ${{ steps.next-version.outputs.NEW_VERSION }} 73 | footer: "${{ steps.next-version.outputs.CHANGELOG }}" 74 | 75 | - name: 📦 Create zip file 76 | run: | 77 | cd custom_components/${{ github.event.repository.name }} 78 | zip -r "${{ github.event.repository.name }}.zip" . 79 | mv "${{ github.event.repository.name }}.zip" ../.. 80 | 81 | - name: 📎 Upload zip file to release 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: release-artifact 85 | path: "${{ github.event.repository.name }}.zip" 86 | 87 | - name: 📝 Update release with zip file 88 | run: | 89 | gh release upload ${{ steps.release_published.outputs.tag_name }} "${{ github.event.repository.name }}.zip" 90 | env: 91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 92 | 93 | - name: Discord notification 94 | env: 95 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_BETA }} 96 | uses: Ilshidur/action-discord@master 97 | with: 98 | args: "New BETA release published: https://github.com/{{ EVENT_PAYLOAD.repository.full_name }}/releases/tag/${{ steps.next-version.outputs.NEW_VERSION }}" 99 | -------------------------------------------------------------------------------- /.github/workflows/bump_version_and_release.yml: -------------------------------------------------------------------------------- 1 | name: Release - Bump and Release 2 | on: [workflow_dispatch] 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | jobs: 9 | create_release_draft: 10 | name: Create the release draft 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | steps: 16 | - name: ⤵️ Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | ref: ${{ github.ref }} 20 | 21 | - name: 🗑 Delete drafts 22 | uses: hugo19941994/delete-draft-releases@v1.0.1 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: 📝 Draft release 27 | uses: release-drafter/release-drafter@v6 28 | id: release_drafter 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: 🔄 Update version in 'VERSION' and 'manifest.json' and push changes 33 | env: 34 | tag_name: ${{ steps.release_drafter.outputs.tag_name }} 35 | GITHUB_REPO: ${{ github.event.repository.name }} 36 | run: | 37 | echo "** Manifest before replace **" 38 | cat custom_components/$GITHUB_REPO/manifest.json 39 | sed -i 's/"version": ".*"/"version": "'$tag_name'"/g' custom_components/$GITHUB_REPO/manifest.json 40 | echo "** Manifest after replace **" 41 | cat custom_components/$GITHUB_REPO/manifest.json 42 | echo $tag_name > VERSION 43 | 44 | - name: 🚀 Add and commit changes 45 | uses: EndBug/add-and-commit@v9 46 | with: 47 | message: Bump version ${{ steps.release_drafter.outputs.tag_name }} 48 | 49 | - name: 📝 Publish release 50 | uses: release-drafter/release-drafter@v6 51 | id: release_published 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | publish: true 56 | 57 | - name: "✏️ Generate release changelog" 58 | uses: heinrichreimer/github-changelog-generator-action@v2.4 59 | with: 60 | token: ${{ secrets.GH_PAT }} 61 | issues: true 62 | issuesWoLabels: true 63 | pullRequests: true 64 | prWoLabels: true 65 | unreleased: false 66 | addSections: '{"documentation":{"prefix":"**Documentation:**","labels":["documentation"]}}' 67 | 68 | - name: ✅ Commit release notes 69 | uses: EndBug/add-and-commit@v9 70 | with: 71 | message: Commit release notes ${{ steps.release_drafter.outputs.tag_name }} 72 | 73 | - name: 📦 Create zip file 74 | run: | 75 | cd custom_components/${{ github.event.repository.name }} 76 | zip -r "${{ github.event.repository.name }}.zip" . 77 | mv "${{ github.event.repository.name }}.zip" ../.. 78 | 79 | - name: 📎 Upload zip file to release 80 | uses: actions/upload-artifact@v4 81 | with: 82 | name: release-artifact 83 | path: "${{ github.event.repository.name }}.zip" 84 | 85 | - name: 📝 Update release with zip file 86 | run: | 87 | gh release upload ${{ steps.release_drafter.outputs.tag_name }} "${{ github.event.repository.name }}.zip" 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - name: 🚮 Remove pre-releases 92 | run: | 93 | gh release list --json tagName,isPrerelease -q '.[] | select(.isPrerelease == true) | .tagName' | while read -r tag_name; do 94 | echo "Deleting pre-release with tag: $tag_name" 95 | # Delete the release 96 | gh release delete "$tag_name" --yes 97 | # Delete the Git tag 98 | echo "Deleting Git tag: $tag_name" 99 | git push origin --delete "$tag_name" || echo "Failed to delete tag $tag_name from remote" 100 | git tag -d "$tag_name" || echo "Failed to delete tag $tag_name locally" 101 | done 102 | env: 103 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 104 | 105 | - name: 🚀 Discord notification 106 | env: 107 | tag_name: ${{ steps.release_drafter.outputs.tag_name }} 108 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 109 | uses: Ilshidur/action-discord@master 110 | with: 111 | args: "New release published: https://github.com/{{ EVENT_PAYLOAD.repository.full_name }}/releases/tag/{{tag_name}}" 112 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "Analysis - CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | paths: 18 | - "**.py" 19 | pull_request: 20 | # The branches below must be a subset of the branches above 21 | branches: ["main"] 22 | paths: 23 | - "**.py" 24 | schedule: 25 | - cron: "37 6 * * 5" 26 | 27 | concurrency: 28 | group: ${{ github.workflow }}-${{ github.ref }} 29 | cancel-in-progress: true 30 | 31 | jobs: 32 | analyze: 33 | name: Analyze 34 | runs-on: ubuntu-latest 35 | permissions: 36 | actions: read 37 | contents: read 38 | security-events: write 39 | 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | language: ["python"] 44 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 45 | # Use only 'java' to analyze code written in Java, Kotlin or both 46 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 47 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 48 | 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v4 52 | 53 | # Initializes the CodeQL tools for scanning. 54 | - name: Initialize CodeQL 55 | uses: github/codeql-action/init@v3 56 | with: 57 | languages: ${{ matrix.language }} 58 | # If you wish to specify custom queries, you can do so here or in a config file. 59 | # By default, queries listed here will override any specified in a config file. 60 | # Prefix the list here with "+" to use these queries and those in the config file. 61 | 62 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 63 | # queries: security-extended,security-and-quality 64 | 65 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 66 | # If this step fails, then you should remove it and run the build manually (see below) 67 | - name: Autobuild 68 | uses: github/codeql-action/autobuild@v3 69 | 70 | # ℹ️ Command-line programs to run using the OS shell. 71 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 72 | 73 | # If the Autobuild fails above, remove it and uncomment the following three lines. 74 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 75 | 76 | # - run: | 77 | # echo "Run, Build Application using script" 78 | # ./location_of_script_within_repo/buildscript.sh 79 | 80 | - name: Perform CodeQL Analysis 81 | uses: github/codeql-action/analyze@v3 82 | with: 83 | category: "/language:${{matrix.language}}" 84 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: "Analysis - Dependency Review" 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: "Checkout Repository" 18 | uses: actions/checkout@v4 19 | - name: "Dependency Review" 20 | uses: actions/dependency-review-action@v4 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Analysis - Ruff" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | paths: 8 | - "**.py" 9 | pull_request: 10 | branches: 11 | - "main" 12 | paths: 13 | - "**.py" 14 | 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | ruff: 21 | name: "Ruff" 22 | runs-on: "ubuntu-latest" 23 | steps: 24 | - name: "Checkout the repository" 25 | uses: "actions/checkout@v4" 26 | 27 | - name: "Set up Python" 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.10" 31 | cache: "pip" 32 | 33 | - name: "Install requirements" 34 | run: python3 -m pip install -r requirements.txt 35 | 36 | - name: "Run" 37 | run: python3 -m ruff check . 38 | -------------------------------------------------------------------------------- /.github/workflows/pr-checker.yml: -------------------------------------------------------------------------------- 1 | name: PR - Assign and check labels 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - reopened 7 | - edited 8 | - synchronize 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | assign_labels: 16 | name: Assign SemVer labels 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: write 20 | pull-requests: write 21 | steps: 22 | - name: ⤵️ Checkout code 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Get commit messages 28 | id: commit_messages 29 | run: | 30 | PR_NUMBER="${{ github.event.pull_request.number }}" 31 | COMMIT_MESSAGES=$(curl -sSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 32 | "https://api.github.com/repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" | \ 33 | jq -r '.[].commit.message') 34 | echo "$COMMIT_MESSAGES" > COMMIT_MESSAGES 35 | echo "$COMMIT_MESSAGES" 36 | 37 | - name: Determine SemVer level 38 | id: semver_level 39 | run: | 40 | labels=$(python .github/scripts/pr_extract_labels.py) 41 | echo Labels: $labels 42 | echo "labels=$labels" >> "$GITHUB_OUTPUT" 43 | 44 | - name: Delete commit messages file 45 | run: | 46 | rm COMMIT_MESSAGES 47 | 48 | - name: Assign SemVer label 49 | uses: actions/github-script@v7 50 | with: 51 | script: | 52 | github.rest.issues.addLabels({ 53 | issue_number: context.issue.number, 54 | owner: context.repo.owner, 55 | repo: context.repo.repo, 56 | labels: ${{ steps.semver_level.outputs.labels }} 57 | }); 58 | check_semver_labels: 59 | name: Check Semver labels in PR 60 | needs: assign_labels 61 | runs-on: "ubuntu-latest" 62 | steps: 63 | - name: Check for Semver labels 64 | uses: danielchabr/pr-labels-checker@v3.3 65 | with: 66 | hasSome: major,minor,patch 67 | githubToken: ${{ secrets.GITHUB_TOKEN }} 68 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: "PR & Issues - Close stale iteams" 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days." 13 | stale-pr-message: "This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days." 14 | close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity." 15 | days-before-stale: 30 16 | days-before-close: 5 17 | days-before-pr-close: -1 18 | -------------------------------------------------------------------------------- /.github/workflows/sync-dev-current.yml: -------------------------------------------------------------------------------- 1 | name: Sync dev-current with main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: # Allow manual triggering 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | sync-dev-current: 15 | name: Synchronise dev-current 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout the repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Git 23 | run: | 24 | git config user.name "github-actions[bot]" 25 | git config user.email "github-actions[bot]@users.noreply.github.com" 26 | 27 | - name: Sync dev-current with main 28 | run: | 29 | #!/bin/bash 30 | set -euo pipefail 31 | 32 | handle_error() { 33 | echo "ERROR: Command failed: $1" 34 | exit 1 35 | } 36 | cleanup() { 37 | echo "INFO: Cleaning up..." 38 | # Return to original branch if possible 39 | git switch - 2>/dev/null || true 40 | } 41 | 42 | trap cleanup EXIT INT TERM 43 | 44 | # Fetch the latest updates 45 | echo "INFO: Pulling the latest changes..." 46 | git pull || handle_error "git pull failed" 47 | 48 | # Ensure we are on the main branch and update it 49 | echo "INFO: Switching to the main branch..." 50 | git switch main || handle_error "Failed to switch to main branch" 51 | git pull || handle_error "Failed to update main branch" 52 | 53 | 54 | # Ensure dev-current branch is up-to-date 55 | echo "INFO: Switching to the dev-current branch..." 56 | if git show-ref --verify --quiet refs/heads/dev-current; then 57 | git switch dev-current 58 | git pull 59 | else 60 | echo "INFO: dev-current branch does not exist." 61 | fi 62 | 63 | # Check if dev-current is ahead of main 64 | echo "INFO: Checking if dev-current has unmerged commits..." 65 | ahead=$(git rev-list --count main..dev-current 2>/dev/null || echo 0) 66 | 67 | if [[ $ahead -gt 0 ]]; then 68 | echo "----------------------------------------------------" 69 | echo "INFO: dev-current is ahead of main by $ahead commits" 70 | echo "INFO: No sync will occur." 71 | echo "Commit list:" 72 | git log --pretty=format:" %h %s" main..dev-current 73 | exit 0 74 | fi 75 | 76 | # Sync dev-current with main 77 | echo "INFO: Syncing dev-current with main..." 78 | git switch main 79 | 80 | # Verify we're on main branch before proceeding 81 | current_branch=$(git rev-parse --abbrev-ref HEAD) 82 | if [[ "$current_branch" != "main" ]]; then 83 | echo "ERROR: Failed to switch to main branch" 84 | exit 1 85 | fi 86 | 87 | if git show-ref --verify --quiet refs/heads/dev-current; then 88 | git branch -D dev-current 89 | fi 90 | git branch dev-current 91 | git switch dev-current 92 | 93 | # Verify branch creation and switch succeeded 94 | current_branch=$(git rev-parse --abbrev-ref HEAD) 95 | if [[ "$current_branch" != "dev-current" ]]; then 96 | echo "ERROR: Failed to create and switch to dev-current branch" 97 | exit 1 98 | fi 99 | 100 | # Verify the branch points to the same commit as main 101 | dev_current_sha=$(git rev-parse HEAD) 102 | main_sha=$(git rev-parse main) 103 | if [[ "$dev_current_sha" != "$main_sha" ]]; then 104 | echo "ERROR: Branch creation failed, dev-current does not match main" 105 | exit 1 106 | fi 107 | 108 | git push --force-with-lease --set-upstream origin dev-current 109 | echo "INFO: dev-current successfully synced with main." 110 | -------------------------------------------------------------------------------- /.github/workflows/synchronize-labels.yml: -------------------------------------------------------------------------------- 1 | name: Labels - Synchronize 2 | "on": 3 | push: 4 | paths: 5 | - .github/labels.yml 6 | workflow_dispatch: {} 7 | jobs: 8 | synchronize: 9 | name: Synchronize Labels 10 | runs-on: 11 | - ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: micnncim/action-label-syncer@v1 15 | env: 16 | GITHUB_TOKEN: ${{ github.token }} 17 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate - Hassfest & Hacs" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | paths: 9 | - "custom_components/**" 10 | pull_request: 11 | paths: 12 | - "custom_components/**" 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 20 | name: "Validate Hassfest" 21 | runs-on: "ubuntu-latest" 22 | steps: 23 | - name: "Checkout the repository" 24 | uses: "actions/checkout@v4" 25 | 26 | - name: "Run hassfest validation" 27 | uses: "home-assistant/actions/hassfest@master" 28 | 29 | hacs: # https://github.com/hacs/action 30 | name: "Validate HACS" 31 | runs-on: "ubuntu-latest" 32 | steps: 33 | - name: "Checkout the repository" 34 | uses: "actions/checkout@v4" 35 | 36 | - name: "Run HACS validation" 37 | uses: "hacs/action@main" 38 | with: 39 | category: "integration" 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | **/__pycache__/ 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: CHANGELOG.md 2 | minimum_pre_commit_version: 2.11.0 3 | default_stages: [pre-commit, pre-push, manual] 4 | repos: 5 | - repo: https://github.com/psf/black 6 | rev: 24.10.0 7 | hooks: 8 | - id: black 9 | - repo: https://github.com/pycqa/flake8 10 | rev: 7.1.1 11 | hooks: 12 | - id: flake8 13 | args: [--max-line-length=88, "-j8", "--ignore=E501,W503"] 14 | - repo: https://github.com/pycqa/isort 15 | rev: 5.13.2 16 | hooks: 17 | - id: isort 18 | args: [--filter-files] 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v1.14.1 21 | hooks: 22 | - id: mypy 23 | - repo: https://github.com/pre-commit/pre-commit-hooks 24 | rev: v5.0.0 25 | hooks: 26 | - id: check-added-large-files 27 | args: [--maxkb=800] 28 | - id: check-ast 29 | - id: check-docstring-first 30 | - id: check-json 31 | - id: check-toml 32 | - id: debug-statements 33 | - id: check-yaml 34 | - id: detect-private-key 35 | - id: file-contents-sorter 36 | - id: end-of-file-fixer 37 | - id: forbid-new-submodules 38 | - id: mixed-line-ending 39 | - id: trailing-whitespace 40 | - id: pretty-format-json 41 | args: [--autofix] 42 | exclude: 'manifest\.json' 43 | - repo: https://github.com/astral-sh/ruff-pre-commit 44 | # Ruff version. 45 | rev: v0.8.4 46 | hooks: 47 | - id: ruff 48 | args: 49 | - --fix 50 | - id: ruff-format 51 | files: ^((custom_components|test)/.+)?[^/]+\.(py|pyi)$ 52 | - repo: https://github.com/codespell-project/codespell 53 | rev: v2.3.0 54 | hooks: 55 | - id: codespell 56 | args: 57 | - --ignore-words-list=hass,commitish,THIRDPARTY,periode,succesful 58 | - --skip="./.*,*.csv,*.ambr" 59 | - --quiet-level=2 60 | exclude_types: [csv, json, html] 61 | - repo: https://github.com/pre-commit/mirrors-prettier 62 | rev: v3.0.3 63 | hooks: 64 | - id: prettier 65 | files: ^((custom_components|test)/.+)?[^/]+\.(py|pyi)$ 66 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | lint.select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | lint.ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "T201", # 'print' found 39 | "E731", # do not assign a lambda expression, use a def 40 | ] 41 | 42 | [lint.flake8-pytest-style] 43 | fixture-parentheses = false 44 | 45 | [lint.mccabe] 46 | max-complexity = 40 47 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socioeconomic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | geertmeersman@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [style]: https://standardjs.com/ 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! I'm thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 11 | 12 | ## Submitting a pull request 13 | 14 | 1. [Fork][fork] and clone the repository 15 | 1. Configure and install the dependencies: `yarn install` 16 | 1. Make sure the tests pass on your machine: `yarn test`, note: these tests also apply the linter, so no need to lint separately 17 | 1. Create a new branch: `git checkout -b my-branch-name` 18 | 1. Make your change, add tests, build with `yarn prettier && yarn lint --fix && yarn build` and make sure the tests still pass 19 | 1. Push to your fork and [submit a pull request][pr] 20 | 1. Give yourself a high five, and wait for your pull request to be reviewed and merged. 21 | 22 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 23 | 24 | - Follow the [style guide][style] which is using standard. Any linting errors should be shown when running `yarn test` 25 | - Write and update tests. 26 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 27 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 28 | 29 | Work in Progress pull request are also welcome to get feedback early on, or if there is something blocked you. 30 | 31 | ## Resources 32 | 33 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 34 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 35 | - [GitHub Help](https://help.github.com) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Geert Meersman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you found a vulnerability or suspect one to be present, please encode it [here](https://github.com/geertmeersman/robonect/security/advisories/new) 6 | Each vulnerability will be treated with the needed care and priority 7 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v2.4.2 2 | -------------------------------------------------------------------------------- /custom_components/robonect/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for Robonect.""" 2 | 3 | from __future__ import annotations 4 | 5 | import copy 6 | import logging 7 | 8 | from homeassistant.components.binary_sensor import BinarySensorEntity 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import ( 11 | CONF_MONITORED_VARIABLES, 12 | STATE_OFF, 13 | STATE_ON, 14 | STATE_UNAVAILABLE, 15 | ) 16 | from homeassistant.core import HomeAssistant, State, callback 17 | from homeassistant.helpers.entity import EntityCategory 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | 20 | from . import RobonectDataUpdateCoordinator 21 | from .const import ( 22 | ATTRIBUTION_REST, 23 | CONF_ATTRS_UNITS, 24 | CONF_REST_ENABLED, 25 | CONF_WINTER_MODE, 26 | DOMAIN, 27 | ) 28 | from .definitions import BINARY_SENSORS, RobonectSensorEntityDescription 29 | from .entity import RobonectCoordinatorEntity, RobonectEntity 30 | from .utils import adapt_attributes, get_json_dict_path 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | 35 | async def async_setup_entry( 36 | hass: HomeAssistant, 37 | entry: ConfigEntry, 38 | async_add_entities: AddEntitiesCallback, 39 | ) -> None: 40 | """Set up Robonect binary sensors from config entry.""" 41 | 42 | if entry.data[CONF_REST_ENABLED] is True: 43 | _LOGGER.debug("Creating REST binary sensors") 44 | coordinator: RobonectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 45 | "coordinator" 46 | ] 47 | entities: list[RobonectRestBinarySensor] = [] 48 | entities.append( 49 | RobonectWinterBinarySensor( 50 | hass, 51 | entry, 52 | ) 53 | ) 54 | if coordinator.data is not None: 55 | for description in BINARY_SENSORS: 56 | if not description.rest: 57 | path = description.key 58 | else: 59 | if description.rest == "$.none": 60 | continue 61 | if description.category not in entry.data[CONF_MONITORED_VARIABLES]: 62 | continue 63 | path = description.rest 64 | if description.category not in coordinator.data: 65 | continue 66 | _LOGGER.debug(f"[sensor|async_setup_entry|adding] {path}") 67 | if description.array: 68 | array = get_json_dict_path( 69 | coordinator.data, description.rest_attrs.replace(".0", "") 70 | ) 71 | if array is None: 72 | continue 73 | for idx, item in enumerate(array): 74 | _LOGGER.debug(f"Item in array: {item}") 75 | desc = copy.copy(description) 76 | desc.rest_attrs = description.rest_attrs.replace( 77 | ".0", f".{idx}" 78 | ) 79 | desc.key = description.key.replace(".0", f".{idx}") 80 | entities.append( 81 | RobonectRestBinarySensor( 82 | hass, 83 | entry, 84 | coordinator=coordinator, 85 | description=desc, 86 | ) 87 | ) 88 | else: 89 | entities.append( 90 | RobonectRestBinarySensor( 91 | hass, 92 | entry, 93 | coordinator=coordinator, 94 | description=description, 95 | ) 96 | ) 97 | async_add_entities(entities) 98 | 99 | 100 | class RobonectBinarySensor(RobonectEntity, BinarySensorEntity): 101 | """Representation of a Robonect binary sensor.""" 102 | 103 | entity_description: RobonectSensorEntityDescription 104 | _attr_has_entity_name = True 105 | 106 | def __init__( 107 | self, 108 | hass: HomeAssistant, 109 | entry: ConfigEntry, 110 | description: RobonectSensorEntityDescription, 111 | ) -> None: 112 | """Initialize the sensor.""" 113 | super().__init__(hass, entry, description) 114 | self.entity_id = f"binary_sensor.{self.slug}" 115 | self._state = None 116 | self._attributes = {} 117 | 118 | 119 | class RobonectWinterBinarySensor(RobonectBinarySensor): 120 | """Representation of a Robonect binary winter sensor.""" 121 | 122 | def __init__( 123 | self, 124 | hass: HomeAssistant, 125 | entry: ConfigEntry, 126 | ) -> None: 127 | """Initialize the sensor.""" 128 | entity_description = RobonectSensorEntityDescription( 129 | key=".winter/mode", 130 | rest="$.none", 131 | icon="mdi:snowflake", 132 | entity_category=EntityCategory.DIAGNOSTIC, 133 | category="NONE", 134 | ) 135 | 136 | super().__init__(hass, entry, entity_description) 137 | 138 | @property 139 | def is_on(self) -> bool | None: 140 | """Return true if the binary sensor is on.""" 141 | return self.entry.data[CONF_WINTER_MODE] 142 | 143 | 144 | class RobonectRestBinarySensor(RobonectCoordinatorEntity, RobonectBinarySensor): 145 | """Representation of a Robonect binary sensor that is updated via REST API.""" 146 | 147 | _attr_attribution = ATTRIBUTION_REST 148 | 149 | def __init__( 150 | self, 151 | hass: HomeAssistant, 152 | entry: ConfigEntry, 153 | coordinator: RobonectDataUpdateCoordinator, 154 | description: RobonectSensorEntityDescription, 155 | ) -> None: 156 | """Initialize the sensor.""" 157 | RobonectBinarySensor.__init__(self, hass, entry, description) 158 | super().__init__(coordinator, description) 159 | self.category = self.entity_description.category 160 | self.entity_description = description 161 | 162 | def handle_last_state(self, last_state: State | None) -> None: 163 | """Handle the last state.""" 164 | if last_state is not None and last_state.state is not None: 165 | if last_state.state == STATE_ON: 166 | self._attr_is_on = True 167 | elif last_state.state == STATE_OFF: 168 | self._attr_is_on = False 169 | elif last_state.state == STATE_UNAVAILABLE: 170 | self._attr_available = False 171 | 172 | @callback 173 | def _handle_coordinator_update(self) -> None: 174 | """Handle updated data from the coordinator.""" 175 | self.set_extra_attributes() 176 | self.set_is_on() 177 | super()._handle_coordinator_update() 178 | 179 | def set_is_on(self): 180 | """Set the status of the from the coordinatorsensor.""" 181 | if len(self.coordinator.data) and self.category in self.coordinator.data: 182 | state = get_json_dict_path( 183 | self.coordinator.data, self.entity_description.rest 184 | ) 185 | if self.entity_description.rest == "$.health.health.alarm": 186 | if state is not None: 187 | for alarm in state: 188 | if state[alarm]: 189 | self._attr_is_on = True 190 | return 191 | self._attr_is_on = False 192 | return 193 | if self.entity_description.rest == "$.status.status.status": 194 | if state is not None and state == 7: 195 | self._attr_is_on = True 196 | return 197 | self._attr_is_on = False 198 | return 199 | if state is True: 200 | self._attr_is_on = True 201 | else: 202 | self._attr_is_on = False 203 | 204 | @property 205 | def is_on(self) -> bool | None: 206 | """Return true if the binary sensor is on.""" 207 | self.set_is_on() 208 | return self._attr_is_on 209 | 210 | def set_extra_attributes(self): 211 | """Set the attributes for the sensor from coordinator.""" 212 | if len(self.coordinator.data) and self.category in self.coordinator.data: 213 | attributes = { 214 | "last_synced": self.last_synced, 215 | "category": self.category, 216 | } 217 | if self.entity_description.rest_attrs: 218 | attrs = get_json_dict_path( 219 | self.coordinator.data, self.entity_description.rest_attrs 220 | ) 221 | if attrs: 222 | adapt_attributes( 223 | attrs, self.category, self.entry.data[CONF_ATTRS_UNITS] 224 | ) 225 | if not isinstance(attrs, list): 226 | attributes.update(attrs) 227 | self._attr_extra_state_attributes = attributes 228 | 229 | @property 230 | def extra_state_attributes(self): 231 | """Return attributes for sensor.""" 232 | self.set_extra_attributes() 233 | return self._attr_extra_state_attributes 234 | -------------------------------------------------------------------------------- /custom_components/robonect/button.py: -------------------------------------------------------------------------------- 1 | """Button platform for Robonect.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | 8 | from homeassistant.components import mqtt 9 | from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers import entity_registry as er 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.util import slugify 15 | 16 | from . import RobonectDataUpdateCoordinator 17 | from .const import ( 18 | ATTRIBUTION_MQTT, 19 | ATTRIBUTION_REST, 20 | CONF_MQTT_ENABLED, 21 | CONF_MQTT_TOPIC, 22 | CONF_REST_ENABLED, 23 | DOMAIN, 24 | ) 25 | from .entity import RobonectEntity 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | @dataclass 31 | class RobonectButtonEntityDescription(ButtonEntityDescription): 32 | """Sensor entity description for Robonect.""" 33 | 34 | category: str | None = None 35 | cmd: str | None = None 36 | params: dict | None = None 37 | topic: str | None = None 38 | key: str | None = None 39 | icon: str | None = None 40 | 41 | 42 | BUTTON_TYPES = ( 43 | RobonectButtonEntityDescription( 44 | key="error_reset", 45 | icon="mdi:backup-restore", 46 | cmd="error", 47 | params={"reset": 1}, 48 | category="NONE", 49 | ), 50 | RobonectButtonEntityDescription( 51 | key="blades_reset", 52 | icon="mdi:backup-restore", 53 | cmd="reset_blades", 54 | category="NONE", 55 | ), 56 | RobonectButtonEntityDescription( 57 | key="home", 58 | icon="mdi:home-import-outline", 59 | topic="control/mode", 60 | cmd="mode", 61 | params={"mode": "home"}, 62 | category="NONE", 63 | ), 64 | RobonectButtonEntityDescription( 65 | key="eod", 66 | icon="mdi:weather-sunset-down", 67 | topic="control/mode", 68 | cmd="mode", 69 | params={"mode": "eod"}, 70 | category="NONE", 71 | ), 72 | RobonectButtonEntityDescription( 73 | key="stop", 74 | icon="mdi:stop", 75 | topic="control", 76 | cmd="stop", 77 | category="NONE", 78 | ), 79 | RobonectButtonEntityDescription( 80 | key="reboot", 81 | icon="mdi:restart", 82 | cmd="service", 83 | params={"reboot": 1}, 84 | category="NONE", 85 | ), 86 | RobonectButtonEntityDescription( 87 | key="shutdown", 88 | icon="mdi:power", 89 | cmd="service", 90 | params={"shutdown": 1}, 91 | category="NONE", 92 | ), 93 | RobonectButtonEntityDescription( 94 | key="sleep", 95 | icon="mdi:sleep", 96 | cmd="service", 97 | params={"sleep": 1}, 98 | category="NONE", 99 | ), 100 | RobonectButtonEntityDescription( 101 | key="start", 102 | icon="mdi:play", 103 | topic="control", 104 | cmd="start", 105 | category="NONE", 106 | ), 107 | RobonectButtonEntityDescription( 108 | key="auto", 109 | icon="mdi:refresh-auto", 110 | topic="control/mode", 111 | cmd="mode", 112 | params={"mode": "auto"}, 113 | category="NONE", 114 | ), 115 | RobonectButtonEntityDescription( 116 | key="man", 117 | icon="mdi:hand-clap", 118 | topic="control/mode", 119 | cmd="mode", 120 | params={"mode": "man"}, 121 | category="NONE", 122 | ), 123 | ) 124 | 125 | 126 | async def async_setup_entry( 127 | hass: HomeAssistant, 128 | entry: ConfigEntry, 129 | async_add_entities: AddEntitiesCallback, 130 | ) -> None: 131 | """Set up Robonect sensors from config entry.""" 132 | 133 | # Make sure MQTT integration is enabled and the client is available 134 | if entry.data[CONF_MQTT_ENABLED] is True: 135 | if not await mqtt.async_wait_for_mqtt_client(hass): 136 | _LOGGER.error("MQTT integration is not available") 137 | return 138 | 139 | entity_reg: er.EntityRegistry = er.async_get(hass) 140 | 141 | entities = [] 142 | if entry.data[CONF_REST_ENABLED] is True: 143 | coordinator: RobonectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 144 | "coordinator" 145 | ] 146 | for description in BUTTON_TYPES: 147 | added = False 148 | if entry.data[CONF_MQTT_ENABLED] is True and description.topic is not None: 149 | entities.append(RobonectMqttButton(hass, entry, description)) 150 | added = True 151 | if entry.data[CONF_REST_ENABLED] is True: 152 | coordinator: RobonectDataUpdateCoordinator = hass.data[DOMAIN][ 153 | entry.entry_id 154 | ]["coordinator"] 155 | if ( 156 | entry.data[CONF_MQTT_ENABLED] is True and description.topic is None 157 | ) or entry.data[CONF_MQTT_ENABLED] is False: 158 | added = True 159 | entities.append( 160 | RobonectRestButton(hass, entry, coordinator, description) 161 | ) 162 | if not added: 163 | topic = f"{entry.data[CONF_MQTT_TOPIC]}/{description.key}" 164 | slug = slugify(topic.replace("/", "_")) 165 | if entity_reg.async_get_entity_id( 166 | "button", DOMAIN, f"{entry.entry_id}-{description.category}-{slug}" 167 | ): 168 | entity_reg.async_remove(f"button.{slug}") 169 | async_add_entities(entities) 170 | 171 | 172 | class RobonectButton(RobonectEntity, ButtonEntity): 173 | """Representation of a Robonect button.""" 174 | 175 | entity_description: ButtonEntityDescription 176 | _attr_has_entity_name = True 177 | 178 | def __init__( 179 | self, 180 | hass: HomeAssistant, 181 | entry: ConfigEntry, 182 | description: ButtonEntityDescription, 183 | ) -> None: 184 | """Initialize the sensor.""" 185 | self.entity_description = description 186 | super().__init__(hass, entry, self.entity_description) 187 | self.entity_id = f"button.{self.slug}" 188 | 189 | 190 | class RobonectMqttButton(RobonectButton): 191 | """Representation of a Robonect MQTT button.""" 192 | 193 | _attr_attribution = ATTRIBUTION_MQTT 194 | 195 | def __init__( 196 | self, 197 | hass: HomeAssistant, 198 | entry: ConfigEntry, 199 | description: ButtonEntityDescription, 200 | ) -> None: 201 | """Initialize the sensor.""" 202 | self.coordinator = None 203 | super().__init__(hass, entry, description) 204 | 205 | async def async_press(self) -> None: 206 | """Press the button.""" 207 | _LOGGER.debug(f"MQTT button pressed {self.entity_id}") 208 | if not self.entity_description.cmd: 209 | _LOGGER.error(f"No command defined for button {self.entity_id}") 210 | await self.async_send_command( 211 | self.entity_description.key, {}, topic=self.entity_description.topic 212 | ) 213 | 214 | 215 | class RobonectRestButton(RobonectButton): 216 | """Representation of a Robonect REST button.""" 217 | 218 | _attr_attribution = ATTRIBUTION_REST 219 | 220 | def __init__( 221 | self, 222 | hass: HomeAssistant, 223 | entry: ConfigEntry, 224 | coordinator: RobonectDataUpdateCoordinator, 225 | description: ButtonEntityDescription, 226 | ) -> None: 227 | """Initialize the sensor.""" 228 | self.coordinator = coordinator 229 | super().__init__(hass, entry, description) 230 | 231 | async def async_press(self) -> None: 232 | """Press the button.""" 233 | _LOGGER.debug( 234 | f"REST BUTTON PRESSED: {self.entity_description.cmd} {self.entity_id} {self.entity_description.params}", 235 | True, 236 | ) 237 | await self.async_send_command( 238 | self.entity_description.cmd, self.entity_description.params 239 | ) 240 | await self.coordinator.async_refresh() 241 | return 242 | -------------------------------------------------------------------------------- /custom_components/robonect/client/__init__.py: -------------------------------------------------------------------------------- 1 | """Robonect library using aiohttp.""" 2 | 3 | # from .client import RobonectClient 4 | -------------------------------------------------------------------------------- /custom_components/robonect/client/__init__.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/custom_components/robonect/client/__init__.pyc -------------------------------------------------------------------------------- /custom_components/robonect/client/client.py: -------------------------------------------------------------------------------- 1 | """Robonect library using httpx.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | import json 7 | import logging 8 | import urllib.parse 9 | 10 | from homeassistant.helpers.httpx_client import create_async_httpx_client 11 | import httpx 12 | 13 | from .const import SAFE_COMMANDS 14 | from .utils import transform_json_to_single_depth 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | def encode_dict_values_to_utf8(dictionary): 20 | """Encode dict values to utf8.""" 21 | encoded_dict = {} 22 | for key, value in dictionary.items(): 23 | if isinstance(value, dict): 24 | encoded_dict[key] = encode_dict_values_to_utf8(value) 25 | elif isinstance(value, str): 26 | encoded_dict[key] = value.encode("utf-8") 27 | else: 28 | encoded_dict[key] = value 29 | return encoded_dict 30 | 31 | 32 | def validate_json(json_str): 33 | """Validate json string.""" 34 | if isinstance(json_str, dict): 35 | return True 36 | try: 37 | return json.loads(json_str) 38 | except ValueError as error: 39 | print(error) 40 | return False 41 | 42 | 43 | class RobonectException(Exception): 44 | """Raised when an update has failed.""" 45 | 46 | def __init__(self, cmd, exception, result): 47 | """Init the Robonect Exception.""" 48 | self.message = f"Aiorobonect call for cmd {cmd} failed: {result}\n{exception}" 49 | super().__init__(self.message) 50 | 51 | 52 | class RobonectClient: 53 | """Class to communicate with the Robonect API.""" 54 | 55 | def __init__(self, hass, host, username, password, transform_json=False) -> None: 56 | """Initialize the Communication API to get data.""" 57 | self.auth = None 58 | self.hass = hass 59 | self.host = host 60 | self.scheme = None 61 | self.username = username 62 | self.password = password 63 | self.client = None 64 | self.is_sleeping = None 65 | self.transform_json = transform_json 66 | if username is not None and password is not None: 67 | self.auth = (username, password) 68 | 69 | async def client_start(self): 70 | """Start a new, isolated httpx client.""" 71 | if not self.client: 72 | self.client = create_async_httpx_client( 73 | self.hass, timeout=httpx.Timeout(20.0, read=10.0) 74 | ) 75 | if self.auth: 76 | self.client.auth = self.auth 77 | 78 | async def client_close(self): 79 | """Properly close and cleanup the httpx client.""" 80 | if self.client: 81 | # await self.client.aclose() commented as this closes the HA httpx client 82 | # Don't close the client as it's a shared Home Assistant HTTPX client 83 | # that's managed by Home Assistant itself 84 | self.client = None 85 | 86 | async def async_cmd(self, command=None, params={}) -> list[dict]: 87 | """Send command to mower.""" 88 | ext = None 89 | if command is None: 90 | return False 91 | if params is None: 92 | params = "" 93 | else: 94 | if command == "equipment": 95 | ext = params.pop("ext") 96 | params = urllib.parse.urlencode(params) 97 | 98 | if command == "job": 99 | _LOGGER.debug(f"Job params: {params}") 100 | return 101 | 102 | result_json = None 103 | 104 | def create_url(scheme): 105 | if command == "reset_blades": 106 | return f"{scheme}://{self.host}/?btexe=" 107 | if command == "equipment": 108 | return f"{scheme}://{self.host}/{ext}?{params}" 109 | return f"{scheme}://{self.host}/json?cmd={command}&{params}" 110 | 111 | if self.scheme is None: 112 | self.scheme = ["http", "https"] 113 | 114 | if command == "direct": 115 | status = await self.async_stop() 116 | if status.get("successful") is False and status.get("error_code") != 7: 117 | _LOGGER.warning( 118 | f"Mower not stopped before `direct` command: {status.get('error_code')}" 119 | ) 120 | return 121 | 122 | response = None 123 | last_exception = None 124 | 125 | await self.client_start() 126 | 127 | for scheme in self.scheme: 128 | url = create_url(scheme) 129 | _LOGGER.debug(f"Calling {url}") 130 | try: 131 | response = await self.client.get(url) 132 | if response.status_code == 200 or response.status_code >= 400: 133 | self.scheme = [scheme] # Set scheme for future calls 134 | break # Exit the loop on successful or error response 135 | elif 300 <= response.status_code < 400: 136 | _LOGGER.debug( 137 | f"Received redirect status code {response.status_code}, continuing to next scheme" 138 | ) 139 | continue # Continue loop on redirect (3xx) 140 | except httpx.ReadTimeout as e: 141 | _LOGGER.debug( 142 | f"Read timeout while connecting to {scheme}://{self.host}. Error: {str(e)}" 143 | ) 144 | last_exception = e 145 | continue # Continue to the next scheme 146 | except httpx.RequestError as e: 147 | _LOGGER.debug( 148 | f"Failed to connect using {scheme}://{self.host}, error: {str(e)}" 149 | ) 150 | last_exception = e 151 | continue # Continue to the next scheme on connection error 152 | 153 | if response is None: 154 | raise Exception( 155 | f"Failed to get a response from the mower. `{str(last_exception)}`" 156 | ) 157 | 158 | if response and response.status_code == 200: 159 | if command == "reset_blades": 160 | await self.client_close() 161 | return {"successful": True} 162 | result_text = response.text 163 | if command == "equipment": 164 | await self.client_close() 165 | if "The changes were successfully applied" in result_text: 166 | return {"successful": True} 167 | else: 168 | return {"successful": False} 169 | _LOGGER.debug(f"Rest API call result for {command}: {result_text}") 170 | try: 171 | result_json = json.loads(result_text) 172 | except json.JSONDecodeError as e: 173 | _LOGGER.debug( 174 | f"The returned JSON for {command} is invalid ({e}): {result_text}" 175 | ) 176 | return False 177 | result_json["sync_time"] = datetime.now() 178 | elif response and response.status_code >= 400: 179 | response.raise_for_status() 180 | await self.client_close() 181 | 182 | if self.transform_json: 183 | return transform_json_to_single_depth(result_json) 184 | 185 | return result_json 186 | 187 | async def async_cmds(self, commands=None, bypass_sleeping=False) -> dict: 188 | """Send command to mower.""" 189 | await self.client_start() 190 | result = await self.state() 191 | if result: 192 | result = {"status": result} 193 | for cmd in commands: 194 | if not self.is_sleeping or bypass_sleeping or cmd in SAFE_COMMANDS: 195 | json_res = await self.async_cmd(cmd) 196 | if json_res: 197 | result.update({cmd: json_res}) 198 | await self.client_close() 199 | return result 200 | 201 | async def state(self) -> dict: 202 | """Send status command to mower.""" 203 | await self.client_start() 204 | result = await self.async_cmd("status") 205 | if result: 206 | self.is_sleeping = result.get("status").get("status") == 17 207 | await self.client_close() 208 | return result 209 | 210 | async def async_start(self) -> bool: 211 | """Start the mower.""" 212 | result = await self.async_cmd("start") 213 | return result 214 | 215 | async def async_stop(self) -> bool: 216 | """Stop the mower.""" 217 | result = await self.async_cmd("stop") 218 | return result 219 | 220 | async def async_reboot(self) -> bool: 221 | """Reboot Robonect.""" 222 | result = await self.async_cmd("service", {"service": "reboot"}) 223 | return result 224 | 225 | async def async_shutdown(self) -> bool: 226 | """Shutdown Robonect.""" 227 | result = await self.async_cmd("service", {"service": "shutdown"}) 228 | return result 229 | 230 | async def async_sleep(self) -> bool: 231 | """Make Robonect sleep.""" 232 | result = await self.async_cmd("service", {"service": "sleep"}) 233 | return result 234 | 235 | def is_sleeping(self) -> bool: 236 | """Return if the mower is sleeping.""" 237 | return self.is_sleeping 238 | 239 | async def async_reset_blades(self) -> bool: 240 | """Reset the mower blades.""" 241 | result = await self.async_cmd("reset_blades") 242 | return {"successful": result} 243 | -------------------------------------------------------------------------------- /custom_components/robonect/client/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by the Robonect client.""" 2 | 3 | COMMANDS = [ 4 | "battery", # wakes up robonect 5 | "clock", # ok when sleeping 6 | "door", # ok when sleeping 7 | "error", # wakes up robonect 8 | "ext", # ok when sleeping 9 | "gps", # ok when sleeping 10 | "health", # ok when sleeping 11 | "hour", # wakes up robonect 12 | "motor", # wakes up robonect 13 | "portal", # ok when sleeping 14 | "push", # ok when sleeping 15 | "remote", # wakes up robonect 16 | "report", # wakes up robonect 17 | "status", # ok when sleeping 18 | "timer", # ok when sleeping 19 | "version", # wakes up robonect 20 | "weather", # ok when sleeping 21 | "wlan", # ok when sleeping 22 | "wire", # wakes up robonect 23 | ] 24 | 25 | SAFE_COMMANDS = [ 26 | "clock", # ok when sleeping 27 | "door", # ok when sleeping 28 | "ext", # ok when sleeping 29 | "health", # ok when sleeping 30 | "status", # ok when sleeping 31 | "timer", # ok when sleeping 32 | "weather", # ok when sleeping 33 | "wlan", # ok when sleeping 34 | ] 35 | -------------------------------------------------------------------------------- /custom_components/robonect/client/utils.py: -------------------------------------------------------------------------------- 1 | """Robonect library utils.""" 2 | 3 | 4 | def transform_json_to_single_depth(json_data, prefix=""): 5 | """Transform a json so only 1 depth level.""" 6 | result = [] 7 | for key, value in json_data.items(): 8 | if isinstance(value, dict): 9 | result.extend(transform_json_to_single_depth(value, f"{key}_")) 10 | else: 11 | result.append({f"{prefix}{key}": value}) 12 | return result 13 | -------------------------------------------------------------------------------- /custom_components/robonect/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure Robonect.""" 2 | 3 | from __future__ import annotations 4 | 5 | from abc import ABC 6 | from collections.abc import Awaitable 7 | import logging 8 | from typing import Any 9 | 10 | from homeassistant.components import mqtt 11 | from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow 12 | from homeassistant.const import ( 13 | CONF_HOST, 14 | CONF_MONITORED_VARIABLES, 15 | CONF_PASSWORD, 16 | CONF_SCAN_INTERVAL, 17 | CONF_TYPE, 18 | CONF_USERNAME, 19 | ) 20 | from homeassistant.core import HomeAssistant, callback 21 | from homeassistant.data_entry_flow import FlowHandler, FlowResult 22 | from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler 23 | import homeassistant.helpers.config_validation as cv 24 | from homeassistant.helpers.selector import ( 25 | NumberSelector, 26 | NumberSelectorConfig, 27 | NumberSelectorMode, 28 | SelectSelector, 29 | SelectSelectorConfig, 30 | SelectSelectorMode, 31 | TextSelector, 32 | TextSelectorConfig, 33 | TextSelectorType, 34 | ) 35 | from homeassistant.helpers.typing import UNDEFINED 36 | import httpx 37 | import voluptuous as vol 38 | 39 | from .client.client import RobonectClient 40 | from .const import ( 41 | CONF_ATTRS_UNITS, 42 | CONF_BRAND, 43 | CONF_MQTT_ENABLED, 44 | CONF_MQTT_TOPIC, 45 | CONF_REST_ENABLED, 46 | CONF_SUGGESTED_BRAND, 47 | CONF_SUGGESTED_HOST, 48 | CONF_SUGGESTED_TYPE, 49 | CONF_WINTER_MODE, 50 | DEFAULT_MQTT_TOPIC, 51 | DEFAULT_SCAN_INTERVAL, 52 | DOMAIN, 53 | NAME, 54 | ROBONECT_BRANDS, 55 | SENSOR_GROUPS, 56 | ) 57 | from .exceptions import BadCredentialsException, RobonectServiceException 58 | from .models import RobonectConfigEntryData 59 | 60 | _LOGGER = logging.getLogger(__name__) 61 | 62 | DEFAULT_ENTRY_DATA = RobonectConfigEntryData( 63 | mqtt_enabled=True, 64 | mqtt_topic=DEFAULT_MQTT_TOPIC, 65 | host=CONF_SUGGESTED_HOST, 66 | type=CONF_SUGGESTED_TYPE, 67 | brand=CONF_SUGGESTED_BRAND, 68 | rest_enabled=True, 69 | username=None, 70 | password=None, 71 | monitoried_variables=SENSOR_GROUPS, 72 | scan_interval=DEFAULT_SCAN_INTERVAL, 73 | attributes_units=True, 74 | winter_mode=False, 75 | ) 76 | 77 | 78 | async def _async_has_devices(_: HomeAssistant) -> bool: 79 | """MQTT is set as dependency, so that should be sufficient.""" 80 | return True 81 | 82 | 83 | class RobonectFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DOMAIN): 84 | """Handle Robonect MQTT DiscoveryFlow. The MQTT step is inherited from the parent class.""" 85 | 86 | def __init__(self) -> None: 87 | """Set up the config flow.""" 88 | super().__init__(DOMAIN, "Robonect", _async_has_devices) 89 | 90 | async def async_step_confirm( 91 | self, user_input: dict[str, Any] | None = None 92 | ) -> FlowResult: 93 | """Confirm setup.""" 94 | if user_input is None: 95 | return self.async_show_form( 96 | step_id="confirm", 97 | ) 98 | 99 | return await super().async_step_confirm(user_input) 100 | 101 | 102 | class RobonectCommonFlow(ABC, FlowHandler): 103 | """Base class for Robonect flows.""" 104 | 105 | def __init__(self, initial_data: RobonectConfigEntryData) -> None: 106 | """Initialize RobonectCommonFlow.""" 107 | self.initial_data = initial_data 108 | self.new_entry_data = RobonectConfigEntryData() 109 | self.new_title: str | None = None 110 | self._config_id = None 111 | 112 | def new_data(self): 113 | """Construct new data.""" 114 | return DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data 115 | 116 | async def async_validate_input(self, user_input: dict[str, Any]) -> None: 117 | """Validate user credentials.""" 118 | 119 | client = RobonectClient( 120 | hass=self.hass, 121 | host=user_input[CONF_HOST], 122 | username=user_input[CONF_USERNAME], 123 | password=user_input[CONF_PASSWORD], 124 | ) 125 | state = await client.state() 126 | return state 127 | 128 | async def async_step_connection_methods( 129 | self, user_input: dict | None = None 130 | ) -> FlowResult: 131 | """Handle connection configuration.""" 132 | errors: dict = {} 133 | 134 | if user_input is None: 135 | self.new_entry_data = self.new_data() 136 | 137 | if not await mqtt.async_wait_for_mqtt_client(self.hass): 138 | self.new_entry_data |= RobonectConfigEntryData( 139 | mqtt_enabled=False, 140 | ) 141 | 142 | if user_input is not None: 143 | user_input = self.new_data() | user_input 144 | self.new_entry_data |= user_input 145 | if user_input[ 146 | CONF_MQTT_ENABLED 147 | ] and not await mqtt.async_wait_for_mqtt_client(self.hass): 148 | errors["base"] = "mqtt_disabled" 149 | self.new_entry_data |= RobonectConfigEntryData( 150 | mqtt_enabled=False, 151 | ) 152 | else: 153 | self._config_id = f"{DOMAIN}_" + user_input[CONF_MQTT_TOPIC] 154 | if ( 155 | self.hass.config_entries.async_get_entry(self._config_id) 156 | is not None 157 | ): 158 | errors["CONF_MQTT_TOPIC"] = "topic_used" 159 | else: 160 | return await self.async_step_connection_rest() 161 | 162 | fields = { 163 | vol.Required(CONF_MQTT_ENABLED): bool, 164 | vol.Required(CONF_MQTT_TOPIC): str, 165 | vol.Required(CONF_REST_ENABLED): bool, 166 | vol.Required(CONF_BRAND): SelectSelector( 167 | SelectSelectorConfig( 168 | options=ROBONECT_BRANDS, 169 | multiple=False, 170 | custom_value=False, 171 | mode=SelectSelectorMode.DROPDOWN, 172 | ), 173 | ), 174 | vol.Required(CONF_TYPE): TextSelector( 175 | TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete=CONF_TYPE) 176 | ), 177 | } 178 | return self.async_show_form( 179 | step_id="connection_methods", 180 | data_schema=self.add_suggested_values_to_schema( 181 | vol.Schema(fields), self.new_entry_data 182 | ), 183 | description_placeholders={ 184 | "name": NAME, 185 | }, 186 | errors=errors, 187 | ) 188 | 189 | async def async_step_connection_rest( 190 | self, user_input: dict | None = None 191 | ) -> FlowResult: 192 | """Handle connection configuration.""" 193 | errors: dict = {} 194 | placeholders = { 195 | "name": NAME, 196 | } 197 | if user_input is not None: 198 | user_input = self.new_data() | user_input 199 | self.new_entry_data |= user_input 200 | if user_input[CONF_REST_ENABLED]: 201 | test = await self.test_connection(user_input) 202 | if not test["errors"]: 203 | self.new_title = test["status"].get("name") 204 | await self.async_set_unique_id(self._config_id) 205 | self._abort_if_unique_id_configured() 206 | _LOGGER.debug(f"New account {self.new_title} added") 207 | return self.finish_flow() 208 | if test["exception"]: 209 | placeholders |= {"exception": test["exception"]} 210 | else: 211 | await self.async_set_unique_id(self._config_id) 212 | self._abort_if_unique_id_configured() 213 | _LOGGER.debug(f"New account {self.new_title} added") 214 | return self.finish_flow() 215 | errors = test["errors"] 216 | else: 217 | self.new_entry_data = self.new_data() 218 | 219 | fields = { 220 | vol.Required(CONF_HOST): TextSelector( 221 | TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="host") 222 | ), 223 | vol.Required(CONF_USERNAME): TextSelector( 224 | TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="username") 225 | ), 226 | vol.Required(CONF_PASSWORD): TextSelector( 227 | TextSelectorConfig( 228 | type=TextSelectorType.PASSWORD, autocomplete="password" 229 | ) 230 | ), 231 | vol.Required( 232 | CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL 233 | ): NumberSelector( 234 | NumberSelectorConfig(min=1, max=60, step=1, mode=NumberSelectorMode.BOX) 235 | ), 236 | vol.Required( 237 | CONF_MONITORED_VARIABLES, default=SENSOR_GROUPS 238 | ): SelectSelector( 239 | SelectSelectorConfig( 240 | options=SENSOR_GROUPS, 241 | multiple=True, 242 | custom_value=False, 243 | mode=SelectSelectorMode.DROPDOWN, 244 | translation_key=CONF_MONITORED_VARIABLES, 245 | ) 246 | ), 247 | vol.Required(CONF_ATTRS_UNITS): bool, 248 | } 249 | return self.async_show_form( 250 | step_id="connection_rest", 251 | data_schema=self.add_suggested_values_to_schema( 252 | vol.Schema(fields), self.new_entry_data 253 | ), 254 | description_placeholders=placeholders, 255 | errors=errors, 256 | ) 257 | 258 | async def test_connection(self, user_input: dict | None = None) -> dict: 259 | """Test the connection to Robonect.""" 260 | errors: dict = {} 261 | status: dict = {} 262 | exception: dict = {} 263 | 264 | if user_input is not None: 265 | user_input = self.new_data() | user_input 266 | try: 267 | status = await self.async_validate_input(user_input) 268 | if not status.get("successful") or status.get("successful") is not True: 269 | raise RobonectServiceException 270 | except AssertionError as exception: 271 | errors["base"] = "cannot_connect" 272 | _LOGGER.debug(f"[async_step_password|login] AssertionError {exception}") 273 | except httpx.ConnectError: 274 | errors["base"] = "cannot_connect" 275 | except RobonectServiceException: 276 | errors["base"] = "service_error" 277 | except BadCredentialsException: 278 | errors["base"] = "invalid_auth" 279 | except Exception as e: 280 | if isinstance(e, httpx.HTTPStatusError): 281 | errors["base"] = "invalid_auth" 282 | else: 283 | errors["base"] = "unknown" 284 | exception = str(e) 285 | return {"status": status, "errors": errors, "exception": exception} 286 | 287 | async def async_step_username_password( 288 | self, user_input: dict | None = None 289 | ) -> FlowResult: 290 | """Configure password.""" 291 | errors: dict = {} 292 | 293 | if user_input is not None: 294 | user_input = self.new_data() | user_input 295 | test = await self.test_connection(user_input) 296 | if not test["errors"]: 297 | self.new_entry_data |= RobonectConfigEntryData( 298 | password=user_input[CONF_PASSWORD], 299 | ) 300 | return self.finish_flow() 301 | errors = test["errors"] 302 | 303 | fields = { 304 | vol.Required(CONF_USERNAME): cv.string, 305 | vol.Required(CONF_PASSWORD): cv.string, 306 | } 307 | return self.async_show_form( 308 | step_id="username_password", 309 | data_schema=self.add_suggested_values_to_schema( 310 | vol.Schema(fields), 311 | self.initial_data 312 | | RobonectConfigEntryData( 313 | password=None, 314 | ), 315 | ), 316 | errors=errors, 317 | ) 318 | 319 | async def async_step_connection_options( 320 | self, user_input: dict | None = None 321 | ) -> FlowResult: 322 | """Configure password.""" 323 | errors: dict = {} 324 | 325 | if user_input is not None: 326 | user_input = self.new_data() | user_input 327 | self.new_entry_data |= user_input 328 | if user_input[ 329 | CONF_MQTT_ENABLED 330 | ] and not await mqtt.async_wait_for_mqtt_client(self.hass): 331 | errors["base"] = "mqtt_disabled" 332 | self.new_entry_data |= RobonectConfigEntryData( 333 | mqtt_enabled=False, 334 | ) 335 | else: 336 | return self.finish_flow() 337 | 338 | fields = { 339 | vol.Required(CONF_MQTT_ENABLED): bool, 340 | vol.Required(CONF_REST_ENABLED): bool, 341 | } 342 | 343 | return self.async_show_form( 344 | step_id="connection_options", 345 | data_schema=self.add_suggested_values_to_schema( 346 | vol.Schema(fields), 347 | self.initial_data, 348 | ), 349 | description_placeholders={ 350 | "name": NAME, 351 | }, 352 | errors=errors, 353 | ) 354 | 355 | async def async_step_winter_mode( 356 | self, user_input: dict | None = None 357 | ) -> FlowResult: 358 | """Configure winter mode.""" 359 | errors: dict = {} 360 | 361 | if user_input is not None: 362 | self.new_entry_data |= user_input 363 | return self.finish_flow() 364 | 365 | fields = { 366 | vol.Required(CONF_WINTER_MODE): bool, 367 | } 368 | 369 | return self.async_show_form( 370 | step_id="winter_mode", 371 | data_schema=self.add_suggested_values_to_schema( 372 | vol.Schema(fields), 373 | self.initial_data, 374 | ), 375 | description_placeholders={ 376 | "name": NAME, 377 | }, 378 | errors=errors, 379 | ) 380 | 381 | async def async_step_brand_type(self, user_input: dict | None = None) -> FlowResult: 382 | """Configure brand and type.""" 383 | errors: dict = {} 384 | 385 | if user_input is not None: 386 | self.new_entry_data |= user_input 387 | return self.finish_flow() 388 | 389 | fields = { 390 | vol.Required(CONF_BRAND): SelectSelector( 391 | SelectSelectorConfig( 392 | options=ROBONECT_BRANDS, 393 | multiple=False, 394 | custom_value=False, 395 | mode=SelectSelectorMode.DROPDOWN, 396 | ), 397 | ), 398 | vol.Required(CONF_TYPE): TextSelector( 399 | TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete=CONF_TYPE) 400 | ), 401 | } 402 | 403 | return self.async_show_form( 404 | step_id="brand_type", 405 | data_schema=self.add_suggested_values_to_schema( 406 | vol.Schema(fields), 407 | self.initial_data, 408 | ), 409 | description_placeholders={ 410 | "name": NAME, 411 | }, 412 | errors=errors, 413 | ) 414 | 415 | async def async_step_host(self, user_input: dict | None = None) -> FlowResult: 416 | """Configure host.""" 417 | errors: dict = {} 418 | 419 | if user_input is not None: 420 | user_input = self.new_data() | user_input 421 | test = await self.test_connection(user_input) 422 | if not test["errors"]: 423 | self.new_entry_data |= RobonectConfigEntryData( 424 | host=user_input[CONF_HOST], 425 | ) 426 | return self.finish_flow() 427 | errors = test["errors"] 428 | 429 | fields = { 430 | vol.Required(CONF_HOST): cv.string, 431 | } 432 | return self.async_show_form( 433 | step_id="host", 434 | data_schema=self.add_suggested_values_to_schema( 435 | vol.Schema(fields), 436 | self.initial_data, 437 | ), 438 | errors=errors, 439 | ) 440 | 441 | async def async_step_scan_interval( 442 | self, user_input: dict | None = None 443 | ) -> FlowResult: 444 | """Configure update interval.""" 445 | errors: dict = {} 446 | 447 | if user_input is not None: 448 | self.new_entry_data |= user_input 449 | return self.finish_flow() 450 | 451 | fields = { 452 | vol.Required(CONF_SCAN_INTERVAL): NumberSelector( 453 | NumberSelectorConfig(min=1, max=60, step=1, mode=NumberSelectorMode.BOX) 454 | ), 455 | } 456 | return self.async_show_form( 457 | step_id="scan_interval", 458 | data_schema=self.add_suggested_values_to_schema( 459 | vol.Schema(fields), 460 | self.initial_data, 461 | ), 462 | errors=errors, 463 | ) 464 | 465 | async def async_step_monitored_variables( 466 | self, user_input: dict | None = None 467 | ) -> FlowResult: 468 | """Configure monitored variables.""" 469 | errors: dict = {} 470 | 471 | if user_input is not None: 472 | self.new_entry_data |= user_input 473 | return self.finish_flow() 474 | 475 | fields = { 476 | vol.Required(CONF_MONITORED_VARIABLES): SelectSelector( 477 | SelectSelectorConfig( 478 | options=SENSOR_GROUPS, 479 | multiple=True, 480 | custom_value=False, 481 | mode=SelectSelectorMode.DROPDOWN, 482 | translation_key=CONF_MONITORED_VARIABLES, 483 | ) 484 | ), 485 | vol.Required(CONF_ATTRS_UNITS): bool, 486 | } 487 | return self.async_show_form( 488 | step_id="monitored_variables", 489 | data_schema=self.add_suggested_values_to_schema( 490 | vol.Schema(fields), 491 | self.initial_data, 492 | ), 493 | errors=errors, 494 | ) 495 | 496 | 497 | class RobonectOptionsFlow(RobonectCommonFlow, OptionsFlow): 498 | """Handle Robonect options.""" 499 | 500 | def __init__(self, config_entry: ConfigEntry) -> None: 501 | """Initialize Robonect options flow.""" 502 | super().__init__(initial_data=config_entry.data) 503 | 504 | @callback 505 | def finish_flow(self) -> FlowResult: 506 | """Update the ConfigEntry and finish the flow.""" 507 | new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data 508 | 509 | if self.config_entry: 510 | self.hass.config_entries.async_update_entry( 511 | self.config_entry, 512 | data=new_data, 513 | title=self.new_title or UNDEFINED, 514 | ) 515 | self.hass.async_create_task( 516 | self.hass.config_entries.async_reload(self._config_entry_id) 517 | ) 518 | return self.async_create_entry(title="", data={}) 519 | 520 | async def async_step_init( 521 | self, user_input: dict[str, Any] | None = None 522 | ) -> FlowResult: 523 | """Manage Robonect options.""" 524 | return self.async_show_menu( 525 | step_id="init", 526 | menu_options=[ 527 | "connection_options", 528 | "brand_type", 529 | "host", 530 | "username_password", 531 | "scan_interval", 532 | "monitored_variables", 533 | "winter_mode", 534 | ], 535 | ) 536 | 537 | 538 | class RobonectConfigFlow(RobonectCommonFlow, ConfigFlow, domain=DOMAIN): 539 | """Handle a config flow for Robonect.""" 540 | 541 | VERSION = 6 542 | 543 | def __init__(self) -> None: 544 | """Initialize Robonect Config Flow.""" 545 | super().__init__(initial_data=DEFAULT_ENTRY_DATA) 546 | 547 | @staticmethod 548 | @callback 549 | def async_get_options_flow(config_entry: ConfigEntry) -> RobonectOptionsFlow: 550 | """Get the options flow for this handler.""" 551 | return RobonectOptionsFlow(config_entry) 552 | 553 | @callback 554 | def finish_flow(self) -> FlowResult: 555 | """Create the ConfigEntry.""" 556 | title = self.new_title or NAME 557 | return self.async_create_entry( 558 | title=title, 559 | data=DEFAULT_ENTRY_DATA | self.new_entry_data, 560 | ) 561 | 562 | async def async_step_user(self, user_input: dict | None = None) -> FlowResult: 563 | """Handle a flow initialized by the user.""" 564 | return await self.async_step_connection_methods() 565 | 566 | # async def async_step_mqtt(self, user_input: dict | None = None) -> FlowResult: 567 | """Handle a flow initialized by the autodiscovery.""" 568 | -------------------------------------------------------------------------------- /custom_components/robonect/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by Robonect.""" 2 | 3 | import json 4 | from pathlib import Path 5 | from typing import Final 6 | 7 | from homeassistant.const import CONF_ENTITY_ID, Platform 8 | from homeassistant.helpers import config_validation as cv 9 | from homeassistant.helpers.selector import ( 10 | BooleanSelector, 11 | SelectSelector, 12 | SelectSelectorConfig, 13 | SelectSelectorMode, 14 | ) 15 | import voluptuous as vol 16 | 17 | 18 | # Custom validator for degrees (allowing positive and negative integers) 19 | def validate_degrees(value): 20 | """Validate degree value.""" 21 | return vol.Coerce(int)(value) 22 | 23 | 24 | PLATFORMS: Final = [ 25 | Platform.BINARY_SENSOR, 26 | Platform.SENSOR, 27 | Platform.BUTTON, 28 | Platform.DEVICE_TRACKER, 29 | Platform.SWITCH, 30 | Platform.LAWN_MOWER, 31 | ] 32 | 33 | ATTR_STATE_UNITS = { 34 | "battery": { 35 | "charge": "%", 36 | "voltage": {"lambda": "lambda a : math.ceil(a / 10)/100", "unit": "V"}, 37 | "current": "mA", 38 | "temperature": {"lambda": "lambda a : a / 10", "unit": "°C"}, 39 | "full": "mAh", 40 | "remaining": "mAh", 41 | }, 42 | "motor": { 43 | "power": "%", 44 | "speed": {"lambda": "lambda a : round(a , 1)", "unit": "cm/s"}, 45 | "current": "mA", 46 | "average": "RPM", 47 | }, 48 | "status": { 49 | "distance": "m", 50 | "duration": "min", 51 | "battery": "%", 52 | "hours": "h", 53 | "quality": "%", 54 | "days": "d", 55 | "signal": "dBm", 56 | "temperature": "°C", 57 | "humidity": "%", 58 | }, 59 | } 60 | 61 | EVENT_ROBONECT_RESPONSE = "robonect_response" 62 | 63 | ATTR_SATELLITES = "satellites" 64 | 65 | CONF_MQTT_ENABLED = "mqtt_enabled" 66 | CONF_MQTT_TOPIC = "mqtt_topic" 67 | CONF_REST_ENABLED = "rest_enabled" 68 | CONF_ATTRS_UNITS = "attributes_units" 69 | DEFAULT_MQTT_TOPIC = "automower" 70 | CONF_SUGGESTED_TYPE = "Automower 310" 71 | CONF_SUGGESTED_HOST = "10.0.0.99" 72 | CONF_SUGGESTED_BRAND = "Husqvarna" 73 | CONF_BRAND = "brand" 74 | CONF_ENABLE = "enable" 75 | CONF_WINTER_MODE = "winter_mode" 76 | 77 | ATTRIBUTION_REST: Final = "Data provided by Robonect REST" 78 | ATTRIBUTION_MQTT: Final = "Data provided by Robonect MQTT" 79 | 80 | SERVICE_START = "start" 81 | SERVICE_STOP = "stop" 82 | SERVICE_REBOOT = "reboot" 83 | SERVICE_SHUTDOWN = "shutdown" 84 | SERVICE_SLEEP = "sleep" 85 | SERVICE_TIMER = "timer" 86 | SERVICE_JOB = "job" 87 | SERVICE_DIRECT = "direct" 88 | SERVICE_EQUIPMENT = "ext" 89 | CONF_ENTRY_ID = "entry_id" 90 | 91 | ROBONECT_BRANDS = ["Husqvarna", "Gardena", "Flymo", "McCulloch"] 92 | SERVICE_JOB_AFTER_VALUES = ["Auto", "Home", "End of day"] 93 | SERVICE_JOB_REMOTESTART_VALUES = [ 94 | "Normal", 95 | "From charging station", 96 | "Remote start 1", 97 | "Remote start 2", 98 | "Remote start 3", 99 | "Remote start 4", 100 | "Remote start 5", 101 | ] 102 | SERVICE_JOB_CORRIDOR_VALUES = [ 103 | "Normal", 104 | "0", 105 | "1", 106 | "2", 107 | "3", 108 | "4", 109 | "5", 110 | "6", 111 | "7", 112 | "8", 113 | "9", 114 | ] 115 | SERVICE_MODE_VALUES = ["man", "auto", "eod", "home"] 116 | SERVICE_JOB_SCHEMA = vol.Schema( 117 | { 118 | vol.Required(CONF_ENTITY_ID): cv.string, 119 | vol.Optional("start"): cv.string, 120 | vol.Optional("end"): cv.string, 121 | vol.Optional("duration"): cv.positive_int, 122 | vol.Optional("after", default="Auto"): vol.In(SERVICE_JOB_AFTER_VALUES), 123 | vol.Optional("remotestart", default="Normal"): vol.In( 124 | SERVICE_JOB_REMOTESTART_VALUES 125 | ), 126 | vol.Optional("corridor", default="Normal"): vol.In(SERVICE_JOB_CORRIDOR_VALUES), 127 | } 128 | ) 129 | WEEKDAYS_SHORT = ["mo", "tu", "we", "th", "fr", "sa", "su"] 130 | SERVICE_TIMER_IDS = [str(id) for id in list(range(1, 15))] 131 | SERVICE_TIMER_SCHEMA = vol.Schema( 132 | { 133 | vol.Required(CONF_ENTITY_ID): cv.string, 134 | vol.Required("timer"): vol.In(SERVICE_TIMER_IDS), 135 | vol.Required(CONF_ENABLE): bool, 136 | vol.Required("start"): cv.string, 137 | vol.Required("end"): cv.string, 138 | vol.Required("weekdays"): SelectSelector( 139 | SelectSelectorConfig( 140 | options=WEEKDAYS_SHORT, 141 | multiple=True, 142 | custom_value=False, 143 | mode=SelectSelectorMode.LIST, 144 | translation_key="weekdays", 145 | ) 146 | ), 147 | } 148 | ) 149 | 150 | EQUIPMENT_SHORT = ["ext0", "ext1", "ext2", "ext3"] 151 | EQUIPMENT_CHANNEL = [ 152 | {"value": "0", "label": "[IN] Analog"}, 153 | {"value": "4", "label": "[IN] Floating"}, 154 | {"value": "40", "label": "[IN] PullDown"}, 155 | {"value": "72", "label": "[IN] PullUp"}, 156 | {"value": "20", "label": "[OUT] OpenDrain"}, 157 | {"value": "16", "label": "[OUT] PushPull"}, 158 | ] 159 | 160 | EQUIPMENT_MODE = [ 161 | {"value": "0", "label": "Off"}, 162 | {"value": "1", "label": "On"}, 163 | {"value": "2", "label": "Night (19-7 o'clock)"}, 164 | {"value": "3", "label": "Drive"}, 165 | {"value": "4", "label": "Night drive (19-7 o'clock)"}, 166 | {"value": "5", "label": "Searching/Way home"}, 167 | {"value": "6", "label": "Park position"}, 168 | {"value": "7", "label": "Brake light"}, 169 | {"value": "8", "label": "Left Turn Signal"}, 170 | {"value": "9", "label": "Right Turn Signal"}, 171 | {"value": "10", "label": "API"}, 172 | ] 173 | 174 | SERVICE_EQUIPMENT_SCHEMA = vol.Schema( 175 | { 176 | vol.Required(CONF_ENTITY_ID): cv.entity_id, # Validate entity ID 177 | vol.Required("ext"): SelectSelector( 178 | SelectSelectorConfig( 179 | options=EQUIPMENT_SHORT, 180 | multiple=False, 181 | custom_value=False, 182 | mode=SelectSelectorMode.LIST, 183 | translation_key="equipment", 184 | ) 185 | ), 186 | vol.Required("gpioout"): SelectSelector( 187 | SelectSelectorConfig( 188 | options=EQUIPMENT_CHANNEL, 189 | multiple=False, 190 | custom_value=False, 191 | mode=SelectSelectorMode.LIST, 192 | translation_key="equipment_channel", 193 | ) 194 | ), 195 | vol.Required("gpiomode"): SelectSelector( 196 | SelectSelectorConfig( 197 | options=EQUIPMENT_MODE, 198 | multiple=False, 199 | custom_value=False, 200 | mode=SelectSelectorMode.LIST, 201 | translation_key="equipment_mode", 202 | ) 203 | ), 204 | vol.Required("gpioerr"): BooleanSelector(), # Checkbox for gpioerr 205 | vol.Required("gpioinv"): BooleanSelector(), # Checkbox for gpioinv 206 | } 207 | ) 208 | 209 | SERVICE_DIRECT_SCHEMA = vol.Schema( 210 | { 211 | vol.Required(CONF_ENTITY_ID): cv.entity_id, # Validate entity ID 212 | vol.Required( 213 | "left" 214 | ): validate_degrees, # Validate left as an integer (positive or negative) 215 | vol.Required( 216 | "right" 217 | ): validate_degrees, # Validate right as an integer (positive or negative) 218 | vol.Required("timeout"): vol.All( 219 | cv.positive_int, vol.Range(max=5000) 220 | ), # Validate timeout as a positive integer with a max of 5000 221 | } 222 | ) 223 | 224 | SERVICE_MODE_SCHEMA = vol.Schema( 225 | { 226 | vol.Required("mode", default="auto"): vol.In(SERVICE_MODE_VALUES), 227 | } 228 | ) 229 | SENSOR_GROUPS = [ 230 | "battery", # wakes up robonect 231 | "clock", # ok when sleeping 232 | "door", # ok when sleeping 233 | "error", # wakes up robonect 234 | "ext", # ok when sleeping 235 | "gps", # ok when sleeping 236 | "health", # ok when sleeping 237 | "hour", # wakes up robonect 238 | "motor", # wakes up robonect 239 | "portal", # ok when sleeping 240 | "push", # ok when sleeping 241 | "remote", # wakes up robonect 242 | "report", # wakes up robonect 243 | "status", # ok when sleeping 244 | "timer", # ok when sleeping 245 | "version", # wakes up robonect 246 | "weather", # ok when sleeping 247 | "wlan", # ok when sleeping 248 | "wire", # wakes up robonect 249 | ] 250 | 251 | STATUS_MAPPING_LAWN_MOWER = { 252 | 0: "detecting_status", 253 | 1: "paused", # "stopped", 254 | 2: "mowing", 255 | 3: "returning", # "searching for charging station", 256 | 4: "charging", 257 | 5: "returning", # "searching", 258 | 7: "error", 259 | 8: "lost_cable_signal", 260 | 16: "off", 261 | 17: "docked", 262 | 18: "waiting_for_garage_door", 263 | 98: "offline_cannot_bind", 264 | 99: "unknown", 265 | } 266 | 267 | DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" 268 | DEFAULT_SCAN_INTERVAL = 2 269 | CONNECTION_RETRY = 5 270 | REQUEST_TIMEOUT = 20 271 | WEBSITE = "https://www.robonect-shop.de" 272 | 273 | manifestfile = Path(__file__).parent / "manifest.json" 274 | with open(manifestfile) as json_file: 275 | manifest_data = json.load(json_file) 276 | 277 | DOMAIN = manifest_data.get("domain") 278 | NAME = manifest_data.get("name") 279 | VERSION = manifest_data.get("version") 280 | ISSUEURL = manifest_data.get("issue_tracker") 281 | STARTUP = f""" 282 | ------------------------------------------------------------------- 283 | {NAME} 284 | Version: {VERSION} 285 | This is a custom component 286 | If you have any issues with this you need to open an issue here: 287 | {ISSUEURL} 288 | ------------------------------------------------------------------- 289 | """ 290 | TRACKER_UPDATE = f"{DOMAIN}_tracker_update" 291 | WEEKDAYS_HEX = { 292 | 0x1: "mo", 293 | 0x2: "tu", 294 | 0x4: "we", 295 | 0x8: "th", 296 | 0x10: "fr", 297 | 0x20: "sa", 298 | 0x40: "su", 299 | } 300 | -------------------------------------------------------------------------------- /custom_components/robonect/definitions.py: -------------------------------------------------------------------------------- 1 | """Definitions for Robonect sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from dataclasses import dataclass 7 | 8 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass 9 | from homeassistant.components.sensor import ( 10 | SensorDeviceClass, 11 | SensorEntityDescription, 12 | SensorStateClass, 13 | ) 14 | from homeassistant.components.switch import SwitchEntityDescription 15 | from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature 16 | from homeassistant.helpers.entity import EntityCategory 17 | 18 | from .utils import unix_to_datetime, wifi_signal_to_percentage 19 | 20 | 21 | @dataclass 22 | class RobonectSwitchEntityDescription(SwitchEntityDescription): 23 | """Switch entity description for Robonect.""" 24 | 25 | rest: str | None = None 26 | rest_attrs: dict | None = None 27 | category: str | None = None 28 | translation_key: str | None = None 29 | array: bool | True = None 30 | ext: str | None = None 31 | 32 | 33 | @dataclass 34 | class RobonectSensorEntityDescription(SensorEntityDescription): 35 | """Sensor entity description for Robonect.""" 36 | 37 | state: Callable | None = None 38 | rest: str | None = None 39 | rest_attrs: dict | None = None 40 | category: str | None = None 41 | translation_key: str | None = None 42 | array: bool | True = None 43 | 44 | 45 | BUTTONS: tuple[RobonectSensorEntityDescription, ...] = ( 46 | RobonectSensorEntityDescription( 47 | key="mower/battery/charge", 48 | device_class=SensorDeviceClass.BATTERY, 49 | state_class=SensorStateClass.MEASUREMENT, 50 | native_unit_of_measurement=PERCENTAGE, 51 | entity_category=EntityCategory.DIAGNOSTIC, 52 | icon="mdi:battery", 53 | ), 54 | ) 55 | 56 | BINARY_SENSORS: tuple[RobonectSensorEntityDescription, ...] = ( 57 | RobonectSensorEntityDescription( 58 | key=".health/alarm", 59 | rest="$.health.health.alarm", 60 | rest_attrs="$.health.health.alarm", 61 | icon="mdi:medication", 62 | device_class=BinarySensorDeviceClass.SAFETY, 63 | entity_category=EntityCategory.DIAGNOSTIC, 64 | category="health", 65 | ), 66 | RobonectSensorEntityDescription( 67 | key=".ext/gpio1", 68 | rest="$.ext.ext.gpio1.status", 69 | rest_attrs="$.ext.ext.gpio1", 70 | icon="mdi:gesture-pinch", 71 | entity_category=EntityCategory.DIAGNOSTIC, 72 | category="ext", 73 | ), 74 | RobonectSensorEntityDescription( 75 | key=".ext/gpio2", 76 | rest="$.ext.ext.gpio2.status", 77 | rest_attrs="$.ext.ext.gpio2", 78 | icon="mdi:gesture-pinch", 79 | entity_category=EntityCategory.DIAGNOSTIC, 80 | category="ext", 81 | ), 82 | RobonectSensorEntityDescription( 83 | key=".ext/out1", 84 | rest="$.ext.ext.out1.status", 85 | rest_attrs="$.ext.ext.out1", 86 | icon="mdi:gesture-pinch", 87 | entity_category=EntityCategory.DIAGNOSTIC, 88 | category="ext", 89 | ), 90 | RobonectSensorEntityDescription( 91 | key=".ext/out2", 92 | rest="$.ext.ext.out2.status", 93 | rest_attrs="$.ext.ext.out2", 94 | icon="mdi:gesture-pinch", 95 | entity_category=EntityCategory.DIAGNOSTIC, 96 | category="ext", 97 | ), 98 | RobonectSensorEntityDescription( 99 | key="weather/data/break", 100 | rest="$.weather.weather.break", 101 | icon="mdi:stop", 102 | entity_category=EntityCategory.DIAGNOSTIC, 103 | category="weather", 104 | ), 105 | RobonectSensorEntityDescription( 106 | key=".weather/service", 107 | rest="$.weather.service.enable", 108 | rest_attrs="$.weather", 109 | icon="mdi:weather-cloudy", 110 | entity_category=EntityCategory.DIAGNOSTIC, 111 | category="weather", 112 | ), 113 | RobonectSensorEntityDescription( 114 | key="mower/stopped", 115 | rest="$.status.status.stopped", 116 | icon="mdi:stop", 117 | entity_category=EntityCategory.DIAGNOSTIC, 118 | category="status", 119 | ), 120 | RobonectSensorEntityDescription( 121 | key="mower/error", 122 | rest="$.status.status.status", 123 | rest_attrs="$.error.errors.0", 124 | icon="mdi:alert-octagon", 125 | category="status", 126 | ), 127 | ) 128 | 129 | SWITCHES: tuple[RobonectSwitchEntityDescription, ...] = ( 130 | RobonectSwitchEntityDescription( 131 | key=".timer/0", 132 | array=True, 133 | rest="$.timer.timer.0.enabled", 134 | rest_attrs="$.timer.timer.0", 135 | icon="mdi:calendar-clock", 136 | entity_category=EntityCategory.DIAGNOSTIC, 137 | category="timer", 138 | ), 139 | RobonectSwitchEntityDescription( 140 | key=".ext/gpio1", 141 | rest="$.ext.ext.gpio1.status", 142 | rest_attrs="$.ext.ext.gpio1", 143 | icon="mdi:gesture-pinch", 144 | ext="ext0", 145 | entity_category=EntityCategory.DIAGNOSTIC, 146 | category="ext", 147 | ), 148 | RobonectSwitchEntityDescription( 149 | key=".ext/gpio2", 150 | rest="$.ext.ext.gpio2.status", 151 | rest_attrs="$.ext.ext.gpio2", 152 | icon="mdi:gesture-pinch", 153 | ext="ext1", 154 | entity_category=EntityCategory.DIAGNOSTIC, 155 | category="ext", 156 | ), 157 | RobonectSwitchEntityDescription( 158 | key=".ext/out1", 159 | rest="$.ext.ext.out1.status", 160 | rest_attrs="$.ext.ext.out1", 161 | icon="mdi:gesture-pinch", 162 | ext="ext2", 163 | entity_category=EntityCategory.DIAGNOSTIC, 164 | category="ext", 165 | ), 166 | RobonectSwitchEntityDescription( 167 | key=".ext/out2", 168 | rest="$.ext.ext.out2.status", 169 | rest_attrs="$.ext.ext.out2", 170 | icon="mdi:gesture-pinch", 171 | ext="ext3", 172 | entity_category=EntityCategory.DIAGNOSTIC, 173 | category="ext", 174 | ), 175 | ) 176 | 177 | SENSORS: tuple[RobonectSensorEntityDescription, ...] = ( 178 | RobonectSensorEntityDescription( 179 | key=".battery/0", 180 | rest="$.battery.batteries.0.charge", 181 | rest_attrs="$.battery.batteries.0", 182 | array=True, 183 | device_class=SensorDeviceClass.BATTERY, 184 | state_class=SensorStateClass.MEASUREMENT, 185 | native_unit_of_measurement=PERCENTAGE, 186 | entity_category=EntityCategory.DIAGNOSTIC, 187 | icon="mdi:battery", 188 | category="battery", 189 | translation_key="battery", 190 | ), 191 | RobonectSensorEntityDescription( 192 | key=".motor/drive/left", 193 | rest="$.motor.drive.left.power", 194 | rest_attrs="$.motor.drive.left", 195 | state_class=SensorStateClass.MEASUREMENT, 196 | native_unit_of_measurement=PERCENTAGE, 197 | entity_category=EntityCategory.DIAGNOSTIC, 198 | icon="mdi:flash", 199 | category="motor", 200 | ), 201 | RobonectSensorEntityDescription( 202 | key=".motor/drive/right", 203 | rest="$.motor.drive.right.power", 204 | rest_attrs="$.motor.drive.right", 205 | state_class=SensorStateClass.MEASUREMENT, 206 | native_unit_of_measurement=PERCENTAGE, 207 | entity_category=EntityCategory.DIAGNOSTIC, 208 | icon="mdi:flash", 209 | category="motor", 210 | ), 211 | RobonectSensorEntityDescription( 212 | key=".motor/blade", 213 | rest="$.motor.blade.speed", 214 | rest_attrs="$.motor.blade", 215 | native_unit_of_measurement="RPM", 216 | entity_category=EntityCategory.DIAGNOSTIC, 217 | icon="mdi:fan", 218 | category="motor", 219 | ), 220 | RobonectSensorEntityDescription( 221 | key="mower/battery/charge", 222 | rest="$.status.status.battery", 223 | device_class=SensorDeviceClass.BATTERY, 224 | state_class=SensorStateClass.MEASUREMENT, 225 | native_unit_of_measurement=PERCENTAGE, 226 | entity_category=EntityCategory.DIAGNOSTIC, 227 | icon="mdi:battery", 228 | category="status", 229 | ), 230 | RobonectSensorEntityDescription( 231 | key="device/serial", 232 | rest="$.version.serial", 233 | entity_category=EntityCategory.DIAGNOSTIC, 234 | icon="mdi:tag", 235 | category="version", 236 | ), 237 | RobonectSensorEntityDescription( 238 | key=".version/application", 239 | rest="$.version.application.comment", 240 | rest_attrs="$.version", 241 | entity_category=EntityCategory.DIAGNOSTIC, 242 | icon="mdi:tag", 243 | category="version", 244 | ), 245 | RobonectSensorEntityDescription( 246 | key="mower/distance", 247 | rest="$.status.status.distance", 248 | icon="mdi:map-marker-distance", 249 | device_class=SensorDeviceClass.DISTANCE, 250 | native_unit_of_measurement="m", 251 | entity_category=EntityCategory.DIAGNOSTIC, 252 | category="status", 253 | ), 254 | RobonectSensorEntityDescription( 255 | key="device/name", 256 | rest="$.status.name", 257 | icon="mdi:robot-mower", 258 | entity_category=EntityCategory.DIAGNOSTIC, 259 | category="status", 260 | ), 261 | RobonectSensorEntityDescription( 262 | key="health/climate/temperature", 263 | rest="$.status.health.temperature", 264 | icon="mdi:thermometer", 265 | device_class=SensorDeviceClass.TEMPERATURE, 266 | state_class=SensorStateClass.MEASUREMENT, 267 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 268 | entity_category=EntityCategory.DIAGNOSTIC, 269 | category="status", 270 | ), 271 | RobonectSensorEntityDescription( 272 | key="health/climate/humidity", 273 | rest="$.status.health.humidity", 274 | icon="mdi:water-percent", 275 | device_class=SensorDeviceClass.HUMIDITY, 276 | state_class=SensorStateClass.MEASUREMENT, 277 | native_unit_of_measurement=PERCENTAGE, 278 | entity_category=EntityCategory.DIAGNOSTIC, 279 | category="status", 280 | ), 281 | RobonectSensorEntityDescription( 282 | key="mower/blades/quality", 283 | rest="$.status.blades.quality", 284 | state_class=SensorStateClass.MEASUREMENT, 285 | native_unit_of_measurement=PERCENTAGE, 286 | icon="mdi:terraform", 287 | entity_category=EntityCategory.DIAGNOSTIC, 288 | category="status", 289 | ), 290 | RobonectSensorEntityDescription( 291 | key="mower/blades/days", 292 | rest="$.status.blades.days", 293 | icon="mdi:terraform", 294 | device_class=SensorDeviceClass.DURATION, 295 | state_class=SensorStateClass.TOTAL_INCREASING, 296 | native_unit_of_measurement="d", 297 | entity_category=EntityCategory.DIAGNOSTIC, 298 | category="status", 299 | ), 300 | RobonectSensorEntityDescription( 301 | key="mower/blades/hours", 302 | rest="$.status.blades.hours", 303 | icon="mdi:terraform", 304 | device_class=SensorDeviceClass.DURATION, 305 | state_class=SensorStateClass.TOTAL_INCREASING, 306 | native_unit_of_measurement="h", 307 | entity_category=EntityCategory.DIAGNOSTIC, 308 | category="status", 309 | ), 310 | RobonectSensorEntityDescription( 311 | key="wlan/rssi", 312 | rest="$.status.wlan.signal", 313 | rest_attrs="$.status.wlan", 314 | state=wifi_signal_to_percentage, 315 | state_class=SensorStateClass.MEASUREMENT, 316 | native_unit_of_measurement=PERCENTAGE, 317 | icon="mdi:wifi", 318 | entity_category=EntityCategory.DIAGNOSTIC, 319 | category="status", 320 | ), 321 | RobonectSensorEntityDescription( 322 | key="mower/mode", 323 | rest="$.status.status.mode", 324 | icon="mdi:auto-mode", 325 | entity_category=EntityCategory.DIAGNOSTIC, 326 | category="status", 327 | ), 328 | RobonectSensorEntityDescription( 329 | key="mower/statistic/hours", 330 | rest="$.status.status.hours", 331 | icon="mdi:clock-star-four-points", 332 | device_class=SensorDeviceClass.DURATION, 333 | state_class=SensorStateClass.TOTAL_INCREASING, 334 | native_unit_of_measurement="h", 335 | entity_category=EntityCategory.DIAGNOSTIC, 336 | category="status", 337 | ), 338 | RobonectSensorEntityDescription( 339 | key="mower/error/code", 340 | rest="$.error.errors.0.error_code", 341 | rest_attrs="$.error.errors", 342 | icon="mdi:alert-circle", 343 | entity_category=EntityCategory.DIAGNOSTIC, 344 | category="error", 345 | ), 346 | RobonectSensorEntityDescription( 347 | key="mower/error/message", 348 | rest="$.error.errors.0.error_message", 349 | rest_attrs="$.error.errors.0", 350 | icon="mdi:alert-circle", 351 | entity_category=EntityCategory.DIAGNOSTIC, 352 | category="error", 353 | ), 354 | RobonectSensorEntityDescription( 355 | key="mower/substatus", 356 | rest="$.none", 357 | icon="mdi:crosshairs-question", 358 | entity_category=EntityCategory.DIAGNOSTIC, 359 | category="NONE", 360 | ), 361 | RobonectSensorEntityDescription( 362 | key="mower/substatus/plain", 363 | rest="$.none", 364 | icon="mdi:crosshairs-question", 365 | entity_category=EntityCategory.DIAGNOSTIC, 366 | category="NONE", 367 | ), 368 | RobonectSensorEntityDescription( 369 | key="mower/timer/next/unix", 370 | rest="$.status.timer.next.unix", 371 | rest_attrs="$.status.timer.next", 372 | icon="mdi:calendar-clock", 373 | device_class=SensorDeviceClass.TIMESTAMP, 374 | state=unix_to_datetime, 375 | entity_category=EntityCategory.DIAGNOSTIC, 376 | category="status", 377 | ), 378 | RobonectSensorEntityDescription( 379 | key="mower/status", 380 | rest="$.status.status.status", 381 | icon="mdi:crosshairs-question", 382 | entity_category=EntityCategory.DIAGNOSTIC, 383 | category="status", 384 | ), 385 | RobonectSensorEntityDescription( 386 | key="mower/status/plain", 387 | rest="$.none", 388 | icon="mdi:crosshairs-question", 389 | entity_category=EntityCategory.DIAGNOSTIC, 390 | category="NONE", 391 | ), 392 | RobonectSensorEntityDescription( 393 | key="mower/status/duration", 394 | rest="$.status.status.duration", 395 | icon="mdi:timer-sand", 396 | entity_category=EntityCategory.DIAGNOSTIC, 397 | device_class=SensorDeviceClass.DURATION, 398 | state_class=SensorStateClass.TOTAL_INCREASING, 399 | native_unit_of_measurement="min", 400 | category="status", 401 | ), 402 | RobonectSensorEntityDescription( 403 | key="health/voltage/int33", 404 | rest="$.health.health.voltages.int3v3", 405 | icon="mdi:flash", 406 | device_class=SensorDeviceClass.VOLTAGE, 407 | state_class=SensorStateClass.MEASUREMENT, 408 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 409 | entity_category=EntityCategory.DIAGNOSTIC, 410 | category="health", 411 | ), 412 | RobonectSensorEntityDescription( 413 | key="health/voltage/ext33", 414 | rest="$.health.health.voltages.ext3v3", 415 | icon="mdi:flash", 416 | device_class=SensorDeviceClass.VOLTAGE, 417 | state_class=SensorStateClass.MEASUREMENT, 418 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 419 | entity_category=EntityCategory.DIAGNOSTIC, 420 | category="health", 421 | ), 422 | RobonectSensorEntityDescription( 423 | key="health/voltage/batt", 424 | rest="$.health.health.voltages.batt", 425 | icon="mdi:flash", 426 | device_class=SensorDeviceClass.VOLTAGE, 427 | state_class=SensorStateClass.MEASUREMENT, 428 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 429 | entity_category=EntityCategory.DIAGNOSTIC, 430 | category="health", 431 | ), 432 | RobonectSensorEntityDescription( 433 | key="gps/latitude", 434 | rest="$.gps.latitude", 435 | rest_attrs="$.gps", 436 | icon="mdi:crosshairs-gps", 437 | entity_category=EntityCategory.DIAGNOSTIC, 438 | category="gps", 439 | ), 440 | RobonectSensorEntityDescription( 441 | key="gps/longitude", 442 | rest="$.gps.longitude", 443 | rest_attrs="$.gps", 444 | icon="mdi:crosshairs-gps", 445 | entity_category=EntityCategory.DIAGNOSTIC, 446 | category="gps", 447 | ), 448 | RobonectSensorEntityDescription( 449 | key="passage/open", 450 | rest="$.door.open", 451 | rest_attrs="$.door", 452 | icon="mdi:gate-open", 453 | entity_category=EntityCategory.DIAGNOSTIC, 454 | category="door", 455 | ), 456 | RobonectSensorEntityDescription( 457 | key=".clock/time", 458 | rest="$.status.clock.unix", 459 | rest_attrs="$.status.clock", 460 | icon="mdi:clock-time-eight-outline", 461 | device_class=SensorDeviceClass.TIMESTAMP, 462 | entity_category=EntityCategory.DIAGNOSTIC, 463 | category="status", 464 | ), 465 | RobonectSensorEntityDescription( 466 | key=".mower/error", 467 | rest="$.status.error.error_code", 468 | rest_attrs="$.status.error", 469 | icon="mdi:alert", 470 | entity_category=EntityCategory.DIAGNOSTIC, 471 | category="status", 472 | ), 473 | ) 474 | -------------------------------------------------------------------------------- /custom_components/robonect/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Robonect device tracking.""" 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | import logging 6 | 7 | from homeassistant.components import mqtt 8 | from homeassistant.components.device_tracker import SourceType, TrackerEntity 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_LATITUDE, ATTR_LONGITUDE 11 | from homeassistant.core import HomeAssistant, callback 12 | from homeassistant.helpers.entity import EntityDescription 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.helpers.restore_state import RestoreEntity 15 | 16 | from . import RobonectDataUpdateCoordinator 17 | from .const import ( 18 | ATTR_SATELLITES, 19 | ATTRIBUTION_MQTT, 20 | ATTRIBUTION_REST, 21 | CONF_MQTT_ENABLED, 22 | CONF_MQTT_TOPIC, 23 | CONF_REST_ENABLED, 24 | DOMAIN, 25 | ) 26 | from .entity import RobonectCoordinatorEntity, RobonectEntity 27 | from .utils import convert_coordinate_degree_to_float, filter_out_units 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | async def async_setup_entry( 33 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 34 | ) -> None: 35 | """Set up an Robonect device_tracker entry.""" 36 | 37 | # Make sure MQTT integration is enabled and the client is available 38 | if entry.data[CONF_MQTT_ENABLED] is True: 39 | if not await mqtt.async_wait_for_mqtt_client(hass): 40 | _LOGGER.error("MQTT integration is not available") 41 | return 42 | 43 | @callback 44 | def async_mqtt_event_received(msg: mqtt.ReceiveMessage) -> None: 45 | """Receive set latitude.""" 46 | if ( 47 | entry.data[CONF_MQTT_TOPIC] 48 | in hass.data[DOMAIN][entry.entry_id]["device_tracker"] 49 | ): 50 | return 51 | 52 | hass.data[DOMAIN][entry.entry_id]["device_tracker"].add( 53 | entry.data[CONF_MQTT_TOPIC] 54 | ) 55 | 56 | async_add_entities([RobonectMqttGPSEntity(hass, entry)]) 57 | 58 | if entry.data[CONF_MQTT_ENABLED] is True: 59 | await mqtt.async_subscribe( 60 | hass, 61 | f"{entry.data[CONF_MQTT_TOPIC]}/gps/latitude", 62 | async_mqtt_event_received, 63 | 0, 64 | ) 65 | elif entry.data[CONF_REST_ENABLED] is True: 66 | _LOGGER.debug("Creating REST device tracker") 67 | coordinator: RobonectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 68 | "coordinator" 69 | ] 70 | if ( 71 | coordinator.data is not None 72 | and "gps" in coordinator.data 73 | and coordinator.data.get("gps").get("succesful") 74 | ): 75 | async_add_entities( 76 | [ 77 | RobonectRestGPSEntity( 78 | hass, 79 | entry, 80 | coordinator, 81 | ) 82 | ] 83 | ) 84 | 85 | 86 | @dataclass 87 | class DeviceTrackerEntityDescription(EntityDescription): 88 | """Device tracker entity description for Robonect.""" 89 | 90 | category: str | None = None 91 | 92 | 93 | class RobonectGPSEntity(RobonectEntity, TrackerEntity, RestoreEntity): 94 | """Represent a tracked device.""" 95 | 96 | _attr_has_entity_name = True 97 | 98 | def __init__( 99 | self, 100 | hass: HomeAssistant, 101 | entry: ConfigEntry, 102 | ): 103 | """Set up GPS entity.""" 104 | self.coordinator = None 105 | self.entity_description = DeviceTrackerEntityDescription( 106 | key="gps", 107 | icon="mdi:robot-mower", 108 | category="NONE", 109 | ) 110 | self.entry = entry 111 | super().__init__(hass, entry, self.entity_description) 112 | self._attr_translation_key = "gps" 113 | self.entity_id = f"device_tracker.{self.slug}" 114 | self._longitude = None 115 | self._latitude = None 116 | self._satellites = None 117 | self._battery = None 118 | self._attributes = None 119 | 120 | @property 121 | def battery_level(self) -> int | None: 122 | """Return the battery level of the device. 123 | 124 | Percentage from 0-100. 125 | """ 126 | return self._battery 127 | 128 | @property 129 | def latitude(self) -> float | None: 130 | """Return latitude value of the device.""" 131 | return self._latitude 132 | 133 | @property 134 | def longitude(self) -> float | None: 135 | """Return longitude value of the device.""" 136 | return self._longitude 137 | 138 | @property 139 | def source_type(self) -> SourceType: 140 | """Return the source type of the device.""" 141 | return SourceType.GPS 142 | 143 | def update_rest_gps_state(self): 144 | """Update state based on REST GPS State.""" 145 | if self.coordinator is None: 146 | return False 147 | if len(self.coordinator.data) and "gps" in self.coordinator.data: 148 | gps_state = self.coordinator.data.get("gps") 149 | # gps_state = {"gps": {"satellites": 12, "latitude": "51.12052635932699", "longitude": "4.223141069768272"}, "successful": True} 150 | status = self.coordinator.data.get("status") 151 | if "gps" in gps_state: 152 | gps_state = gps_state.get("gps") 153 | self._latitude = float(gps_state.get(ATTR_LATITUDE)) 154 | self._longitude = float(gps_state.get(ATTR_LONGITUDE)) 155 | self._attributes = { 156 | "last_synced": self.last_synced, 157 | "category": self.category, 158 | ATTR_SATELLITES: gps_state.get(ATTR_SATELLITES), 159 | } 160 | else: 161 | _LOGGER.debug(f"RobonectRestGPSEntity update NOK {gps_state}") 162 | self._location = (None, None) 163 | self._accuracy = None 164 | self._attributes = { 165 | ATTR_SATELLITES: None, 166 | } 167 | self._battery = int(status.get("status").get("battery")) 168 | self.update_ha_state() 169 | return True 170 | return False 171 | 172 | async def async_added_to_hass(self) -> None: 173 | """Subscribe to MQTT events.""" 174 | await super().async_added_to_hass() 175 | 176 | @callback 177 | def latitude_received(message): 178 | """Handle new latitude topic.""" 179 | self._latitude = convert_coordinate_degree_to_float(message.payload) 180 | self.update_ha_state() 181 | 182 | @callback 183 | def longitude_received(message): 184 | """Handle new longitude topic.""" 185 | self._longitude = convert_coordinate_degree_to_float(message.payload) 186 | self.update_ha_state() 187 | 188 | @callback 189 | def battery_received(message): 190 | """Handle battery topic.""" 191 | self._battery = int(filter_out_units(message.payload)) 192 | self.update_ha_state() 193 | 194 | @callback 195 | def satellites_received(message): 196 | """Handle satellites topic.""" 197 | self._attributes |= {"satellites": message.payload} 198 | self.update_ha_state() 199 | 200 | if self.entry.data[CONF_MQTT_ENABLED] is True: 201 | await mqtt.async_subscribe( 202 | self.hass, 203 | f"{self.entry.data[CONF_MQTT_TOPIC]}/gps/latitude", 204 | latitude_received, 205 | 1, 206 | ) 207 | await mqtt.async_subscribe( 208 | self.hass, 209 | f"{self.entry.data[CONF_MQTT_TOPIC]}/gps/longitude", 210 | longitude_received, 211 | 1, 212 | ) 213 | await mqtt.async_subscribe( 214 | self.hass, 215 | f"{self.entry.data[CONF_MQTT_TOPIC]}/gps/satellites", 216 | satellites_received, 217 | 1, 218 | ) 219 | await mqtt.async_subscribe( 220 | self.hass, 221 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/battery/charge", 222 | battery_received, 223 | 1, 224 | ) 225 | 226 | # Don't restore if status is fetched from coordinator data 227 | if self.entry.data[CONF_MQTT_ENABLED] is False and self.update_rest_gps_state(): 228 | return 229 | 230 | if (state := await self.async_get_last_state()) is None: 231 | self._location = (None, None) 232 | self._accuracy = None 233 | self._attributes = { 234 | ATTR_SATELLITES: None, 235 | } 236 | self._battery = None 237 | return 238 | 239 | attr = state.attributes 240 | self._latitude = attr.get(ATTR_LATITUDE) 241 | self._longitude = attr.get(ATTR_LONGITUDE) 242 | self._attributes = { 243 | ATTR_SATELLITES: attr.get(ATTR_SATELLITES), 244 | } 245 | self._battery = attr.get(ATTR_BATTERY_LEVEL) 246 | 247 | 248 | class RobonectMqttGPSEntity(RobonectGPSEntity): 249 | """Represent an MQTT tracked device.""" 250 | 251 | _attr_attribution = ATTRIBUTION_MQTT 252 | 253 | def __init__( 254 | self, 255 | hass: HomeAssistant, 256 | entry: ConfigEntry, 257 | ) -> None: 258 | """Initialize the sensor.""" 259 | super().__init__(hass, entry) 260 | 261 | 262 | class RobonectRestGPSEntity(RobonectCoordinatorEntity, RobonectGPSEntity): 263 | """Represent a REST tracked device.""" 264 | 265 | _attr_attribution = ATTRIBUTION_REST 266 | 267 | def __init__( 268 | self, 269 | hass: HomeAssistant, 270 | entry: ConfigEntry, 271 | coordinator: RobonectDataUpdateCoordinator, 272 | ) -> None: 273 | """Initialize the sensor.""" 274 | RobonectGPSEntity.__init__(self, hass, entry) 275 | super().__init__(coordinator, self.entity_description) 276 | 277 | @callback 278 | def _handle_coordinator_update(self) -> None: 279 | """Handle updated data from the coordinator.""" 280 | self.last_synced = datetime.now() 281 | self.update_rest_gps_state() 282 | return 283 | -------------------------------------------------------------------------------- /custom_components/robonect/entity.py: -------------------------------------------------------------------------------- 1 | """Base Robonect entity.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | import logging 7 | from typing import Any 8 | 9 | from homeassistant.components import mqtt 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_HOST, CONF_TYPE 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.helpers import device_registry as dr 14 | from homeassistant.helpers.device_registry import DeviceEntryType 15 | from homeassistant.helpers.entity import DeviceInfo, EntityDescription 16 | from homeassistant.helpers.restore_state import RestoreEntity 17 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 18 | from homeassistant.util import slugify 19 | 20 | from . import RobonectDataUpdateCoordinator 21 | from .const import ( 22 | CONF_BRAND, 23 | CONF_MQTT_ENABLED, 24 | CONF_MQTT_TOPIC, 25 | DOMAIN, 26 | EVENT_ROBONECT_RESPONSE, 27 | NAME, 28 | VERSION, 29 | ) 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | class RobonectEntity(RestoreEntity): 35 | """Base Robonect entity.""" 36 | 37 | _attr_translation_key = None 38 | 39 | def __init__( 40 | self, 41 | hass: HomeAssistant, 42 | entry: ConfigEntry, 43 | description: EntityDescription, 44 | ) -> None: 45 | """Initialize Robonect entities.""" 46 | self.hass = hass 47 | self.dev_reg = dr.async_get(hass) 48 | self.entry = entry 49 | self.entity_description = description 50 | self.base_topic = entry.data[CONF_MQTT_TOPIC] 51 | self.topic = f"{self.base_topic}/{self.entity_description.key}" 52 | self.slug = slugify(self.topic.replace("/", "_")) 53 | if self._attr_translation_key is None: 54 | if self.entity_description.translation_key: 55 | self._attr_translation_key = self.entity_description.translation_key 56 | else: 57 | self._attr_translation_key = slugify( 58 | self.entity_description.key.replace("/", "_") 59 | ) 60 | if self._attr_unique_id is None: 61 | self._attr_unique_id = ( 62 | f"{entry.entry_id}-{self.entity_description.category}-{self.slug}" 63 | ) 64 | self.device_identifier = {(DOMAIN, self.base_topic)} 65 | 66 | self._attr_device_info = DeviceInfo( 67 | identifiers=self.device_identifier, 68 | name=self.base_topic.title(), 69 | manufacturer=NAME, 70 | configuration_url=f"http://{entry.data[CONF_HOST]}", 71 | entry_type=DeviceEntryType.SERVICE, 72 | model=f"{entry.data[CONF_BRAND]} {entry.data[CONF_TYPE]}", 73 | sw_version=VERSION, 74 | ) 75 | self.last_synced = datetime.now() 76 | 77 | def not_supported(self, feature): 78 | """Log a warning for a not supported feature instead of raising a standard exception.""" 79 | _LOGGER.warning(f"{feature} not supported") 80 | 81 | async def async_fire_event(self, response): 82 | """Fire a bus event.""" 83 | device = self.dev_reg.async_get_device(self.device_identifier) 84 | self.hass.bus.async_fire( 85 | EVENT_ROBONECT_RESPONSE, 86 | {"device_id": device.id, "client_response": response}, 87 | ) 88 | 89 | async def async_send_command( 90 | self, 91 | command: str, 92 | params: dict[str, Any] | list[Any] | None = None, 93 | **kwargs: Any, 94 | ) -> None: 95 | """Send a command to a Robonect mower.""" 96 | 97 | if not command: 98 | _LOGGER.error(f"No command defined for entity {self.entity_id}") 99 | if params is None: 100 | params = {} 101 | if self.coordinator: 102 | _LOGGER.debug( 103 | f"[REST async_send_command] command: {command}, params: {params}" 104 | ) 105 | try: 106 | response = await self.coordinator.client.async_cmd(command, params) 107 | except Exception as exception: 108 | _LOGGER.error(f"Exception during async command execution: {exception}") 109 | response = {"successful": False, "exception": str(exception)} 110 | await self.async_fire_event( 111 | {**response, "command": command, "params": params} 112 | ) 113 | elif self.entry.data[CONF_MQTT_ENABLED] is True and "topic" in kwargs: 114 | _LOGGER.debug( 115 | f"[MQTT async_send_command] MQTT publish to topic: {self.entry.data[CONF_MQTT_TOPIC]}/{kwargs['topic']} with payload: {command}" 116 | ) 117 | await mqtt.async_publish( 118 | self.hass, 119 | f"{self.entry.data[CONF_MQTT_TOPIC]}/{kwargs['topic']}", 120 | command, 121 | qos=0, 122 | retain=False, 123 | ) 124 | 125 | def update_ha_state(self): 126 | """Update HA state.""" 127 | self.last_synced = datetime.now() 128 | self.schedule_update_ha_state() 129 | 130 | 131 | class RobonectCoordinatorEntity( 132 | CoordinatorEntity[RobonectDataUpdateCoordinator], RestoreEntity 133 | ): 134 | """Robonect Coordinator entity.""" 135 | 136 | def __init__( 137 | self, 138 | coordinator: RobonectDataUpdateCoordinator, 139 | description: EntityDescription, 140 | ) -> None: 141 | """Initialize Robonect entities.""" 142 | super().__init__(coordinator) 143 | self.entity_description = description 144 | 145 | @callback 146 | def _handle_coordinator_update(self) -> None: 147 | """Handle updated data from the coordinator.""" 148 | if len(self.coordinator.data): 149 | self.update_ha_state() 150 | return 151 | -------------------------------------------------------------------------------- /custom_components/robonect/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions used by Robonect.""" 2 | 3 | 4 | class RobonectException(Exception): 5 | """Base class for all exceptions raised by Robonect.""" 6 | 7 | pass 8 | 9 | 10 | class RobonectServiceException(Exception): 11 | """Raised when service is not available.""" 12 | 13 | pass 14 | 15 | 16 | class BadCredentialsException(Exception): 17 | """Raised when credentials are incorrect.""" 18 | 19 | pass 20 | 21 | 22 | class NotAuthenticatedException(Exception): 23 | """Raised when session is invalid.""" 24 | 25 | pass 26 | 27 | 28 | class GatewayTimeoutException(RobonectServiceException): 29 | """Raised when server times out.""" 30 | 31 | pass 32 | 33 | 34 | class BadGatewayException(RobonectServiceException): 35 | """Raised when server returns Bad Gateway.""" 36 | 37 | pass 38 | -------------------------------------------------------------------------------- /custom_components/robonect/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "job": "mdi:briefcase", 4 | "operation_mode": "mdi:auto-mode", 5 | "reboot": "mdi:restart", 6 | "shutdown": "mdi:power", 7 | "sleep": "mdi:sleep", 8 | "start": "mdi:play", 9 | "stop": "mdi:stop", 10 | "timer": "mdi:timer" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/robonect/lawn_mower.py: -------------------------------------------------------------------------------- 1 | """Robonect lawn_mower platform.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | 8 | from homeassistant.components import mqtt 9 | from homeassistant.components.lawn_mower import ( 10 | LawnMowerEntity, 11 | LawnMowerEntityEntityDescription, 12 | ) 13 | from homeassistant.components.lawn_mower.const import LawnMowerEntityFeature 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.const import ATTR_BATTERY_LEVEL 16 | from homeassistant.core import HomeAssistant, callback 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.icon import icon_for_battery_level 19 | from homeassistant.helpers.restore_state import RestoreEntity 20 | from homeassistant.util import slugify 21 | 22 | from . import RobonectDataUpdateCoordinator 23 | from .const import ( 24 | ATTRIBUTION_MQTT, 25 | ATTRIBUTION_REST, 26 | CONF_MQTT_ENABLED, 27 | CONF_MQTT_TOPIC, 28 | CONF_REST_ENABLED, 29 | DOMAIN, 30 | STATUS_MAPPING_LAWN_MOWER, 31 | ) 32 | from .entity import RobonectCoordinatorEntity, RobonectEntity 33 | from .utils import filter_out_units, get_json_dict_path, unix_to_datetime 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | MQTT_ATTRIBUTES = [ 38 | "status_plain", 39 | "status_duration", 40 | "distance", 41 | "statistic_hours", 42 | "timer_next_unix", 43 | "blades_quality", 44 | "mode", 45 | ] 46 | 47 | 48 | async def async_setup_entry( 49 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 50 | ) -> None: 51 | """Set up an Robonect lawn_mower entry.""" 52 | 53 | # Make sure MQTT integration is enabled and the client is available 54 | if entry.data[CONF_MQTT_ENABLED] is True: 55 | if not await mqtt.async_wait_for_mqtt_client(hass): 56 | _LOGGER.error("MQTT integration is not available") 57 | return 58 | 59 | @callback 60 | def async_mqtt_event_received(msg: mqtt.ReceiveMessage) -> None: 61 | """Receive set latitude.""" 62 | if ( 63 | entry.data[CONF_MQTT_TOPIC] 64 | in hass.data[DOMAIN][entry.entry_id]["lawn_mower"] 65 | ): 66 | return 67 | 68 | _LOGGER.debug("async_mqtt_event_received | Adding MQTT Lawn mower") 69 | hass.data[DOMAIN][entry.entry_id]["lawn_mower"].add(entry.data[CONF_MQTT_TOPIC]) 70 | 71 | async_add_entities([RobonectMqttLawnMowerEntity(hass, entry)]) 72 | 73 | if entry.data[CONF_MQTT_ENABLED] is True: 74 | _LOGGER.debug("Creating MQTT Lawn mower") 75 | await mqtt.async_subscribe( 76 | hass, f"{entry.data[CONF_MQTT_TOPIC]}/mqtt", async_mqtt_event_received, 0 77 | ) 78 | elif entry.data[CONF_REST_ENABLED] is True: 79 | _LOGGER.debug("Creating REST Lawn mower") 80 | coordinator: RobonectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 81 | "coordinator" 82 | ] 83 | if coordinator.data is not None and "status" in coordinator.data: 84 | async_add_entities( 85 | [ 86 | RobonectRestLawnMowerEntity( 87 | hass, 88 | entry, 89 | coordinator, 90 | ) 91 | ] 92 | ) 93 | 94 | 95 | @dataclass 96 | class LawnMowerEntityDescription(LawnMowerEntityEntityDescription): 97 | """Lawn mower entity description for Robonect.""" 98 | 99 | category: str | None = None 100 | 101 | 102 | class RobonectLawnMowerEntity(RobonectEntity, LawnMowerEntity, RestoreEntity): 103 | """Representation of a Robonect lawn_mower.""" 104 | 105 | _attr_has_entity_name = True 106 | _attr_icon = "mdi:robot-mower" 107 | _attr_supported_features = ( 108 | LawnMowerEntityFeature.DOCK 109 | | LawnMowerEntityFeature.PAUSE 110 | | LawnMowerEntityFeature.START_MOWING 111 | ) 112 | 113 | def __init__( 114 | self, 115 | hass: HomeAssistant, 116 | entry: ConfigEntry, 117 | ): 118 | """Set up Lawn Mower entity.""" 119 | self.coordinator = None 120 | self.entity_description = LawnMowerEntityDescription( 121 | key="automower", 122 | icon="mdi:robot-mower", 123 | category="NONE", 124 | ) 125 | self.entry = entry 126 | super().__init__(hass, entry, self.entity_description) 127 | self._attr_translation_key = "automower" 128 | self.entity_id = f"lawn_mower.{self.base_topic}_robonect" 129 | self._battery = None 130 | self._attributes = {} 131 | self._attr_state = None 132 | self._attr_status = None 133 | 134 | @property 135 | def extra_state_attributes(self) -> dict: 136 | """Return the specific state attributes of this mower.""" 137 | if self._attr_status: 138 | return { 139 | "substatus": self._attr_status, 140 | } | self._attributes 141 | return self._attributes | {"last_synced": self.last_synced} 142 | 143 | @property 144 | def battery_level(self) -> int | None: 145 | """Return the battery level of the device. 146 | 147 | Percentage from 0-100. 148 | """ 149 | return self._battery 150 | 151 | @property 152 | def battery_icon(self) -> str: 153 | """Return the battery icon for the Robonect mower.""" 154 | charging = bool(self.state == "charging") 155 | 156 | return icon_for_battery_level( 157 | battery_level=self.battery_level, charging=charging 158 | ) 159 | 160 | async def async_start_mowing(self) -> None: 161 | """Start mowing.""" 162 | await self.async_send_command("start", {}, topic="control") 163 | 164 | async def async_pause(self) -> None: 165 | """Pause.""" 166 | await self.async_send_command("stop", {}, topic="control") 167 | 168 | async def async_dock(self) -> None: 169 | """Dock.""" 170 | if self.entry.data[CONF_MQTT_ENABLED] is True: 171 | await self.async_send_command("home", {}, topic="control/mode") 172 | else: 173 | await self.async_send_command("mode", {"mode": "home"}) 174 | 175 | def update_rest_state(self): 176 | """Update state based on REST State.""" 177 | if self.coordinator is None: 178 | return False 179 | if len(self.coordinator.data) and "status" in self.coordinator.data: 180 | status = self.coordinator.data.get("status") 181 | self._attr_activity = STATUS_MAPPING_LAWN_MOWER.get( 182 | int(status.get("status").get("status")), "unknown" 183 | ) 184 | self._battery = int(status.get("status").get("battery")) 185 | attr_states = { 186 | "status_duration": "$.status.status.duration", 187 | "distance": "$.status.status.distance", 188 | "statistic_hours": "$.status.status.hours", 189 | "timer_next_unix": "$.status.timer.next.unix", 190 | "blades_quality": "$.status.blades.quality", 191 | "mode": "$.status.status.mode", 192 | } 193 | self._attributes = {} 194 | for key, value in attr_states.items(): 195 | state = get_json_dict_path(self.coordinator.data, value) 196 | if state is not None: 197 | if key == "timer_next_unix": 198 | state = unix_to_datetime(state, self.coordinator.hass) 199 | elif key == "status_duration": 200 | state = round(state / 60) 201 | self._attributes |= {key: state} 202 | self.update_ha_state() 203 | return True 204 | return False 205 | 206 | async def async_added_to_hass(self) -> None: 207 | """Subscribe to MQTT events.""" 208 | 209 | @callback 210 | def battery_received(message): 211 | """Handle battery topic.""" 212 | self._battery = int(filter_out_units(message.payload)) 213 | self.update_ha_state() 214 | 215 | @callback 216 | def state_received(message): 217 | """Handle state topic.""" 218 | try: 219 | status_code = int(message.payload) 220 | except ValueError: 221 | _LOGGER.error("Invalid status code received: %s", message.payload) 222 | status_code = None 223 | self._attr_activity = STATUS_MAPPING_LAWN_MOWER.get(status_code, "unknown") 224 | self.update_ha_state() 225 | 226 | @callback 227 | def substatus_received(message): 228 | """Handle substatus topic.""" 229 | self._attr_status = message.payload 230 | self.update_ha_state() 231 | 232 | @callback 233 | def topic_received(message): 234 | """Handle topic.""" 235 | slug = slugify( 236 | message.topic.replace( 237 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/", "" 238 | ).replace("/", "_") 239 | ) 240 | if slug in MQTT_ATTRIBUTES: 241 | payload = message.payload 242 | if slug == "timer_next_unix": 243 | payload = unix_to_datetime(message.payload, self.hass) 244 | self._attributes |= {slug: payload} 245 | 246 | if self.entry.data[CONF_MQTT_ENABLED] is True: 247 | await mqtt.async_subscribe( 248 | self.hass, 249 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/battery/charge", 250 | battery_received, 251 | 1, 252 | ) 253 | await mqtt.async_subscribe( 254 | self.hass, 255 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/status", 256 | state_received, 257 | 1, 258 | ) 259 | await mqtt.async_subscribe( 260 | self.hass, 261 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/substatus", 262 | substatus_received, 263 | 1, 264 | ) 265 | await mqtt.async_subscribe( 266 | self.hass, 267 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/#", 268 | topic_received, 269 | 1, 270 | ) 271 | # Don't restore if status is fetched from coordinator data 272 | if self.entry.data[CONF_MQTT_ENABLED] is False and self.update_rest_state(): 273 | await super().async_added_to_hass() 274 | return 275 | 276 | if (state := await self.async_get_last_state()) is None: 277 | self._attr_activity = None 278 | self._battery = None 279 | self._attributes = {} 280 | await super().async_added_to_hass() 281 | return 282 | 283 | attr = state.attributes 284 | self._attributes = attr 285 | self._battery = attr.get(ATTR_BATTERY_LEVEL) 286 | await super().async_added_to_hass() 287 | 288 | 289 | class RobonectMqttLawnMowerEntity(RobonectLawnMowerEntity): 290 | """Represent an MQTT tracked device.""" 291 | 292 | _attr_attribution = ATTRIBUTION_MQTT 293 | 294 | def __init__( 295 | self, 296 | hass: HomeAssistant, 297 | entry: ConfigEntry, 298 | ) -> None: 299 | """Initialize the sensor.""" 300 | super().__init__(hass, entry) 301 | 302 | 303 | class RobonectRestLawnMowerEntity(RobonectCoordinatorEntity, RobonectLawnMowerEntity): 304 | """Represent a REST tracked device.""" 305 | 306 | _attr_attribution = ATTRIBUTION_REST 307 | 308 | def __init__( 309 | self, 310 | hass: HomeAssistant, 311 | entry: ConfigEntry, 312 | coordinator: RobonectDataUpdateCoordinator, 313 | ) -> None: 314 | """Initialize the sensor.""" 315 | RobonectLawnMowerEntity.__init__(self, hass, entry) 316 | super().__init__(coordinator, self.entity_description) 317 | 318 | @callback 319 | def _handle_coordinator_update(self) -> None: 320 | """Handle updated data from the coordinator.""" 321 | 322 | self.update_rest_state() 323 | return 324 | -------------------------------------------------------------------------------- /custom_components/robonect/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "robonect", 3 | "name": "Robonect", 4 | "codeowners": [ 5 | "@geertmeersman" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [ 9 | "mqtt", 10 | "lawn_mower" 11 | ], 12 | "documentation": "https://github.com/geertmeersman/robonect", 13 | "integration_type": "hub", 14 | "iot_class": "local_push", 15 | "issue_tracker": "https://github.com/geertmeersman/robonect/issues", 16 | "mqtt": [ 17 | "automower/mqtt" 18 | ], 19 | "requirements": [ 20 | "jsonpath" 21 | ], 22 | "version": "v2.4.2" 23 | } 24 | -------------------------------------------------------------------------------- /custom_components/robonect/models.py: -------------------------------------------------------------------------------- 1 | """Models used by Robonect.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass, field 6 | from typing import TypedDict 7 | 8 | 9 | class RobonectConfigEntryData(TypedDict): 10 | """Config entry for the Robonect integration.""" 11 | 12 | mqtt_enabled: bool | None 13 | mqtt_topic: str | None 14 | host: str | None 15 | username: str | None 16 | password: str | None 17 | type: str | None 18 | brand: str | None 19 | rest_enabled: bool | None 20 | monitored_variables: dict | None 21 | scan_interval: int | None 22 | attributes_units: bool | True 23 | winter_mode: bool | True 24 | 25 | 26 | @dataclass 27 | class RobonectEnvironment: 28 | """Class to describe a Robonect environment.""" 29 | 30 | api_endpoint: str 31 | 32 | 33 | @dataclass 34 | class RobonectTimer: 35 | """Robonect timer model.""" 36 | 37 | enable: bool | False 38 | weekdays: str | None 39 | weekdays_dict: dict | None 40 | start: str | None 41 | end: str | None 42 | 43 | 44 | @dataclass 45 | class RobonectItem: 46 | """Robonect item model.""" 47 | 48 | platform: str = "" 49 | name: str = "" 50 | key: str = "" 51 | type: str = "" 52 | state: str = "" 53 | device_key: str = "" 54 | device_name: str = "" 55 | device_model: str = "" 56 | serial: str = "" 57 | firmware: str = "" 58 | status: dict = field(default_factory=dict) 59 | data: dict = field(default_factory=dict) 60 | extra_attributes: dict = field(default_factory=dict) 61 | native_unit_of_measurement: str = None 62 | 63 | 64 | @dataclass 65 | class RobonectSensorItem(RobonectItem): 66 | """Robonect sensor model.""" 67 | 68 | platform: str = "sensor" 69 | 70 | 71 | @dataclass 72 | class RobonectVacuumItem(RobonectItem): 73 | """Robonect Vacuum model.""" 74 | 75 | platform: str = "vacuum" 76 | -------------------------------------------------------------------------------- /custom_components/robonect/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Robonect through MQTT.""" 2 | 3 | from __future__ import annotations 4 | 5 | import copy 6 | import logging 7 | 8 | from homeassistant.components import mqtt 9 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_MONITORED_VARIABLES 12 | from homeassistant.core import Event, HomeAssistant, callback 13 | from homeassistant.helpers.entity import EntityCategory 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.util import slugify 16 | 17 | from . import RobonectDataUpdateCoordinator 18 | from .const import ( 19 | ATTRIBUTION_MQTT, 20 | ATTRIBUTION_REST, 21 | CONF_ATTRS_UNITS, 22 | CONF_MQTT_ENABLED, 23 | CONF_MQTT_TOPIC, 24 | CONF_REST_ENABLED, 25 | DOMAIN, 26 | EVENT_ROBONECT_RESPONSE, 27 | ) 28 | from .definitions import SENSORS, RobonectSensorEntityDescription 29 | from .entity import RobonectCoordinatorEntity, RobonectEntity 30 | from .utils import ( 31 | adapt_attributes, 32 | filter_out_units, 33 | get_json_dict_path, 34 | unix_to_datetime, 35 | ) 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | async def async_setup_entry( 41 | hass: HomeAssistant, 42 | entry: ConfigEntry, 43 | async_add_entities: AddEntitiesCallback, 44 | ) -> None: 45 | """Set up Robonect sensors from config entry.""" 46 | 47 | # Make sure MQTT integration is enabled and the client is available 48 | if entry.data[CONF_MQTT_ENABLED] is True: 49 | if not await mqtt.async_wait_for_mqtt_client(hass): 50 | _LOGGER.error("MQTT integration is not available") 51 | return 52 | 53 | added_entities = [] 54 | 55 | @callback 56 | def async_mqtt_event_received(msg: mqtt.ReceiveMessage) -> None: 57 | """Process events as sensors.""" 58 | slug = slugify(msg.topic.replace("/", "_")) 59 | entity_id = f"sensor.{slug}" 60 | if slug in hass.data[DOMAIN][entry.entry_id]["sensor"]: 61 | return 62 | hass.data[DOMAIN][entry.entry_id]["sensor"].add(slug) 63 | 64 | if entity_id not in added_entities: 65 | description_key = msg.topic.replace(f"{entry.data[CONF_MQTT_TOPIC]}/", "") 66 | if description_key[0] != ".": 67 | _LOGGER.debug( 68 | f"[async_mqtt_event_received] Adding entity {entity_id} (MQTT Topic {msg.topic})" 69 | ) 70 | async_add_entities([RobonectMqttSensor(hass, entry, description_key)]) 71 | added_entities.append(entity_id) 72 | 73 | if entry.data[CONF_MQTT_ENABLED] is True: 74 | _LOGGER.debug(f"MQTT Subscribing to {entry.data[CONF_MQTT_TOPIC]}/#") 75 | await mqtt.async_subscribe( 76 | hass, f"{entry.data[CONF_MQTT_TOPIC]}/#", async_mqtt_event_received, 0 77 | ) 78 | 79 | if entry.data[CONF_REST_ENABLED] is True: 80 | _LOGGER.debug("Creating REST sensors") 81 | coordinator: RobonectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 82 | "coordinator" 83 | ] 84 | entities: list[RobonectRestSensor] = [] 85 | entities.append( 86 | RobonectServiceSensor( 87 | hass, 88 | entry, 89 | description=RobonectSensorEntityDescription( 90 | key=".service/call/result", 91 | rest="$.service.call.result", 92 | icon="mdi:book-information-variant", 93 | entity_category=EntityCategory.DIAGNOSTIC, 94 | category="NONE", 95 | ), 96 | ) 97 | ) 98 | if coordinator.data is not None: 99 | for description in SENSORS: 100 | if not description.rest: 101 | path = description.key 102 | else: 103 | if entry.data[CONF_MQTT_ENABLED] and description.key[0] != ".": 104 | _LOGGER.debug( 105 | f"[sensor|async_setup_entry|skipping since MQTT] {description.key}" 106 | ) 107 | continue 108 | if description.rest == "$.none": 109 | continue 110 | if description.category not in entry.data[CONF_MONITORED_VARIABLES]: 111 | continue 112 | path = description.rest 113 | if description.category not in coordinator.data: 114 | continue 115 | _LOGGER.debug(f"[async_setup_entry|REST|adding] {path}") 116 | if description.array: 117 | array = get_json_dict_path( 118 | coordinator.data, description.rest_attrs.replace(".0", "") 119 | ) 120 | if array is None: 121 | continue 122 | for idx, item in enumerate(array): 123 | _LOGGER.debug( 124 | f"[async_setup_entry|REST|adding] Item in array: {item}" 125 | ) 126 | desc = copy.copy(description) 127 | desc.rest = description.rest.replace(".0", f".{idx}") 128 | desc.rest_attrs = description.rest_attrs.replace( 129 | ".0", f".{idx}" 130 | ) 131 | desc.key = description.key.replace("/0", f"/{idx}") 132 | entities.append( 133 | RobonectRestSensor( 134 | hass, 135 | entry, 136 | coordinator=coordinator, 137 | description=desc, 138 | ) 139 | ) 140 | else: 141 | entities.append( 142 | RobonectRestSensor( 143 | hass, 144 | entry, 145 | coordinator=coordinator, 146 | description=description, 147 | ) 148 | ) 149 | async_add_entities(entities) 150 | 151 | 152 | class RobonectSensor(RobonectEntity, SensorEntity): 153 | """Representation of a Robonect sensor.""" 154 | 155 | entity_description: RobonectSensorEntityDescription 156 | _attr_has_entity_name = True 157 | 158 | def __init__( 159 | self, 160 | hass: HomeAssistant, 161 | entry: ConfigEntry, 162 | description: RobonectSensorEntityDescription, 163 | ) -> None: 164 | """Initialize the sensor.""" 165 | super().__init__(hass, entry, description) 166 | self.entity_id = f"sensor.{self.slug}" 167 | self._state = None 168 | self._attributes = {} 169 | 170 | async def async_added_to_hass(self) -> None: 171 | """Subscribe to MQTT events.""" 172 | 173 | @callback 174 | def message_received(message): 175 | """Handle new MQTT messages.""" 176 | if message.payload == "": 177 | self._state = None 178 | else: 179 | if self.entity_description.native_unit_of_measurement: 180 | state = filter_out_units(message.payload) 181 | else: 182 | state = message.payload 183 | if self.entity_description.state is not None: 184 | self._state = self.entity_description.state(state, self.hass) 185 | self._attributes = {"Raw state": message.payload} 186 | else: 187 | self._state = state 188 | self.update_ha_state() 189 | 190 | """ 191 | if state := await self.async_get_last_state(): 192 | if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: 193 | _LOGGER.debug(f"Restoring state for: {self.entity_id} => {state.state}") 194 | if ( 195 | state.state is not STATE_UNAVAILABLE 196 | and state.as_dict().get("attributes").get("device_class") 197 | == SensorDeviceClass.TIMESTAMP 198 | ): 199 | if state.state is STATE_UNKNOWN: 200 | self._state = None 201 | else: 202 | self._state = unix_to_datetime( 203 | state.as_dict().get("attributes").get("unix"), self.hass 204 | ) 205 | else: 206 | if state.state is STATE_UNAVAILABLE or state.state is STATE_UNKNOWN: 207 | self._state = None 208 | else: 209 | self._state = state.state 210 | self._attributes = state.attributes 211 | else: 212 | _LOGGER.debug(f"Last state is none for {self._attr_unique_id}") 213 | """ 214 | if self.entry.data[CONF_MQTT_ENABLED] is True: 215 | await mqtt.async_subscribe(self.hass, self.topic, message_received, 1) 216 | 217 | await super().async_added_to_hass() 218 | 219 | return 220 | 221 | 222 | class RobonectMqttSensor(RobonectSensor): 223 | """Representation of a Robonect sensor that is updated via MQTT.""" 224 | 225 | _attr_attribution = ATTRIBUTION_MQTT 226 | 227 | def __init__( 228 | self, hass: HomeAssistant, entry: ConfigEntry, description_key: str 229 | ) -> None: 230 | """Initialize the sensor.""" 231 | self._description_key = description_key 232 | self.entity_description = self.get_mqtt_description() 233 | self._attr_entity_registry_enabled_default = self.get_mqtt_description(True) 234 | super().__init__(hass, entry, self.entity_description) 235 | 236 | def get_mqtt_description( 237 | self, return_bool=False 238 | ) -> RobonectSensorEntityDescription: 239 | """Return the RobonectSensorEntityDescription for the description_key.""" 240 | for description in SENSORS: 241 | if description.key == self._description_key: 242 | if return_bool: 243 | return True 244 | return description 245 | if return_bool: 246 | return False 247 | return RobonectSensorEntityDescription( 248 | key=self._description_key, 249 | icon="mdi:help", 250 | entity_category=EntityCategory.DIAGNOSTIC, 251 | ) 252 | 253 | @property 254 | def native_value(self): 255 | """Return the status of the sensor.""" 256 | return self._state 257 | 258 | @property 259 | def extra_state_attributes(self): 260 | """Return attributes for sensor.""" 261 | self._attributes |= { 262 | "last_synced": self.last_synced, 263 | } 264 | return self._attributes 265 | 266 | 267 | class RobonectRestSensor(RobonectCoordinatorEntity, RobonectSensor): 268 | """Representation of a Robonect sensor that is updated via REST API.""" 269 | 270 | _attr_attribution = ATTRIBUTION_REST 271 | 272 | def __init__( 273 | self, 274 | hass: HomeAssistant, 275 | entry: ConfigEntry, 276 | coordinator: RobonectDataUpdateCoordinator, 277 | description: RobonectSensorEntityDescription, 278 | ) -> None: 279 | """Initialize the sensor.""" 280 | super().__init__(coordinator, description) 281 | RobonectSensor.__init__(self, hass, entry, description) 282 | self.category = self.entity_description.category 283 | self.entity_description = description 284 | 285 | @callback 286 | def _handle_coordinator_update(self) -> None: 287 | """Handle updated data from the coordinator.""" 288 | self.set_extra_attributes() 289 | self.set_state() 290 | super()._handle_coordinator_update() 291 | 292 | def set_state(self): 293 | """Set the status of the sensor from the coordinatorsensor.""" 294 | if len(self.coordinator.data) and self.category in self.coordinator.data: 295 | state = get_json_dict_path( 296 | self.coordinator.data, self.entity_description.rest 297 | ) 298 | if state is not None: 299 | state = copy.copy(state) 300 | if isinstance(state, str): 301 | state = filter_out_units(state) 302 | if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: 303 | state = unix_to_datetime(state, self.coordinator.hass) 304 | elif self.entity_description.device_class == SensorDeviceClass.VOLTAGE: 305 | state = round(state / 1000, 1) 306 | elif self.entity_description.rest == "$.status.status.duration": 307 | state = state / 60 308 | self._state = state 309 | else: 310 | self._state = None 311 | 312 | @property 313 | def native_value(self): 314 | """Return the status of the sensor.""" 315 | self.set_state() 316 | return self._state 317 | 318 | def set_extra_attributes(self): 319 | """Set the attributes for the sensor from coordinator.""" 320 | if len(self.coordinator.data) and self.category in self.coordinator.data: 321 | attributes = { 322 | "last_synced": self.coordinator.data[self.category]["sync_time"], 323 | "category": self.category, 324 | } 325 | if self.entity_description.rest_attrs: 326 | attrs = get_json_dict_path( 327 | self.coordinator.data, self.entity_description.rest_attrs 328 | ) 329 | attrs = copy.copy(attrs) 330 | if attrs: 331 | adapt_attributes( 332 | attrs, self.category, self.entry.data[CONF_ATTRS_UNITS] 333 | ) 334 | if not isinstance(attrs, list): 335 | attributes.update(attrs) 336 | self._attr_extra_state_attributes = attributes 337 | 338 | @property 339 | def extra_state_attributes(self): 340 | """Return attributes for sensor.""" 341 | self.set_extra_attributes() 342 | return self._attr_extra_state_attributes 343 | 344 | 345 | class RobonectServiceSensor(RobonectSensor): 346 | """Representation of a Robonect sensor that is updated via REST API.""" 347 | 348 | _attr_attribution = ATTRIBUTION_REST 349 | 350 | def __init__( 351 | self, 352 | hass: HomeAssistant, 353 | entry: ConfigEntry, 354 | description: RobonectSensorEntityDescription, 355 | ) -> None: 356 | """Initialize the sensor.""" 357 | super().__init__(hass, entry, description) 358 | self.category = self.entity_description.rest.split(".")[1] 359 | self.entity_description = description 360 | self._attributes = {} 361 | self._state = "SUCCESS" 362 | hass.bus.async_listen(EVENT_ROBONECT_RESPONSE, self.update_busevent) 363 | 364 | def update_busevent(self, event: Event): 365 | """Update sensor on bus event.""" 366 | client_response = event.data.get("client_response") 367 | self._attributes = client_response 368 | self._state = client_response.get("successful") 369 | self.update_ha_state() 370 | _LOGGER.debug(f"Event client_response: {client_response}") 371 | 372 | @property 373 | def native_value(self): 374 | """Return the status of the sensor.""" 375 | return self._state 376 | 377 | @property 378 | def extra_state_attributes(self): 379 | """Return attributes for sensor.""" 380 | return self._attributes | {"timestamp": self.last_synced} 381 | -------------------------------------------------------------------------------- /custom_components/robonect/services.yaml: -------------------------------------------------------------------------------- 1 | start: 2 | name: Start the mower 3 | description: "Start the Robonect mower." 4 | 5 | stop: 6 | name: Stop the mower 7 | description: "Stop the Robonect mower." 8 | 9 | reboot: 10 | name: Reboot the mower 11 | description: "Reboots the Robonect mower." 12 | 13 | shutdown: 14 | name: Shuts the mower down 15 | description: "Shuts the Robonect mower down." 16 | 17 | sleep: 18 | name: Set the mower to sleep 19 | description: "Sets the Robonect mower to sleep." 20 | 21 | operation_mode: 22 | name: Set the operation mode 23 | description: Set the operation mode of the mower. 24 | fields: 25 | mode: 26 | name: Operation mode 27 | description: Operation mode of the mower 28 | example: "eod" 29 | default: "auto" 30 | selector: 31 | select: 32 | options: 33 | - "man" 34 | - "auto" 35 | - "eod" 36 | - "home" 37 | 38 | job: 39 | name: Place a mowing job 40 | description: Mower performs a mowing job 41 | fields: 42 | entity_id: 43 | name: Entity ID 44 | description: Entity ID of the Robonect Lawn Mower 45 | required: true 46 | example: lawn_mower.automower_robonect 47 | selector: 48 | entity: 49 | domain: lawn_mower 50 | integration: robonect 51 | start: 52 | name: Start time 53 | description: Start time 'hh:mm', if omitted, then 'Immediately' applies. 54 | example: "10:00" 55 | selector: 56 | time: 57 | end: 58 | name: End time 59 | description: End time 'hh:mm'. 60 | example: "13:00" 61 | selector: 62 | time: 63 | duration: 64 | name: Duration 65 | description: Duration of the job in minutes. If the mower is charging, the 'clock' continues. Omitted if end is set. 66 | example: "145" 67 | selector: 68 | number: 69 | min: 1 70 | max: 10080 71 | mode: box 72 | unit_of_measurement: min 73 | after: 74 | name: After mode 75 | description: Mode that will be activated after this mowing job is done. 76 | example: "Auto" 77 | default: "Auto" 78 | selector: 79 | select: 80 | options: 81 | - "Auto" 82 | - "Home" 83 | - "End of day" 84 | corridor: 85 | name: Corridor width 86 | description: Corridor width. If omitted, it will be set to Normal 87 | example: "Normal" 88 | default: "Normal" 89 | selector: 90 | select: 91 | options: 92 | - "Normal" 93 | - "0" 94 | - "1" 95 | - "2" 96 | - "3" 97 | - "4" 98 | - "5" 99 | - "6" 100 | - "7" 101 | - "8" 102 | - "9" 103 | remotestart: 104 | name: Remote start 105 | description: The remote mowing starting point. 106 | default: "Normal" 107 | selector: 108 | select: 109 | options: 110 | - "Normal" 111 | - "From charging station" 112 | - "Remote start 1" 113 | - "Remote start 2" 114 | - "Remote start 3" 115 | - "Remote start 4" 116 | - "Remote start 5" 117 | timer: 118 | name: Modify a timer 119 | description: Modify a Robonect timer 120 | fields: 121 | entity_id: 122 | name: Entity ID 123 | description: Entity ID of the Robonect Lawn Mower 124 | required: true 125 | example: lawn_mower.automower_robonect 126 | selector: 127 | entity: 128 | domain: lawn_mower 129 | integration: robonect 130 | timer: 131 | name: Timer 132 | description: Timer ID 133 | required: true 134 | example: "1" 135 | default: "1" 136 | selector: 137 | select: 138 | options: 139 | - "1" 140 | - "2" 141 | - "3" 142 | - "4" 143 | - "5" 144 | - "6" 145 | - "7" 146 | - "8" 147 | - "9" 148 | - "10" 149 | - "11" 150 | - "12" 151 | - "13" 152 | - "14" 153 | enable: 154 | name: Enable 155 | required: true 156 | description: Timer enable 157 | selector: 158 | boolean: 159 | start: 160 | name: Start time 161 | required: true 162 | description: Start time 'hh:mm' 163 | example: "10:00" 164 | selector: 165 | time: 166 | end: 167 | name: End time 168 | required: true 169 | description: End time 'hh:mm' 170 | example: "13:00" 171 | selector: 172 | time: 173 | weekdays: 174 | name: Weekdays 175 | required: true 176 | description: Weekdays 177 | selector: 178 | select: 179 | multiple: true 180 | mode: list 181 | translation_key: weekdays 182 | options: 183 | - label: Monday 184 | value: mo 185 | - label: Tuesday 186 | value: tu 187 | - label: Wednesday 188 | value: we 189 | - label: Thursday 190 | value: th 191 | - label: Friday 192 | value: fr 193 | - label: Saturday 194 | value: sa 195 | - label: Sunday 196 | value: su 197 | 198 | ext: 199 | name: Control Equipment 200 | description: Control the GPIO or OUT channels, set modes, handle errors and inversion. 201 | fields: 202 | entity_id: 203 | name: Entity ID 204 | description: Entity ID of the Robonect Mower 205 | required: true 206 | example: lawn_mower.automower_robonect 207 | selector: 208 | entity: 209 | domain: lawn_mower 210 | integration: robonect 211 | ext: 212 | name: External Equipment 213 | description: Select the external equipment (e.g., GPIO1, GPIO2, OUT1, OUT2). 214 | required: true 215 | selector: 216 | select: 217 | options: 218 | - value: "ext0" 219 | label: "GPIO1" 220 | - value: "ext1" 221 | label: "GPIO2" 222 | - value: "ext2" 223 | label: "OUT1" 224 | - value: "ext3" 225 | label: "OUT2" 226 | gpioout: 227 | name: GPIO Channel 228 | description: Select the GPIO channel mode. Only taken into account for GPIO. 229 | required: true 230 | selector: 231 | select: 232 | options: 233 | - value: "0" 234 | label: "[IN] Analog" 235 | - value: "4" 236 | label: "[IN] Floating" 237 | - value: "40" 238 | label: "[IN] PullDown" 239 | - value: "72" 240 | label: "[IN] PullUp" 241 | - value: "20" 242 | label: "[OUT] OpenDrain" 243 | - value: "16" 244 | label: "[OUT] PushPull" 245 | gpiomode: 246 | name: GPIO/OUT Mode 247 | description: Select the mode for GPIO/OUT operation. 248 | required: true 249 | selector: 250 | select: 251 | options: 252 | - value: "0" 253 | label: "Off" 254 | - value: "1" 255 | label: "On" 256 | - value: "2" 257 | label: "Night (19-7 o'clock)" 258 | - value: "3" 259 | label: "Drive" 260 | - value: "4" 261 | label: "Night drive (19-7 o'clock)" 262 | - value: "5" 263 | label: "Searching/Way home" 264 | - value: "6" 265 | label: "Park position" 266 | - value: "7" 267 | label: "Brake light" 268 | - value: "8" 269 | label: "Left Turn Signal" 270 | - value: "9" 271 | label: "Right Turn Signal" 272 | - value: "10" 273 | label: "API" 274 | gpioerr: 275 | name: Flashs when fault 276 | description: Check this if you want to flash when fault. 277 | required: true 278 | selector: 279 | boolean: {} 280 | gpioinv: 281 | name: Signal is Low-activ 282 | description: Check this to set the signal Low-activ. 283 | required: true 284 | selector: 285 | boolean: {} 286 | 287 | direct: 288 | name: Direct the mower 289 | description: Direct a Robonect mower by setting the speed percentage for each wheel and a duration 290 | fields: 291 | entity_id: 292 | name: Entity ID 293 | description: Entity ID of the Robonect Mower 294 | required: true 295 | example: lawn_mower.automower_robonect 296 | selector: 297 | entity: 298 | domain: lawn_mower 299 | integration: robonect 300 | left: 301 | name: Left 302 | description: "The percentage of speed on the left wheel. Can be positive or negative." 303 | required: true 304 | example: 50 305 | selector: 306 | number: 307 | mode: box 308 | unit_of_measurement: "%" 309 | right: 310 | name: Right 311 | description: "The percentage of speed on the right wheel. Can be positive or negative." 312 | required: true 313 | example: 50 314 | selector: 315 | number: 316 | mode: box 317 | unit_of_measurement: "%" 318 | timeout: 319 | name: Timeout 320 | description: "The timeout/duration in milliseconds." 321 | required: true 322 | example: 3000 323 | selector: 324 | number: 325 | min: 1 326 | mode: box 327 | unit_of_measurement: ms 328 | -------------------------------------------------------------------------------- /custom_components/robonect/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for Robonect.""" 2 | 3 | import copy 4 | import logging 5 | from typing import Any 6 | 7 | from homeassistant.components import mqtt 8 | from homeassistant.components.switch import SwitchEntity 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import CONF_MONITORED_VARIABLES 11 | from homeassistant.core import HomeAssistant, callback 12 | from homeassistant.helpers.entity import EntityCategory 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.helpers.restore_state import RestoreEntity 15 | 16 | from . import RobonectDataUpdateCoordinator 17 | from .const import ( 18 | ATTRIBUTION_MQTT, 19 | ATTRIBUTION_REST, 20 | CONF_ATTRS_UNITS, 21 | CONF_MQTT_ENABLED, 22 | CONF_MQTT_TOPIC, 23 | CONF_REST_ENABLED, 24 | DOMAIN, 25 | ) 26 | from .definitions import SWITCHES, RobonectSwitchEntityDescription 27 | from .entity import RobonectCoordinatorEntity, RobonectEntity 28 | from .exceptions import RobonectException 29 | from .models import RobonectTimer 30 | from .utils import adapt_attributes, get_json_dict_path, hex2weekdays 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | 35 | async def async_setup_entry( 36 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 37 | ) -> None: 38 | """Set up an Robonect device_tracker entry.""" 39 | 40 | timer_topic = entry.data[CONF_MQTT_TOPIC] + "/mower/timer/" 41 | # Make sure MQTT integration is enabled and the client is available 42 | if entry.data[CONF_MQTT_ENABLED] is True: 43 | if not await mqtt.async_wait_for_mqtt_client(hass): 44 | _LOGGER.error("MQTT integration is not available") 45 | return 46 | 47 | if entry.data[CONF_REST_ENABLED] is False: 48 | _LOGGER.info("Ignoring the switches as REST is not enabled") 49 | return 50 | 51 | coordinator: RobonectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 52 | "coordinator" 53 | ] 54 | 55 | @callback 56 | def async_mqtt_event_received(msg: mqtt.ReceiveMessage) -> None: 57 | """Receive set latitude.""" 58 | if entry.data[CONF_MQTT_TOPIC] in hass.data[DOMAIN][entry.entry_id]["switch"]: 59 | return 60 | topic = msg.topic.replace(timer_topic, "").split("/") 61 | if topic and topic[0].startswith("ch"): 62 | if isinstance(hass.data[DOMAIN][entry.entry_id]["switch"], set): 63 | hass.data[DOMAIN][entry.entry_id]["switch"] = {} 64 | if topic[0] not in hass.data[DOMAIN][entry.entry_id]["switch"]: 65 | hass.data[DOMAIN][entry.entry_id]["switch"].update( 66 | {topic[0]: RobonectTimer(False, "", "", "", "")} 67 | ) 68 | async_add_entities( 69 | [ 70 | RobonectTimerSwitchEntity( 71 | hass, entry, coordinator, None, topic[0].replace("ch", "") 72 | ) 73 | ] 74 | ) 75 | 76 | if entry.data[CONF_MQTT_ENABLED] is True: 77 | await mqtt.async_subscribe( 78 | hass, 79 | f"{timer_topic}#", 80 | async_mqtt_event_received, 81 | 0, 82 | ) 83 | if entry.data[CONF_REST_ENABLED] is True: 84 | _LOGGER.debug("Creating REST sensors") 85 | entities: list[RobonectRestSwitch] = [] 86 | if coordinator.data is not None: 87 | for description in SWITCHES: 88 | if ( 89 | description.category == "timer" 90 | and entry.data[CONF_MQTT_ENABLED] is True 91 | ): 92 | continue 93 | if not description.rest: 94 | path = description.key 95 | else: 96 | if entry.data[CONF_MQTT_ENABLED] and description.key[0] != ".": 97 | _LOGGER.debug( 98 | f"[sensor|async_setup_entry|skipping since MQTT] {description.key}" 99 | ) 100 | continue 101 | if description.rest == "$.none": 102 | continue 103 | if description.category not in entry.data[CONF_MONITORED_VARIABLES]: 104 | continue 105 | path = description.rest 106 | _LOGGER.debug(f"[async_setup_entry|REST|adding] {path}") 107 | if description.array: 108 | array = get_json_dict_path( 109 | coordinator.data, description.rest_attrs.replace(".0", "") 110 | ) 111 | for idx, item in enumerate(array): 112 | _LOGGER.debug( 113 | f"[async_setup_entry|REST|adding] Item in array: {item}" 114 | ) 115 | desc = copy.copy(description) 116 | desc.rest = description.rest.replace(".0", f".{idx}") 117 | desc.rest_attrs = description.rest_attrs.replace( 118 | ".0", f".{idx}" 119 | ) 120 | desc.key = description.key.replace("/0", f"/{idx + 1}") 121 | entities.append( 122 | RobonectRestSwitch( 123 | hass, 124 | entry, 125 | coordinator=coordinator, 126 | description=desc, 127 | timer_id=idx, 128 | ) 129 | ) 130 | else: 131 | entities.append( 132 | RobonectRestSwitch( 133 | hass, 134 | entry, 135 | coordinator=coordinator, 136 | description=description, 137 | timer_id=1, 138 | ) 139 | ) 140 | async_add_entities(entities) 141 | 142 | 143 | class RobonectTimerSwitchEntity(RobonectEntity, SwitchEntity, RestoreEntity): 144 | """Represent a timer switch.""" 145 | 146 | _attr_has_entity_name = True 147 | _attr_attribution = ATTRIBUTION_MQTT 148 | 149 | def __init__( 150 | self, 151 | hass: HomeAssistant, 152 | entry: ConfigEntry, 153 | coordinator: RobonectDataUpdateCoordinator, 154 | description: RobonectSwitchEntityDescription, 155 | timer_id: str, 156 | ): 157 | """Set up Switch entity.""" 158 | self.coordinator = coordinator 159 | if description is None: 160 | self.entity_description = RobonectSwitchEntityDescription( 161 | key=f"timer {int(timer_id) + 1}", 162 | category="timer", 163 | translation_key=f"timer_{int(timer_id) + 1}", 164 | rest="$.timer.timer.0.enabled", 165 | icon="mdi:calendar-clock", 166 | entity_category=EntityCategory.DIAGNOSTIC, 167 | ) 168 | else: 169 | self.entity_description = description 170 | self.entry = entry 171 | self.timer_id = timer_id 172 | super().__init__(hass, entry, self.entity_description) 173 | self.category = self.entity_description.category 174 | self.entity_id = f"switch.{self.slug}" 175 | self._attributes = None 176 | self._is_on = False 177 | self._weekdays = None 178 | self._weekdays_str = None 179 | self._start = None 180 | self._end = None 181 | 182 | @property 183 | def is_on(self) -> bool: 184 | """Return true if switch is on.""" 185 | return self._is_on 186 | 187 | async def async_turn_on(self, **kwargs: Any) -> None: 188 | """Turn the switch on.""" 189 | if self.is_on is True: 190 | return 191 | await self.async_send_command( 192 | "timer", {"timer": int(self.timer_id) + 1, "enable": 1, "save": 1} 193 | ) 194 | await self.coordinator.async_refresh() 195 | 196 | async def async_turn_off(self, **kwargs: Any) -> None: 197 | """Turn the switch off.""" 198 | if self.is_on is False: 199 | return 200 | await self.async_send_command( 201 | "timer", {"timer": int(self.timer_id) + 1, "enable": 0, "save": 1} 202 | ) 203 | await self.coordinator.async_refresh() 204 | 205 | @property 206 | def extra_state_attributes(self) -> dict[str, Any]: 207 | """Return the state attributes of the device.""" 208 | return { 209 | "last_synced": self.last_synced, 210 | "category": self.category, 211 | "id": int(self.timer_id) + 1, 212 | "enabled": self._is_on, 213 | "start": self._start, 214 | "end": self._end, 215 | "weekdays": self._weekdays_str, 216 | } 217 | 218 | async def async_added_to_hass(self) -> None: 219 | """Subscribe to MQTT events and restore the previous state if available.""" 220 | await super().async_added_to_hass() 221 | 222 | # Define the MQTT topic for the timer 223 | timer_topic = ( 224 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/timer/ch{self.timer_id}" 225 | ) 226 | 227 | # Try to restore the previous state 228 | if state := await self.async_get_last_state(): 229 | # Set the state based on the previous state 230 | self._is_on = state.state == "on" 231 | 232 | # Restore attributes, handle cases where there are no attributes 233 | self._attributes = state.attributes or {} 234 | _LOGGER.debug( 235 | f"Restored state for {self.entity_id}: state.state:{state.state}, is_on:{self._is_on}" 236 | ) 237 | else: 238 | _LOGGER.debug( 239 | f"No previous state found for {self.entity_id}. Initializing with default values." 240 | ) 241 | 242 | @callback 243 | def timer_received(msg): 244 | """Handle new weekdays topic.""" 245 | topic = msg.topic.replace(timer_topic + "/", "") 246 | if topic == "enable": 247 | self._is_on = True if msg.payload == "true" else False 248 | elif topic == "weekdays": 249 | self._weekdays = msg.payload 250 | self._weekdays_str = hex2weekdays(msg.payload) 251 | elif topic == "start": 252 | self._start = msg.payload 253 | elif topic == "end": 254 | self._end = msg.payload 255 | self.update_ha_state() 256 | 257 | if self.entry.data[CONF_MQTT_ENABLED] is True and self.category == "timer": 258 | await mqtt.async_subscribe( 259 | self.hass, 260 | f"{self.entry.data[CONF_MQTT_TOPIC]}/mower/timer/ch{self.timer_id}/#", 261 | timer_received, 262 | 1, 263 | ) 264 | 265 | 266 | class RobonectRestSwitch(RobonectCoordinatorEntity, RobonectTimerSwitchEntity): 267 | """Representation of a Robonect REST switch.""" 268 | 269 | _attr_attribution = ATTRIBUTION_REST 270 | 271 | def __init__( 272 | self, 273 | hass: HomeAssistant, 274 | entry: ConfigEntry, 275 | coordinator: RobonectDataUpdateCoordinator, 276 | description: RobonectSwitchEntityDescription, 277 | timer_id: str, 278 | ) -> None: 279 | """Initialize the sensor.""" 280 | self.coordinator = coordinator 281 | super().__init__(coordinator, description) 282 | RobonectTimerSwitchEntity.__init__( 283 | self, hass, entry, coordinator, description, timer_id 284 | ) 285 | self._handle_coordinator_update() 286 | 287 | @callback 288 | def _handle_coordinator_update(self) -> None: 289 | """Handle updated data from the coordinator.""" 290 | super()._handle_coordinator_update() 291 | self.set_state() 292 | self.set_extra_attributes() 293 | 294 | def set_state(self): 295 | """Set the status of the sensor from the coordinatorsensor.""" 296 | if not self.coordinator.data: 297 | self._is_on = False 298 | return 299 | if len(self.coordinator.data) and self.category in self.coordinator.data: 300 | state = get_json_dict_path( 301 | self.coordinator.data, self.entity_description.rest 302 | ) 303 | self._is_on = state 304 | self.update_ha_state() 305 | 306 | def set_extra_attributes(self): 307 | """Set the attributes for the sensor from coordinator.""" 308 | if not self.coordinator.data: 309 | self._attr_extra_state_attributes = {} 310 | return 311 | 312 | if len(self.coordinator.data) and self.category in self.coordinator.data: 313 | attributes = { 314 | "last_synced": self.last_synced, 315 | "category": self.category, 316 | } 317 | if self.entity_description.rest_attrs: 318 | attrs = get_json_dict_path( 319 | self.coordinator.data, self.entity_description.rest_attrs 320 | ) 321 | attrs = copy.copy(attrs) 322 | if attrs: 323 | adapt_attributes( 324 | attrs, self.category, self.entry.data[CONF_ATTRS_UNITS] 325 | ) 326 | if not isinstance(attrs, list): 327 | attributes.update(attrs) 328 | self._attr_extra_state_attributes = attributes 329 | 330 | @property 331 | def extra_state_attributes(self): 332 | """Return attributes for sensor.""" 333 | self.set_extra_attributes() 334 | return self._attr_extra_state_attributes or {} 335 | 336 | async def async_turn_on(self, **kwargs: Any) -> None: 337 | """Turn the switch on.""" 338 | if self.category == "timer": 339 | await super().async_turn_on() 340 | return 341 | elif not self.is_on: 342 | attributes = self._attr_extra_state_attributes 343 | params = {} 344 | try: 345 | params.update({"ext": self.entity_description.ext}) 346 | params.update({"gpioout": 16}) 347 | params.update({"gpiomode": 1}) 348 | if attributes.get("flashonerror", False): 349 | params.update({"gpioerr": "on"}) 350 | if attributes.get("inverted", False): 351 | params.update({"gpioinv": "on"}) 352 | except ValueError as error: 353 | raise RobonectException(error) 354 | 355 | await self.async_send_command("equipment", params) 356 | await self.coordinator.async_refresh() 357 | 358 | async def async_turn_off(self, **kwargs: Any) -> None: 359 | """Turn the switch off.""" 360 | if self.category == "timer": 361 | await super().async_turn_off() 362 | return 363 | elif self.is_on: 364 | attributes = self._attr_extra_state_attributes 365 | params = {} 366 | try: 367 | params.update({"ext": self.entity_description.ext}) 368 | params.update({"gpioout": 16}) 369 | params.update({"gpiomode": 0}) 370 | if attributes.get("flashonerror", False): 371 | params.update({"gpioerr": "on"}) 372 | if attributes.get("inverted", False): 373 | params.update({"gpioinv": "on"}) 374 | except ValueError as error: 375 | raise RobonectException(error) 376 | 377 | await self.async_send_command("equipment", params) 378 | await self.coordinator.async_refresh() 379 | -------------------------------------------------------------------------------- /custom_components/robonect/utils.py: -------------------------------------------------------------------------------- 1 | """Robonect utils.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import logging 7 | import math 8 | import re 9 | 10 | from jsonpath import jsonpath 11 | import pytz 12 | 13 | from .const import ATTR_STATE_UNITS, DOMAIN, WEEKDAYS_HEX 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | _cached_timezone = None 18 | 19 | 20 | def get_cached_timezone(hass): 21 | """Get the cached timezone.""" 22 | global _cached_timezone 23 | if _cached_timezone is None: 24 | _cached_timezone = pytz.timezone(hass.config.time_zone) 25 | return _cached_timezone 26 | 27 | 28 | def str_to_float(input, entity=None) -> float: 29 | """Transform float to string.""" 30 | return float(input.replace(",", ".")) 31 | 32 | 33 | def raw_prefix(value, entity=None): 34 | """Prefix raw.""" 35 | return f"raw_{value}" 36 | 37 | 38 | def float_minutes_to_timestring(float_time, entity=None): 39 | """Transform float minutes to timestring.""" 40 | return float_to_timestring(float_time, "minutes") 41 | 42 | 43 | def float_to_timestring(float_time, unit_type="") -> str: 44 | """Transform float to timestring.""" 45 | if isinstance(float_time, str): 46 | float_time = str_to_float(float_time) 47 | if unit_type.lower() == "seconds": 48 | float_time = float_time * 60 * 60 49 | elif unit_type.lower() == "minutes": 50 | float_time = float_time * 60 51 | # log_debug(f"[float_to_timestring] Float Time {float_time}") 52 | hours, seconds = divmod(float_time, 3600) # split to hours and seconds 53 | minutes, seconds = divmod(seconds, 60) # split the seconds to minutes and seconds 54 | result = "" 55 | if hours: 56 | result += f" {hours:02.0f}" + "h" 57 | if minutes: 58 | result += f" {minutes:02.0f}" + " min" 59 | if seconds: 60 | result += f" {seconds:02.0f}" + " sec" 61 | if len(result) == 0: 62 | result = "0 sec" 63 | return result.strip() 64 | 65 | 66 | def format_entity_name(string: str) -> str: 67 | """Format entity name.""" 68 | string = string.strip() 69 | string = re.sub(r"\s+", "_", string) 70 | string = re.sub(r"\W+", "", string).lower() 71 | return string 72 | 73 | 74 | def sensor_name(string: str) -> str: 75 | """Format sensor name.""" 76 | string = string.strip().replace("_", " ").title() 77 | return string 78 | 79 | 80 | def sizeof_fmt(num, suffix="b"): 81 | """Convert unit to human readable.""" 82 | for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]: 83 | if abs(num) < 1024.0: 84 | return f"{num:3.1f}{unit}{suffix}" 85 | num /= 1024.0 86 | return f"{num:.1f}Yi{suffix}" 87 | 88 | 89 | def get_json_dict_path(dictionary, path): 90 | """Fetch info based on jsonpath from dict.""" 91 | json_dict = jsonpath(dictionary, path) 92 | if json_dict is False: 93 | return None 94 | if isinstance(json_dict, list): 95 | json_dict = json_dict[0] 96 | return json_dict 97 | 98 | 99 | def wifi_signal_to_percentage(signal_strength, entity=None): 100 | """Convert wifi signal in dBm to percentage.""" 101 | # Define the maximum and minimum WiFi signal strengths 102 | signal_strength = int(signal_strength) 103 | max_signal_strength = -60 # dBm 104 | min_signal_strength = -90 # dBm 105 | 106 | # Input validation 107 | if not isinstance(signal_strength, int | float): 108 | return 0 109 | 110 | # Convert the input signal strength to a percentage 111 | percentage = ( 112 | (signal_strength - min_signal_strength) 113 | / (max_signal_strength - min_signal_strength) 114 | * 100 115 | ) 116 | 117 | # Ensure that the percentage is within the range of 0 to 100 118 | percentage = max(0, min(percentage, 100)) 119 | 120 | # Return the percentage 121 | return round(percentage, 1) 122 | 123 | 124 | def unix_to_datetime(epoch_timestamp, hass=None): 125 | """Convert unix epoch to datetime.""" 126 | 127 | if epoch_timestamp is None or int(epoch_timestamp) == 0 or hass is None: 128 | return None 129 | # Convert epoch timestamp to datetime object in UTC 130 | datetime_utc = datetime.datetime.fromtimestamp(int(epoch_timestamp), pytz.UTC) 131 | _LOGGER.debug(f"unix_to_date: UTC {datetime_utc}") 132 | # remove timezone info, das Robonect doesn't have any 133 | datetime_none = datetime_utc.replace(tzinfo=None) 134 | # Convert UTC datetime to the desired timezone 135 | timezone = hass.data[DOMAIN].get("timezone") 136 | if timezone is None: 137 | timezone = pytz.UTC # Fallback in case timezone isn't cached properly 138 | 139 | datetime_with_timezone = timezone.localize(datetime_none, is_dst=None) 140 | _LOGGER.debug(f"unix_to_date: LOCAL {datetime_with_timezone}") 141 | return datetime_with_timezone 142 | 143 | 144 | def filter_out_units(s): 145 | """Split string by whitespace and return the first part. 146 | 147 | If the string contains whitespace, returns only the first part before the space if it is numeric. 148 | 149 | Otherwise, returns the string as is. 150 | """ 151 | 152 | parts = s.strip().split() 153 | if parts and parts[0].replace(".", "", 1).replace("-", "", 1).isdigit(): 154 | return parts[0] 155 | return s 156 | 157 | 158 | def convert_coordinate_degree_to_float(coordinate_str): 159 | """Convert coordinate value in degrees to a float.""" 160 | if "°" not in coordinate_str: 161 | return float(coordinate_str) 162 | 163 | direction = coordinate_str[-1] 164 | coordinate_str = coordinate_str[:-2] 165 | degrees, minutes = coordinate_str.split("°") 166 | decimal_degrees = float(degrees) * 60 + float(minutes) 167 | 168 | if direction in ["S", "W"]: 169 | decimal_degrees = -decimal_degrees 170 | 171 | return decimal_degrees / 60 172 | 173 | 174 | def adapt_attributes(attr_dict, category, add_units=False): 175 | """Add attribute state units.""" 176 | if not isinstance(attr_dict, dict): 177 | return 178 | for key, value in attr_dict.items(): 179 | if isinstance(value, dict): 180 | adapt_attributes(value, category, add_units) 181 | else: 182 | if category in ATTR_STATE_UNITS and key in ATTR_STATE_UNITS.get(category): 183 | unit = ATTR_STATE_UNITS.get(category).get(key) 184 | if isinstance(value, str) and has_non_numeric_characters(value, "."): 185 | _LOGGER.debug(f"Ignoring attribute update for {category} - {value}") 186 | else: 187 | if isinstance(unit, dict): 188 | lambda_func = eval(unit.get("lambda")) 189 | if add_units: 190 | if isinstance(value, str): 191 | value = str_to_float(filter_out_units(value)) 192 | attr_dict.update( 193 | {key: f"{lambda_func(value)} {unit.get('unit')}"} 194 | ) 195 | else: 196 | attr_dict.update({key: lambda_func(value)}) 197 | elif add_units: 198 | attr_dict.update({key: f"{value} {unit}"}) 199 | 200 | 201 | def has_non_numeric_characters(string, decimal_separator): 202 | """Check for non numeric characters.""" 203 | pattern = r"[^0-9" + re.escape(decimal_separator) + "]" 204 | matches = re.search(pattern, string) 205 | return bool(matches) 206 | 207 | 208 | def dummy_math(input): 209 | """Set Dummy math function.""" 210 | return math.floor(input) 211 | 212 | 213 | def hex2weekdays(hex_value): 214 | """Convert hex value to active weekdays.""" 215 | binary_value = bin(int(hex_value, 16))[ 216 | 2: 217 | ] # Convert hex to binary and remove '0b' prefix 218 | active_days = {} 219 | for bit, day in WEEKDAYS_HEX.items(): 220 | if int(binary_value, 2) & bit: 221 | active_days.update({day: True}) 222 | else: 223 | active_days.update({day: False}) 224 | return active_days 225 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "filename": "robonect.zip", 3 | "name": "Robonect", 4 | "render_readme": true, 5 | "zip_release": true 6 | } 7 | -------------------------------------------------------------------------------- /images/brand/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/brand/icon.png -------------------------------------------------------------------------------- /images/brand/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/brand/icon@2x.png -------------------------------------------------------------------------------- /images/brand/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/brand/logo.png -------------------------------------------------------------------------------- /images/brand/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/brand/logo@2x.png -------------------------------------------------------------------------------- /images/brand/original.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/brand/original.jpg -------------------------------------------------------------------------------- /images/screenshots/config_flow_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/config_flow_1.png -------------------------------------------------------------------------------- /images/screenshots/config_flow_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/config_flow_2.png -------------------------------------------------------------------------------- /images/screenshots/diagnostic_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/diagnostic_1.png -------------------------------------------------------------------------------- /images/screenshots/diagnostic_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/diagnostic_2.png -------------------------------------------------------------------------------- /images/screenshots/diagnostic_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/diagnostic_3.png -------------------------------------------------------------------------------- /images/screenshots/disable-debug-logging.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/disable-debug-logging.gif -------------------------------------------------------------------------------- /images/screenshots/enable-debug-logging.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/enable-debug-logging.gif -------------------------------------------------------------------------------- /images/screenshots/integration_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/integration_device.png -------------------------------------------------------------------------------- /images/screenshots/lovelace_card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/lovelace_card.png -------------------------------------------------------------------------------- /images/screenshots/mowing_job.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/mowing_job.png -------------------------------------------------------------------------------- /images/screenshots/options_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/options_1.png -------------------------------------------------------------------------------- /images/screenshots/options_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/options_2.png -------------------------------------------------------------------------------- /images/screenshots/options_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/options_3.png -------------------------------------------------------------------------------- /images/screenshots/options_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/options_4.png -------------------------------------------------------------------------------- /images/screenshots/options_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/options_5.png -------------------------------------------------------------------------------- /images/screenshots/options_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/options_6.png -------------------------------------------------------------------------------- /images/screenshots/options_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/options_7.png -------------------------------------------------------------------------------- /images/screenshots/rest_category.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/rest_category.png -------------------------------------------------------------------------------- /images/screenshots/timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geertmeersman/robonect/aecec9c5c24f5f8ff31263b366d2567700fa6a6f/images/screenshots/timer.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.9.0 2 | pip>=8.0.3,<25.2 3 | ruff==0.11.11 4 | -------------------------------------------------------------------------------- /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 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | addopts = 17 | --strict 18 | --cov=custom_components 19 | 20 | #[flake8] 21 | # https://github.com/ambv/black#line-length 22 | #max-line-length = 88 23 | # E501: line too long 24 | # W503: Line break occurred before a binary operator 25 | # E203: Whitespace before ':' 26 | # D202 No blank lines allowed after function docstring 27 | # W504 line break after binary operator 28 | #ignore = 29 | # E501, 30 | # W503, 31 | # E203, 32 | # D202, 33 | # W504 34 | 35 | [isort] 36 | # https://github.com/timothycrosley/isort 37 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 38 | # splits long import on multiple lines indented by 4 spaces 39 | multi_line_output = 3 40 | include_trailing_comma=True 41 | force_grid_wrap=0 42 | use_parentheses=True 43 | line_length=88 44 | indent = " " 45 | # by default isort don't check module indexes 46 | not_skip = __init__.py 47 | # will group `import x` and `from x import` of the same module. 48 | force_sort_within_sections = true 49 | sections = FUTURE,STDLIB,INBETWEENS,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 | [mypy] 56 | python_version = 3.7 57 | ignore_errors = true 58 | follow_imports = silent 59 | ignore_missing_imports = true 60 | warn_incomplete_stub = true 61 | warn_redundant_casts = true 62 | warn_unused_configs = true 63 | --------------------------------------------------------------------------------