├── .cookiecutter.json ├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .envrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── dependabot.yml ├── labels.yml ├── release-drafter.yml └── workflows │ ├── constraints.txt │ ├── labeler.yml │ ├── release-drafter.yml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── custom_components ├── __init__.py └── sunspec │ ├── __init__.py │ ├── api.py │ ├── config_flow.py │ ├── const.py │ ├── entity.py │ ├── manifest.json │ ├── sensor.py │ └── translations │ ├── en.json │ ├── pl.json │ ├── sk.json │ └── sv.json ├── hacs.json ├── logo.png ├── requirements_dev.txt ├── requirements_test.txt ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── const.py ├── test_api.py ├── test_config_flow.py ├── test_data ├── inverter.json └── inverter_secondreading.json ├── test_init.py └── test_sensor.py /.cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "_template": "gh:oncleben31/cookiecutter-homeassistant-custom-component", 3 | "class_name_prefix": "SunSpec", 4 | "domain_name": "sunspec", 5 | "friendly_name": "SunSpec", 6 | "github_user": "cjne", 7 | "project_name": "ha-sunspec", 8 | "test_suite": "yes", 9 | "version": "0.0.2" 10 | } 11 | -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.sunspec: debug 7 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 8 | # debugpy: 9 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ludeeus/container:integration-debian", 4 | "name": "SunSpec integration development", 5 | "context": "..", 6 | "appPort": ["9123:8123"], 7 | "postCreateCommand": "container install", 8 | "extensions": [ 9 | "ms-python.python", 10 | "github.vscode-pull-request-github", 11 | "ryanluker.vscode-coverage-gutters", 12 | "ms-python.vscode-pylance" 13 | ], 14 | "settings": { 15 | "files.eol": "\n", 16 | "editor.tabSize": 4, 17 | "terminal.integrated.shell.linux": "/bin/bash", 18 | "python.pythonPath": "/usr/bin/python3", 19 | "python.analysis.autoSearchPaths": false, 20 | "python.linting.pylintEnabled": true, 21 | "python.linting.enabled": true, 22 | "python.formatting.provider": "black", 23 | "editor.formatOnPaste": false, 24 | "editor.formatOnSave": true, 25 | "editor.formatOnType": true, 26 | "files.trimTrailingWhitespace": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout pyenv 3.12.2 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Describe alternatives you've considered** 13 | A clear and concise description of any alternative solutions or features you've considered. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 15 | 16 | ## Version of the custom_component 17 | 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | Add your logs here. 26 | ``` 27 | 28 | ## Describe the bug 29 | 30 | A clear and concise description of what the bug is. 31 | 32 | ## Debug log 33 | 34 | 35 | 36 | ```text 37 | 38 | Add your logs here. 39 | 40 | ``` 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: pip 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: ":boom: Breaking Changes" 3 | label: "breaking" 4 | - title: ":rocket: Features" 5 | label: "enhancement" 6 | - title: ":fire: Removals and Deprecations" 7 | label: "removal" 8 | - title: ":beetle: Fixes" 9 | label: "bug" 10 | - title: ":racehorse: Performance" 11 | label: "performance" 12 | - title: ":rotating_light: Testing" 13 | label: "testing" 14 | - title: ":construction_worker: Continuous Integration" 15 | label: "ci" 16 | - title: ":books: Documentation" 17 | label: "documentation" 18 | - title: ":hammer: Refactoring" 19 | label: "refactoring" 20 | - title: ":lipstick: Style" 21 | label: "style" 22 | - title: ":package: Dependencies" 23 | labels: 24 | - "dependencies" 25 | - "build" 26 | template: | 27 | ## Changes 28 | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==24.0 2 | pre-commit==3.6.2 3 | black==23.11.0 4 | flake8==7.0.0 5 | reorder-python-imports==3.12.0 6 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Manage labels 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | labeler: 11 | name: Labeler 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Run Labeler 18 | uses: crazy-max/ghaction-github-labeler@v5.0.0 19 | with: 20 | skip-delete: true 21 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Draft a release note 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | jobs: 8 | draft_release: 9 | name: Release Drafter 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Run release-drafter 13 | uses: release-drafter/release-drafter@v6.0.0 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | - dev 9 | pull_request: 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | env: 14 | DEFAULT_PYTHON: 3.9 15 | 16 | jobs: 17 | pre-commit: 18 | runs-on: "ubuntu-latest" 19 | name: Pre-commit 20 | steps: 21 | - name: Check out the repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 25 | uses: actions/setup-python@v5.0.0 26 | with: 27 | python-version: ${{ env.DEFAULT_PYTHON }} 28 | 29 | - name: Upgrade pip 30 | run: | 31 | pip install --constraint=.github/workflows/constraints.txt pip 32 | pip --version 33 | 34 | - name: Install Python modules 35 | run: | 36 | pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports 37 | 38 | - name: Run pre-commit on all files 39 | run: | 40 | pre-commit run --all-files --show-diff-on-failure --color=always 41 | 42 | hacs: 43 | runs-on: "ubuntu-latest" 44 | name: HACS 45 | steps: 46 | - name: Check out the repository 47 | uses: "actions/checkout@v4" 48 | 49 | - name: HACS validation 50 | uses: "hacs/action@22.5.0" 51 | with: 52 | category: "integration" 53 | ignore: brands 54 | 55 | hassfest: 56 | runs-on: "ubuntu-latest" 57 | name: Hassfest 58 | steps: 59 | - name: Check out the repository 60 | uses: "actions/checkout@v4" 61 | 62 | - name: Hassfest validation 63 | uses: "home-assistant/actions/hassfest@master" 64 | tests: 65 | runs-on: "ubuntu-latest" 66 | name: Run tests 67 | steps: 68 | - name: Check out code from GitHub 69 | uses: "actions/checkout@v4" 70 | - name: Setup Python ${{ env.DEFAULT_PYTHON }} 71 | uses: "actions/setup-python@v5.0.0" 72 | with: 73 | python-version: ${{ env.DEFAULT_PYTHON }} 74 | - name: Install requirements 75 | run: | 76 | pip install --constraint=.github/workflows/constraints.txt pip 77 | pip install -r requirements_test.txt 78 | - name: Tests suite 79 | run: | 80 | pytest \ 81 | --timeout=9 \ 82 | --durations=10 \ 83 | -n auto \ 84 | -p no:sugar \ 85 | tests 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | .python-version 4 | .coverage 5 | venv 6 | .venv 7 | .direnv 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.3.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: local 10 | hooks: 11 | - id: black 12 | name: black 13 | entry: black 14 | language: system 15 | types: [python] 16 | require_serial: true 17 | - id: flake8 18 | name: flake8 19 | entry: flake8 20 | language: system 21 | types: [python] 22 | require_serial: true 23 | - repo: https://github.com/pycqa/isort 24 | rev: 5.13.2 25 | hooks: 26 | - id: isort 27 | name: isort 28 | args: [--force-single-line, --profile=black] 29 | 30 | - repo: https://github.com/pre-commit/mirrors-prettier 31 | rev: v2.2.1 32 | hooks: 33 | - id: prettier 34 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "venv/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People _love_ thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) 48 | to make sure the code follows the style. 49 | 50 | Or use the `pre-commit` settings implemented in this repository 51 | (see deicated section below). 52 | 53 | ## Test your code modification 54 | 55 | This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 56 | 57 | It comes with development environment in a container, easy to launch 58 | if you use Visual Studio Code. With this container you will have a stand alone 59 | Home Assistant instance running and already configured with the included 60 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 61 | file. 62 | 63 | You can use the `pre-commit` settings implemented in this repository to have 64 | linting tool checking your contributions (see deicated section below). 65 | 66 | You should also verify that existing [tests](./tests) are still working 67 | and you are encouraged to add new ones. 68 | You can run the tests using the following commands from the root folder: 69 | 70 | ```bash 71 | # Create a virtual environment 72 | python3 -m venv venv 73 | source venv/bin/activate 74 | # Install requirements 75 | pip install -r requirements_test.txt 76 | # Run tests and get a summary of successes/failures and code coverage 77 | pytest --durations=10 --cov-report term-missing --cov=custom_components.sunspec tests 78 | ``` 79 | 80 | If any of the tests fail, make the necessary changes to the tests as part of 81 | your changes to the integration. 82 | 83 | ## Pre-commit 84 | 85 | You can use the [pre-commit](https://pre-commit.com/) settings included in the 86 | repostory to have code style and linting checks. 87 | 88 | With `pre-commit` tool already installed, 89 | activate the settings of the repository: 90 | 91 | ```console 92 | $ pre-commit install 93 | ``` 94 | 95 | Now the pre-commit tests will be done every time you commit. 96 | 97 | You can run the tests on all repository file with the command: 98 | 99 | ```console 100 | $ pre-commit run --all-files 101 | ``` 102 | 103 | ## License 104 | 105 | By contributing, you agree that your contributions will be licensed under its MIT License. 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 cjne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SunSpec 2 | 3 | This custom component for [home assistant](https://home-assistant.io/) will let you monitor any SunSpec Modbus compliant device, most commonly a solar inverter or energy meter. A list of compliant devices and manufacturers can be found on the [sunspec website](https://sunspec.org/sunspec-modbus-certified-products/). 4 | 5 | It will auto discover and create sensors depending on the available data of the device. 6 | By default only the most common sensors are created, there is an optional configuration that lets you control exactly what data to use. 7 | 8 | Currenlty supports Modbus TCP connections. Modbus serial connection is planned. 9 | 10 | Works out of the box with the energy dashboard. 11 | 12 | [![GitHub Release][releases-shield]][releases] 13 | [![GitHub Activity][commits-shield]][commits] 14 | [![License][license-shield]](LICENSE) 15 | 16 | [![pre-commit][pre-commit-shield]][pre-commit] 17 | [![Black][black-shield]][black] 18 | 19 | [![hacs][hacsbadge]][hacs] 20 | [![Project Maintenance][maintenance-shield]][user_profile] 21 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 22 | 23 | [![Discord][discord-shield]][discord] 24 | [![Community Forum][forum-shield]][forum] 25 | 26 | **This component will set up the following platforms.** 27 | 28 | | Platform | Description | 29 | | -------- | --------------------------- | 30 | | `sensor` | Show info from SunSpec API. | 31 | 32 | ![logo][logoimg] 33 | 34 | ## HACS Installation 35 | 36 | 1. Add and search for sunspec in [HACS](https://hacs.xyz/) 37 | 2. Install 38 | 39 | ## Manual Installation 40 | 41 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 42 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 43 | 3. In the `custom_components` directory (folder) create a new folder called `sunspec`. 44 | 4. Download _all_ the files from the `custom_components/sunspec/` directory (folder) in this repository. 45 | 5. Place the files you downloaded in the new directory (folder) you created. 46 | 6. Restart Home Assistant 47 | 7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "SunSpec" 48 | 49 | Using your HA configuration directory (folder) as a starting point you should now also have this: 50 | 51 | ```text 52 | custom_components/sunspec/translations/en.json 53 | custom_components/sunspec/__init__.py 54 | custom_components/sunspec/api.py 55 | custom_components/sunspec/config_flow.py 56 | custom_components/sunspec/const.py 57 | custom_components/sunspec/entity.py 58 | custom_components/sunspec/manifest.json 59 | custom_components/sunspec/sensor.py 60 | ``` 61 | 62 | ## Configuration is done in the UI 63 | 64 | 65 | 66 | ## Contributions are welcome! 67 | 68 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 69 | 70 | ## Credits 71 | 72 | This project was generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter](https://github.com/oncleben31/cookiecutter-homeassistant-custom-component) template. 73 | 74 | Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template 75 | 76 | --- 77 | 78 | [integration_blueprint]: https://github.com/custom-components/integration_blueprint 79 | [black]: https://github.com/psf/black 80 | [black-shield]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 81 | [buymecoffee]: https://www.buymeacoffee.com/cjne.coffee 82 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 83 | [commits-shield]: https://img.shields.io/github/commit-activity/y/cjne/ha-sunspec.svg?style=for-the-badge 84 | [commits]: https://github.com/cjne/ha-sunspec/commits/main 85 | [hacs]: https://hacs.xyz 86 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge 87 | [discord]: https://discord.gg/Qa5fW2R 88 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 89 | [logoimg]: logo.png 90 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 91 | [forum]: https://community.home-assistant.io/ 92 | [license-shield]: https://img.shields.io/github/license/cjne/ha-sunspec.svg?style=for-the-badge 93 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40cjne-blue.svg?style=for-the-badge 94 | [pre-commit]: https://github.com/pre-commit/pre-commit 95 | [pre-commit-shield]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=for-the-badge 96 | [releases-shield]: https://img.shields.io/github/release/cjne/ha-sunspec.svg?style=for-the-badge 97 | [releases]: https://github.com/cjne/ha-sunspec/releases 98 | [user_profile]: https://github.com/cjne 99 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy init so that pytest works.""" 2 | -------------------------------------------------------------------------------- /custom_components/sunspec/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate SunSpec with Home Assistant. 3 | 4 | For more details about this integration, please refer to 5 | https://github.com/cjne/ha-sunspec 6 | """ 7 | 8 | import asyncio 9 | from datetime import timedelta 10 | import logging 11 | 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import Config 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 16 | from homeassistant.helpers.update_coordinator import UpdateFailed 17 | 18 | from .api import SunSpecApiClient 19 | from .const import CONF_ENABLED_MODELS 20 | from .const import CONF_HOST 21 | from .const import CONF_PORT 22 | from .const import CONF_SCAN_INTERVAL 23 | from .const import CONF_SLAVE_ID 24 | from .const import DEFAULT_MODELS 25 | from .const import DOMAIN 26 | from .const import PLATFORMS 27 | from .const import STARTUP_MESSAGE 28 | 29 | SCAN_INTERVAL = timedelta(seconds=30) 30 | 31 | _LOGGER: logging.Logger = logging.getLogger(__package__) 32 | 33 | 34 | async def async_setup(hass: HomeAssistant, config: Config): 35 | """Set up this integration using YAML is not supported.""" 36 | return True 37 | 38 | 39 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 40 | """Set up this integration using UI.""" 41 | if hass.data.get(DOMAIN) is None: 42 | hass.data.setdefault(DOMAIN, {}) 43 | _LOGGER.info(STARTUP_MESSAGE) 44 | 45 | host = entry.data.get(CONF_HOST) 46 | port = entry.data.get(CONF_PORT) 47 | slave_id = entry.data.get(CONF_SLAVE_ID, 1) 48 | 49 | client = SunSpecApiClient(host, port, slave_id, hass) 50 | 51 | _LOGGER.debug("Setup conifg entry for SunSpec") 52 | coordinator = SunSpecDataUpdateCoordinator(hass, client=client, entry=entry) 53 | hass.data[DOMAIN][entry.entry_id] = coordinator 54 | 55 | await coordinator.async_config_entry_first_refresh() 56 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 57 | return True 58 | 59 | 60 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 61 | """Handle removal of an entry.""" 62 | 63 | _LOGGER.debug("Unload entry") 64 | unloaded = all( 65 | await asyncio.gather( 66 | *[ 67 | hass.config_entries.async_forward_entry_unload(entry, platform) 68 | for platform in PLATFORMS 69 | ] 70 | ) 71 | ) 72 | if unloaded: 73 | coordinator = hass.data[DOMAIN].pop(entry.entry_id) 74 | coordinator.unsub() 75 | 76 | return True # unloaded 77 | 78 | 79 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 80 | """Reload config entry.""" 81 | await async_unload_entry(hass, entry) 82 | await async_setup_entry(hass, entry) 83 | 84 | 85 | def get_sunspec_unique_id( 86 | config_entry_id: str, key: str, model_id: int, model_index: int 87 | ) -> str: 88 | """Create a uniqe id for a SunSpec entity""" 89 | return f"{config_entry_id}_{key}-{model_id}-{model_index}" 90 | 91 | 92 | class SunSpecDataUpdateCoordinator(DataUpdateCoordinator): 93 | """Class to manage fetching data from the API.""" 94 | 95 | def __init__(self, hass: HomeAssistant, client: SunSpecApiClient, entry) -> None: 96 | """Initialize.""" 97 | self.api = client 98 | self.hass = hass 99 | self.entry = entry 100 | 101 | _LOGGER.debug("Data: %s", entry.data) 102 | _LOGGER.debug("Options: %s", entry.options) 103 | models = entry.options.get( 104 | CONF_ENABLED_MODELS, entry.data.get(CONF_ENABLED_MODELS, DEFAULT_MODELS) 105 | ) 106 | scan_interval = timedelta( 107 | seconds=entry.options.get( 108 | CONF_SCAN_INTERVAL, 109 | entry.data.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL.total_seconds()), 110 | ) 111 | ) 112 | self.option_model_filter = set(map(lambda m: int(m), models)) 113 | self.unsub = entry.add_update_listener(async_reload_entry) 114 | _LOGGER.debug( 115 | "Setup entry with models %s, scan interval %s. IP: %s Port: %s ID: %s", 116 | self.option_model_filter, 117 | scan_interval, 118 | entry.data.get(CONF_HOST), 119 | entry.data.get(CONF_PORT), 120 | entry.data.get(CONF_SLAVE_ID), 121 | ) 122 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=scan_interval) 123 | 124 | async def _async_update_data(self): 125 | """Update data via library.""" 126 | _LOGGER.debug("SunSpec Update data coordinator update") 127 | data = {} 128 | try: 129 | model_ids = self.option_model_filter & set( 130 | await self.api.async_get_models() 131 | ) 132 | _LOGGER.debug("SunSpec Update data got models %s", model_ids) 133 | 134 | for model_id in model_ids: 135 | data[model_id] = await self.api.async_get_data(model_id) 136 | self.api.close() 137 | return data 138 | except Exception as exception: 139 | _LOGGER.warning(exception) 140 | self.api.reconnect_next() 141 | raise UpdateFailed() from exception 142 | -------------------------------------------------------------------------------- /custom_components/sunspec/api.py: -------------------------------------------------------------------------------- 1 | """Sample API Client.""" 2 | 3 | import logging 4 | import socket 5 | import threading 6 | import time 7 | from types import SimpleNamespace 8 | 9 | from homeassistant.core import HomeAssistant 10 | import sunspec2.modbus.client as modbus_client 11 | from sunspec2.modbus.client import SunSpecModbusClientException 12 | from sunspec2.modbus.client import SunSpecModbusClientTimeout 13 | from sunspec2.modbus.modbus import ModbusClientError 14 | 15 | TIMEOUT = 120 16 | 17 | _LOGGER: logging.Logger = logging.getLogger(__package__) 18 | 19 | 20 | class ConnectionTimeoutError(Exception): 21 | pass 22 | 23 | 24 | class ConnectionError(Exception): 25 | pass 26 | 27 | 28 | class SunSpecModelWrapper: 29 | def __init__(self, models) -> None: 30 | """Sunspec model wrapper""" 31 | self._models = models 32 | self.num_models = len(models) 33 | 34 | def isValidPoint(self, point_name): 35 | point = self.getPoint(point_name) 36 | if point.value is None: 37 | return False 38 | if point.pdef["type"] in ("enum16", "bitfield32"): 39 | return True 40 | if point.pdef.get("units", None) is None: 41 | return False 42 | return True 43 | 44 | def getKeys(self): 45 | keys = list(filter(self.isValidPoint, self._models[0].points.keys())) 46 | for group_name in self._models[0].groups: 47 | model_group = self._models[0].groups[group_name] 48 | if type(model_group) is list: 49 | for idx, group in enumerate(model_group): 50 | key_prefix = f"{group_name}:{idx}" 51 | group_keys = map( 52 | lambda gp: f"{key_prefix}:{gp}", group.points.keys() 53 | ) 54 | keys.extend(filter(self.isValidPoint, group_keys)) 55 | else: 56 | key_prefix = f"{group_name}:0" 57 | group_keys = map( 58 | lambda gp: f"{key_prefix}:{gp}", model_group.points.keys() 59 | ) 60 | keys.extend(filter(self.isValidPoint, group_keys)) 61 | return keys 62 | 63 | def getValue(self, point_name, model_index=0): 64 | point = self.getPoint(point_name, model_index) 65 | return point.cvalue 66 | 67 | def getMeta(self, point_name): 68 | return self.getPoint(point_name).pdef 69 | 70 | def getGroupMeta(self): 71 | return self._models[0].gdef 72 | 73 | def getPoint(self, point_name, model_index=0): 74 | point_path = point_name.split(":") 75 | if len(point_path) == 1: 76 | return self._models[model_index].points[point_name] 77 | 78 | group = self._models[model_index].groups[point_path[0]] 79 | if type(group) is list: 80 | return group[int(point_path[1])].points[point_path[2]] 81 | else: 82 | if len(point_path) > 2: 83 | return group.points[ 84 | point_path[2] 85 | ] # Access to the specific point within the group 86 | return group.points[ 87 | point_name 88 | ] # Generic access if no specific subgrouping is specified 89 | 90 | 91 | # pragma: not covered 92 | def progress(msg): 93 | _LOGGER.debug(msg) 94 | return True 95 | 96 | 97 | class SunSpecApiClient: 98 | CLIENT_CACHE = {} 99 | 100 | def __init__( 101 | self, host: str, port: int, slave_id: int, hass: HomeAssistant 102 | ) -> None: 103 | """Sunspec modbus client.""" 104 | 105 | _LOGGER.debug("New SunspecApi Client") 106 | self._host = host 107 | self._port = port 108 | self._hass = hass 109 | self._slave_id = slave_id 110 | self._client_key = f"{host}:{port}:{slave_id}" 111 | self._lock = threading.Lock() 112 | self._reconnect = False 113 | 114 | def get_client(self, config=None): 115 | cached = SunSpecApiClient.CLIENT_CACHE.get(self._client_key, None) 116 | if cached is None or config is not None: 117 | _LOGGER.debug("Not using cached connection") 118 | cached = self.modbus_connect(config) 119 | SunSpecApiClient.CLIENT_CACHE[self._client_key] = cached 120 | if self._reconnect: 121 | if self.check_port(): 122 | cached.connect() 123 | self._reconnect = False 124 | return cached 125 | 126 | def async_get_client(self, config=None): 127 | return self._hass.async_add_executor_job(self.get_client, config) 128 | 129 | async def async_get_data(self, model_id) -> SunSpecModelWrapper: 130 | try: 131 | _LOGGER.debug("Get data for model %s", model_id) 132 | return await self.read(model_id) 133 | except SunSpecModbusClientTimeout as timeout_error: 134 | _LOGGER.warning("Async get data timeout") 135 | raise ConnectionTimeoutError() from timeout_error 136 | except SunSpecModbusClientException as connect_error: 137 | _LOGGER.warning("Async get data connect_error") 138 | raise ConnectionError() from connect_error 139 | 140 | async def read(self, model_id) -> SunSpecModelWrapper: 141 | return await self._hass.async_add_executor_job(self.read_model, model_id) 142 | 143 | async def async_get_device_info(self) -> SunSpecModelWrapper: 144 | return await self.read(1) 145 | 146 | async def async_get_models(self, config=None) -> list: 147 | _LOGGER.debug("Fetching models") 148 | client = await self.async_get_client(config) 149 | model_ids = sorted(list(filter(lambda m: type(m) is int, client.models.keys()))) 150 | return model_ids 151 | 152 | def reconnect_next(self): 153 | self._reconnect = True 154 | 155 | def close(self): 156 | client = self.get_client() 157 | client.close() 158 | 159 | def check_port(self) -> bool: 160 | """Check if port is available""" 161 | with self._lock: 162 | sock_timeout = float(3) 163 | _LOGGER.debug( 164 | f"Check_Port: opening socket on {self._host}:{self._port} with a {sock_timeout}s timeout." 165 | ) 166 | socket.setdefaulttimeout(sock_timeout) 167 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 168 | sock_res = sock.connect_ex((self._host, self._port)) 169 | is_open = sock_res == 0 # True if open, False if not 170 | if is_open: 171 | sock.shutdown(socket.SHUT_RDWR) 172 | _LOGGER.debug( 173 | f"Check_Port (SUCCESS): port open on {self._host}:{self._port}" 174 | ) 175 | else: 176 | _LOGGER.debug( 177 | f"Check_Port (ERROR): port not available on {self._host}:{self._port} - error: {sock_res}" 178 | ) 179 | sock.close() 180 | return is_open 181 | 182 | def modbus_connect(self, config=None): 183 | use_config = SimpleNamespace( 184 | **( 185 | config 186 | or {"host": self._host, "port": self._port, "slave_id": self._slave_id} 187 | ) 188 | ) 189 | _LOGGER.debug( 190 | f"Client connect to IP {use_config.host} port {use_config.port} slave id {use_config.slave_id} using timeout {TIMEOUT}" 191 | ) 192 | client = modbus_client.SunSpecModbusClientDeviceTCP( 193 | slave_id=use_config.slave_id, 194 | ipaddr=use_config.host, 195 | ipport=use_config.port, 196 | timeout=TIMEOUT, 197 | ) 198 | if self.check_port(): 199 | _LOGGER.debug("Inverter ready for Modbus TCP connection") 200 | try: 201 | with self._lock: 202 | client.connect() 203 | if not client.is_connected(): 204 | raise ConnectionError( 205 | f"Failed to connect to {self._host}:{self._port} slave id {self._slave_id}" 206 | ) 207 | _LOGGER.debug("Client connected, perform initial scan") 208 | client.scan( 209 | connect=False, progress=progress, full_model_read=False, delay=0.5 210 | ) 211 | return client 212 | except ModbusClientError: 213 | raise ConnectionError( 214 | f"Failed to connect to {use_config.host}:{use_config.port} slave id {use_config.slave_id}" 215 | ) 216 | else: 217 | _LOGGER.debug("Inverter not ready for Modbus TCP connection") 218 | raise ConnectionError(f"Inverter not active on {self._host}:{self._port}") 219 | 220 | def read_model(self, model_id) -> dict: 221 | client = self.get_client() 222 | models = client.models[model_id] 223 | for model in models: 224 | time.sleep(0.6) 225 | model.read() 226 | 227 | return SunSpecModelWrapper(models) 228 | -------------------------------------------------------------------------------- /custom_components/sunspec/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for SunSpec.""" 2 | 3 | import logging 4 | 5 | from homeassistant import config_entries 6 | from homeassistant.core import callback 7 | import homeassistant.helpers.config_validation as cv 8 | import voluptuous as vol 9 | 10 | from . import SCAN_INTERVAL 11 | from .api import SunSpecApiClient 12 | from .const import CONF_ENABLED_MODELS 13 | from .const import CONF_HOST 14 | from .const import CONF_PORT 15 | from .const import CONF_PREFIX 16 | from .const import CONF_SCAN_INTERVAL 17 | from .const import CONF_SLAVE_ID 18 | from .const import DEFAULT_MODELS 19 | from .const import DOMAIN 20 | 21 | _LOGGER: logging.Logger = logging.getLogger(__package__) 22 | 23 | 24 | class SunSpecFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 25 | """Config flow for sunspec.""" 26 | 27 | VERSION = 1 28 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 29 | 30 | def __init__(self): 31 | """Initialize.""" 32 | self._errors = {} 33 | 34 | async def async_step_user(self, user_input=None): 35 | """Handle a flow initialized by the user.""" 36 | self._errors = {} 37 | if user_input is not None: 38 | host = user_input[CONF_HOST] 39 | port = user_input[CONF_PORT] 40 | slave_id = user_input[CONF_SLAVE_ID] 41 | valid = await self._test_connection(host, port, slave_id) 42 | if valid: 43 | uid = self._device_info.getValue("SN") 44 | _LOGGER.debug(f"Sunspec device unique id: {uid}") 45 | await self.async_set_unique_id(uid) 46 | 47 | self._abort_if_unique_id_configured( 48 | updates={CONF_HOST: host, CONF_PORT: port, CONF_SLAVE_ID: slave_id} 49 | ) 50 | self.init_info = user_input 51 | return await self.async_step_settings() 52 | 53 | # return self.async_create_entry(title=f"{host}:{port}", data=user_input) 54 | 55 | self._errors["base"] = "connection" 56 | 57 | return await self._show_config_form(user_input) 58 | 59 | return await self._show_config_form(user_input) 60 | 61 | async def async_step_settings(self, user_input=None): 62 | self._errors = {} 63 | if user_input is not None: 64 | self.init_info[CONF_PREFIX] = user_input[CONF_PREFIX] 65 | self.init_info[CONF_ENABLED_MODELS] = user_input[CONF_ENABLED_MODELS] 66 | self.init_info[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL] 67 | host = self.init_info[CONF_HOST] 68 | port = self.init_info[CONF_PORT] 69 | slave_id = self.init_info[CONF_SLAVE_ID] 70 | _LOGGER.debug("Creating entry with data %s", self.init_info) 71 | return self.async_create_entry( 72 | title=f"{host}:{port}:{slave_id}", data=self.init_info 73 | ) 74 | 75 | return await self._show_settings_form(user_input) 76 | 77 | @staticmethod 78 | @callback 79 | def async_get_options_flow(config_entry): 80 | return SunSpecOptionsFlowHandler(config_entry) 81 | 82 | async def _show_config_form(self, user_input): # pylint: disable=unused-argument 83 | """Show the configuration form to edit connection data.""" 84 | defaults = user_input or {CONF_HOST: "", CONF_PORT: 502, CONF_SLAVE_ID: 1} 85 | return self.async_show_form( 86 | step_id="user", 87 | data_schema=vol.Schema( 88 | { 89 | vol.Required(CONF_HOST, default=defaults[CONF_HOST]): str, 90 | vol.Required(CONF_PORT, default=defaults[CONF_PORT]): int, 91 | vol.Required(CONF_SLAVE_ID, default=defaults[CONF_SLAVE_ID]): int, 92 | } 93 | ), 94 | errors=self._errors, 95 | ) 96 | 97 | async def _show_settings_form(self, user_input): # pylint: disable=unused-argument 98 | """Show the configuration form to edit settings data.""" 99 | models = set(await self.client.async_get_models()) 100 | model_filter = {model for model in sorted(models)} 101 | default_enabled = {model for model in DEFAULT_MODELS if model in models} 102 | return self.async_show_form( 103 | step_id="settings", 104 | data_schema=vol.Schema( 105 | { 106 | vol.Optional(CONF_PREFIX, default=""): str, 107 | vol.Optional( 108 | CONF_SCAN_INTERVAL, default=SCAN_INTERVAL.total_seconds() 109 | ): int, 110 | vol.Optional( 111 | CONF_ENABLED_MODELS, 112 | default=default_enabled, 113 | ): cv.multi_select(model_filter), 114 | } 115 | ), 116 | errors=self._errors, 117 | ) 118 | 119 | async def _test_connection(self, host, port, slave_id): 120 | """Return true if credentials is valid.""" 121 | _LOGGER.debug(f"Test connection to {host}:{port} slave id {slave_id}") 122 | try: 123 | self.client = SunSpecApiClient(host, port, slave_id, self.hass) 124 | self._device_info = await self.client.async_get_device_info() 125 | _LOGGER.info(self._device_info) 126 | return True 127 | except Exception as e: # pylint: disable=broad-except 128 | _LOGGER.error( 129 | "Failed to connect to host %s:%s slave %s - %s", host, port, slave_id, e 130 | ) 131 | pass 132 | return False 133 | 134 | 135 | class SunSpecOptionsFlowHandler(config_entries.OptionsFlow): 136 | """Config flow options handler for sunspec.""" 137 | 138 | VERSION = 1 139 | 140 | def __init__(self, config_entry): 141 | """Initialize HACS options flow.""" 142 | self.config_entry = config_entry 143 | self.settings = {} 144 | self.options = dict(config_entry.options) 145 | self.coordinator = None 146 | 147 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 148 | """Manage the options.""" 149 | self.coordinator = self.hass.data[DOMAIN][self.config_entry.entry_id] 150 | return await self.async_step_host_options() 151 | 152 | async def async_step_host_options(self, user_input=None): 153 | """Handle a flow initialized by the user.""" 154 | if user_input is not None: 155 | self.settings.update(user_input) 156 | _LOGGER.debug("Sunspec host setttins: %s", user_input) 157 | return await self.async_step_model_options() 158 | 159 | return await self.show_settings_form() 160 | 161 | async def show_settings_form(self, data=None, errors=None): 162 | settings = data or self.config_entry.data 163 | host = settings.get(CONF_HOST) 164 | port = settings.get(CONF_PORT) 165 | slave_id = settings.get(CONF_SLAVE_ID) 166 | 167 | return self.async_show_form( 168 | step_id="host_options", 169 | data_schema=vol.Schema( 170 | { 171 | vol.Required(CONF_HOST, default=host): str, 172 | vol.Required(CONF_PORT, default=port): int, 173 | vol.Required(CONF_SLAVE_ID, default=slave_id): int, 174 | } 175 | ), 176 | errors=errors, 177 | ) 178 | 179 | async def async_step_model_options(self, user_input=None): 180 | """Handle a flow initialized by the user.""" 181 | if user_input is not None: 182 | self.options.update(user_input) 183 | return await self._update_options() 184 | 185 | prefix = self.config_entry.options.get( 186 | CONF_PREFIX, self.config_entry.data.get(CONF_PREFIX) 187 | ) 188 | scan_interval = self.config_entry.options.get( 189 | CONF_SCAN_INTERVAL, self.config_entry.data.get(CONF_SCAN_INTERVAL) 190 | ) 191 | try: 192 | models = set(await self.coordinator.api.async_get_models(self.settings)) 193 | model_filter = {model for model in sorted(models)} 194 | default_enabled = {model for model in DEFAULT_MODELS if model in models} 195 | default_models = self.config_entry.options.get( 196 | CONF_ENABLED_MODELS, default_enabled 197 | ) 198 | 199 | default_models = {model for model in default_models if model in models} 200 | 201 | return self.async_show_form( 202 | step_id="model_options", 203 | data_schema=vol.Schema( 204 | { 205 | vol.Optional(CONF_PREFIX, default=prefix): str, 206 | vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): int, 207 | vol.Optional( 208 | CONF_ENABLED_MODELS, 209 | default=default_models, 210 | ): cv.multi_select(model_filter), 211 | } 212 | ), 213 | ) 214 | except Exception as e: # pylint: disable=broad-except 215 | _LOGGER.error( 216 | "Failed to connect to host %s:%s slave %s - %s", 217 | self.settings[CONF_HOST], 218 | self.settings[CONF_PORT], 219 | self.settings[CONF_SLAVE_ID], 220 | e, 221 | ) 222 | return await self.show_settings_form( 223 | data=self.settings, errors={"base": "connection"} 224 | ) 225 | 226 | async def _update_options(self): 227 | """Update config entry options.""" 228 | # self.settings[CONF_PORT] = 503 229 | # self.settings[CONF_ENABLED_MODELS] = [160, 103] 230 | title = f"{self.settings[CONF_HOST]}:{self.settings[CONF_PORT]}:{self.settings[CONF_SLAVE_ID]}" 231 | _LOGGER.debug( 232 | "Saving config entry with title %s, data: %s options %s", 233 | title, 234 | self.settings, 235 | self.options, 236 | ) 237 | self.hass.config_entries.async_update_entry( 238 | self.config_entry, data=self.settings, title=title 239 | ) 240 | return self.async_create_entry(title="", data=self.options) 241 | -------------------------------------------------------------------------------- /custom_components/sunspec/const.py: -------------------------------------------------------------------------------- 1 | """Constants for SunSpec.""" 2 | 3 | # Base component constants 4 | NAME = "SunSpec" 5 | DOMAIN = "sunspec" 6 | DOMAIN_DATA = f"{DOMAIN}_data" 7 | VERSION = "0.0.26" 8 | 9 | ATTRIBUTION = "Data provided by SunSpec alliance - https://sunspec.org" 10 | ISSUE_URL = "https://github.com/cjne/ha-sunspec/issues" 11 | 12 | # Icons 13 | ICON = "mdi:format-quote-close" 14 | 15 | # Device classes 16 | BINARY_SENSOR_DEVICE_CLASS = "connectivity" 17 | 18 | # Platforms 19 | SENSOR = "sensor" 20 | PLATFORMS = [SENSOR] 21 | 22 | 23 | # Configuration and options 24 | CONF_ENABLED = "enabled" 25 | CONF_HOST = "host" 26 | CONF_PORT = "port" 27 | CONF_SLAVE_ID = "slave_id" 28 | CONF_PREFIX = "prefix" 29 | CONF_SCAN_INTERVAL = "scan_interval" 30 | CONF_ENABLED_MODELS = "models_enabled" 31 | 32 | DEFAULT_MODELS = set( 33 | [ 34 | 101, 35 | 102, 36 | 103, 37 | 160, 38 | 201, 39 | 202, 40 | 203, 41 | 204, 42 | 307, 43 | 308, 44 | 401, 45 | 402, 46 | 403, 47 | 404, 48 | 501, 49 | 502, 50 | 601, 51 | 701, 52 | 801, 53 | 802, 54 | 803, 55 | 804, 56 | 805, 57 | 806, 58 | 808, 59 | 809, 60 | ] 61 | ) 62 | # Defaults 63 | DEFAULT_NAME = DOMAIN 64 | 65 | STARTUP_MESSAGE = f""" 66 | ------------------------------------------------------------------- 67 | {NAME} 68 | Version: {VERSION} 69 | This is a custom integration! 70 | If you have any issues with this you need to open an issue here: 71 | {ISSUE_URL} 72 | ------------------------------------------------------------------- 73 | """ 74 | -------------------------------------------------------------------------------- /custom_components/sunspec/entity.py: -------------------------------------------------------------------------------- 1 | """SunSpecEntity class""" 2 | 3 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 4 | 5 | from .const import DOMAIN 6 | 7 | 8 | class SunSpecEntity(CoordinatorEntity): 9 | def __init__(self, coordinator, config_entry, device_info, model_info): 10 | super().__init__(coordinator) 11 | self._device_data = device_info 12 | self.config_entry = config_entry 13 | self.model_info = model_info 14 | 15 | # @property 16 | # def unique_id(self): 17 | # """Return a unique ID to use for this entity.""" 18 | # return self.config_entry.entry_id 19 | 20 | @property 21 | def device_info(self): 22 | return { 23 | "identifiers": { 24 | (DOMAIN, self.config_entry.entry_id, self.model_info["name"]) 25 | }, 26 | "name": self.model_info["label"], 27 | "model": self._device_data.getValue("Md"), 28 | "sw_version": self._device_data.getValue("Vr"), 29 | "manufacturer": self._device_data.getValue("Mn"), 30 | } 31 | -------------------------------------------------------------------------------- /custom_components/sunspec/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "sunspec", 3 | "name": "SunSpec", 4 | "codeowners": ["@cjne"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/cjne/ha-sunspec", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/cjne/ha-sunspec/issues", 10 | "requirements": ["pysunspec2==1.1.5"], 11 | "version": "0.0.26" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/sunspec/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for SunSpec.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.sensor import RestoreSensor 6 | from homeassistant.components.sensor import SensorDeviceClass 7 | from homeassistant.components.sensor import SensorEntity 8 | from homeassistant.components.sensor import SensorStateClass 9 | from homeassistant.const import DEGREE 10 | from homeassistant.const import PERCENTAGE 11 | from homeassistant.const import UnitOfApparentPower 12 | from homeassistant.const import UnitOfDataRate 13 | from homeassistant.const import UnitOfElectricCurrent 14 | from homeassistant.const import UnitOfElectricPotential 15 | from homeassistant.const import UnitOfEnergy 16 | from homeassistant.const import UnitOfFrequency 17 | from homeassistant.const import UnitOfIrradiance 18 | from homeassistant.const import UnitOfLength 19 | from homeassistant.const import UnitOfPower 20 | from homeassistant.const import UnitOfPressure 21 | from homeassistant.const import UnitOfReactivePower 22 | from homeassistant.const import UnitOfSpeed 23 | from homeassistant.const import UnitOfTemperature 24 | from homeassistant.const import UnitOfTime 25 | 26 | from . import get_sunspec_unique_id 27 | from .const import CONF_PREFIX 28 | from .const import DOMAIN 29 | from .entity import SunSpecEntity 30 | 31 | _LOGGER: logging.Logger = logging.getLogger(__package__) 32 | 33 | ICON_DEFAULT = "mdi:information-outline" 34 | ICON_AC_AMPS = "mdi:current-ac" 35 | ICON_DC_AMPS = "mdi:current-dc" 36 | ICON_VOLT = "mdi:lightning-bolt" 37 | ICON_POWER = "mdi:solar-power" 38 | ICON_FREQ = "mdi:sine-wave" 39 | ICON_ENERGY = "mdi:solar-panel" 40 | ICON_TEMP = "mdi:thermometer" 41 | 42 | HA_META = { 43 | "A": [UnitOfElectricCurrent.AMPERE, ICON_AC_AMPS, SensorDeviceClass.CURRENT], 44 | "HPa": [UnitOfPressure.HPA, ICON_DEFAULT, None], 45 | "Hz": [UnitOfFrequency.HERTZ, ICON_FREQ, None], 46 | "Mbps": [UnitOfDataRate.MEGABITS_PER_SECOND, ICON_DEFAULT, None], 47 | "V": [UnitOfElectricPotential.VOLT, ICON_VOLT, SensorDeviceClass.VOLTAGE], 48 | "VA": [UnitOfApparentPower.VOLT_AMPERE, ICON_POWER, None], 49 | "VAr": [UnitOfReactivePower.VOLT_AMPERE_REACTIVE, ICON_POWER, None], 50 | "W": [UnitOfPower.WATT, ICON_POWER, SensorDeviceClass.POWER], 51 | "W/m2": [UnitOfIrradiance.WATTS_PER_SQUARE_METER, ICON_DEFAULT, None], 52 | "Wh": [UnitOfEnergy.WATT_HOUR, ICON_ENERGY, SensorDeviceClass.ENERGY], 53 | "WH": [UnitOfEnergy.WATT_HOUR, ICON_ENERGY, SensorDeviceClass.ENERGY], 54 | "bps": [UnitOfDataRate.BITS_PER_SECOND, ICON_DEFAULT, None], 55 | "deg": [DEGREE, ICON_TEMP, SensorDeviceClass.TEMPERATURE], 56 | "Degrees": [DEGREE, ICON_TEMP, SensorDeviceClass.TEMPERATURE], 57 | "C": [UnitOfTemperature.CELSIUS, ICON_TEMP, SensorDeviceClass.TEMPERATURE], 58 | "kWh": [UnitOfEnergy.KILO_WATT_HOUR, ICON_ENERGY, SensorDeviceClass.ENERGY], 59 | "m/s": [UnitOfSpeed.METERS_PER_SECOND, ICON_DEFAULT, None], 60 | "mSecs": [UnitOfTime.MILLISECONDS, ICON_DEFAULT, None], 61 | "meters": [UnitOfLength.METERS, ICON_DEFAULT, None], 62 | "mm": [UnitOfLength.MILLIMETERS, ICON_DEFAULT, None], 63 | "%": [PERCENTAGE, ICON_DEFAULT, None], 64 | "Secs": [UnitOfTime.SECONDS, ICON_DEFAULT, None], 65 | "enum16": [None, ICON_DEFAULT, SensorDeviceClass.ENUM], 66 | "bitfield32": [None, ICON_DEFAULT, SensorDeviceClass.ENUM], 67 | } 68 | 69 | 70 | async def async_setup_entry(hass, entry, async_add_devices): 71 | """Setup sensor platform.""" 72 | coordinator = hass.data[DOMAIN][entry.entry_id] 73 | sensors = [] 74 | device_info = await coordinator.api.async_get_device_info() 75 | prefix = entry.options.get(CONF_PREFIX, entry.data.get(CONF_PREFIX, "")) 76 | for model_id in coordinator.data.keys(): 77 | model_wrapper = coordinator.data[model_id] 78 | for key in model_wrapper.getKeys(): 79 | for model_index in range(model_wrapper.num_models): 80 | data = { 81 | "device_info": device_info, 82 | "key": key, 83 | "model_id": model_id, 84 | "model_index": model_index, 85 | "model": model_wrapper, 86 | "prefix": prefix, 87 | } 88 | 89 | meta = model_wrapper.getMeta(key) 90 | sunspec_unit = meta.get("units", "") 91 | ha_meta = HA_META.get(sunspec_unit, [sunspec_unit, None, None]) 92 | device_class = ha_meta[2] 93 | if device_class == SensorDeviceClass.ENERGY: 94 | _LOGGER.debug("Adding energy sensor") 95 | sensors.append(SunSpecEnergySensor(coordinator, entry, data)) 96 | else: 97 | sensors.append(SunSpecSensor(coordinator, entry, data)) 98 | 99 | async_add_devices(sensors) 100 | 101 | 102 | class SunSpecSensor(SunSpecEntity, SensorEntity): 103 | """sunspec Sensor class.""" 104 | 105 | def __init__(self, coordinator, config_entry, data): 106 | super().__init__( 107 | coordinator, config_entry, data["device_info"], data["model"].getGroupMeta() 108 | ) 109 | self.model_id = data["model_id"] 110 | self.model_index = data["model_index"] 111 | self.model_wrapper = data["model"] 112 | self.key = data["key"] 113 | self._meta = self.model_wrapper.getMeta(self.key) 114 | self._group_meta = self.model_wrapper.getGroupMeta() 115 | self._point_meta = self.model_wrapper.getPoint(self.key).pdef 116 | sunspec_unit = self._meta.get("units", self._meta.get("type", "")) 117 | ha_meta = HA_META.get(sunspec_unit, [sunspec_unit, ICON_DEFAULT, None]) 118 | self.unit = ha_meta[0] 119 | self.use_icon = ha_meta[1] 120 | self.use_device_class = ha_meta[2] 121 | self._options = [] 122 | # Used if this is an energy sensor and the read value is 0 123 | # Updated wheneve the value read is not 0 124 | self.lastKnown = None 125 | self._assumed_state = False 126 | 127 | self._uniqe_id = get_sunspec_unique_id( 128 | config_entry.entry_id, self.key, self.model_id, self.model_index 129 | ) 130 | 131 | vtype = self._meta["type"] 132 | if vtype in ("enum16", "bitfield32"): 133 | self._options = self._point_meta.get("symbols", None) 134 | if self._options is None: 135 | self.use_device_class = None 136 | else: 137 | self.use_device_class = SensorDeviceClass.ENUM 138 | self._options = [item["name"] for item in self._options] 139 | self._options.append("") 140 | 141 | self._device_id = config_entry.entry_id 142 | name = self._group_meta.get("name", str(self.model_id)) 143 | if self.model_index > 0: 144 | name = f"{name} {self.model_index}" 145 | key_parts = self.key.split(":") 146 | if len(key_parts) > 1: 147 | name = f"{name} {key_parts[0]} {key_parts[1]}" 148 | 149 | desc = self._meta.get("label", self.key) 150 | if self.unit == UnitOfElectricCurrent.AMPERE and "DC" in desc: 151 | self.use_icon = ICON_DC_AMPS 152 | 153 | if data["prefix"] != "": 154 | name = f"{data['prefix']} {name}" 155 | 156 | self._name = f"{name.capitalize()} {desc}" 157 | _LOGGER.debug( 158 | "Created sensor for %s in model %s using prefix %s: %s uid %s, device class %s unit %s", 159 | self.key, 160 | self.model_id, 161 | data["prefix"], 162 | self._name, 163 | self._uniqe_id, 164 | self.use_device_class, 165 | self.unit, 166 | ) 167 | if self.device_class == SensorDeviceClass.ENUM: 168 | _LOGGER.debug("Valid options for ENUM: %s", self._options) 169 | 170 | # def async_will_remove_from_hass(self): 171 | # _LOGGER.debug(f"Will remove sensor {self._uniqe_id}") 172 | 173 | @property 174 | def options(self): 175 | if self.device_class != SensorDeviceClass.ENUM: 176 | return None 177 | return self._options 178 | 179 | @property 180 | def name(self): 181 | """Return the name of the sensor.""" 182 | return self._name 183 | 184 | @property 185 | def unique_id(self): 186 | """Return a unique ID to use for this entity.""" 187 | return self._uniqe_id 188 | 189 | @property 190 | def assumed_state(self): 191 | return self._assumed_state 192 | 193 | @property 194 | def native_value(self): 195 | """Return the state of the sensor.""" 196 | try: 197 | val = self.coordinator.data[self.model_id].getValue( 198 | self.key, self.model_index 199 | ) 200 | except KeyError: 201 | _LOGGER.warning("Model %s not found", self.model_id) 202 | return None 203 | except OverflowError: 204 | _LOGGER.warning( 205 | "Math overflow error when retreiving calculated value for %s", self.key 206 | ) 207 | return None 208 | vtype = self._meta["type"] 209 | if vtype in ("enum16", "bitfield32"): 210 | symbols = self._point_meta.get("symbols", None) 211 | if symbols is None: 212 | return val 213 | if vtype == "enum16": 214 | symbol = list(filter(lambda s: s["value"] == val, symbols)) 215 | if len(symbol) == 1: 216 | return symbol[0]["name"][:255] 217 | else: 218 | return None 219 | else: 220 | symbols = list( 221 | filter(lambda s: (val >> int(s["value"])) & 1 == 1, symbols) 222 | ) 223 | if len(symbols) > 0: 224 | return ",".join(map(lambda s: s["name"], symbols))[:255] 225 | return "" 226 | return val 227 | 228 | @property 229 | def native_unit_of_measurement(self): 230 | """Return the unit of measurement.""" 231 | # if self.unit == "": 232 | # _LOGGER.debug(f"UNIT IS NONT FOR {self.name}") 233 | # return None 234 | return self.unit 235 | 236 | @property 237 | def icon(self): 238 | """Return the icon of the sensor.""" 239 | return self.use_icon 240 | 241 | @property 242 | def device_class(self): 243 | """Return de device class of the sensor.""" 244 | return self.use_device_class 245 | 246 | @property 247 | def state_class(self): 248 | """Return de device class of the sensor.""" 249 | if self.unit == "" or self.unit is None: 250 | return None 251 | if self.device_class == SensorDeviceClass.ENERGY: 252 | return SensorStateClass.TOTAL_INCREASING 253 | return SensorStateClass.MEASUREMENT 254 | 255 | @property 256 | def extra_state_attributes(self): 257 | """Return the state attributes.""" 258 | attrs = { 259 | "integration": DOMAIN, 260 | "sunspec_key": self.key, 261 | } 262 | label = self._meta.get("label", None) 263 | if label is not None: 264 | attrs["label"] = label 265 | 266 | vtype = self._meta["type"] 267 | if vtype in ("enum16", "bitfield32"): 268 | attrs["raw"] = self.coordinator.data[self.model_id].getValue( 269 | self.key, self.model_index 270 | ) 271 | return attrs 272 | 273 | 274 | class SunSpecEnergySensor(SunSpecSensor, RestoreSensor): 275 | def __init__(self, coordinator, config_entry, data): 276 | super().__init__(coordinator, config_entry, data) 277 | self.last_known_value = None 278 | 279 | @property 280 | def native_value(self): 281 | val = super().native_value 282 | # For an energy sensor a value of 0 woulld mess up long term stats because of how total_increasing works 283 | if val == 0: 284 | _LOGGER.debug( 285 | "Returning last known value instead of 0 for {self.name) to avoid resetting total_increasing counter" 286 | ) 287 | self._assumed_state = True 288 | return self.lastKnown 289 | self.lastKnown = val 290 | self._assumed_state = False 291 | return val 292 | 293 | async def async_added_to_hass(self) -> None: 294 | """Call when entity about to be added to hass.""" 295 | await super().async_added_to_hass() 296 | _LOGGER.debug(f"{self.name} Fetch last known state") 297 | state = await self.async_get_last_sensor_data() 298 | if state: 299 | _LOGGER.debug( 300 | f"{self.name} Got last known value from state: {state.native_value}" 301 | ) 302 | self.last_known_value = state.native_value 303 | else: 304 | _LOGGER.debug(f"{self.name} No previous state was found") 305 | -------------------------------------------------------------------------------- /custom_components/sunspec/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SunSpec Modbus TCP connection", 6 | "description": "If you need help with the configuration have a look here: https://github.com/cjne/ha-sunspec", 7 | "data": { 8 | "host": "Hostname/IP", 9 | "port": "Port", 10 | "slave_id": "Slave ID" 11 | } 12 | }, 13 | "settings": { 14 | "title": "Sensor options", 15 | "description": "Enter an optional prefix for sensor names and select any additional SunSpec models (data registers) you would like to create sensors for. This can also be changed later in the component configuration.", 16 | "data": { 17 | "models_enabled": "Read models", 18 | "prefix": "Sensors prefix", 19 | "scan_interval": "Scan interval (seconds)" 20 | } 21 | } 22 | }, 23 | "error": { 24 | "connection": "Failed to connect, check hostname and port" 25 | }, 26 | "abort": { 27 | "single_instance_allowed": "Only a single instance is allowed." 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "host_options": { 33 | "title": "SunSpec Modbus TCP connection", 34 | "description": "If you need help with the configuration have a look here: https://github.com/cjne/ha-sunspec", 35 | "data": { 36 | "host": "Hostname/IP", 37 | "port": "Port", 38 | "slave_id": "Slave ID" 39 | } 40 | }, 41 | "model_options": { 42 | "title": "Device Options", 43 | "description": "Select what SunSpec models (data registers) you would like to create sensors for.", 44 | "data": { 45 | "host": "Hostname/IP", 46 | "port": "Port", 47 | "slave_id": "Slave ID", 48 | "models_enabled": "Read models", 49 | "scan_interval": "Scan interval (seconds)" 50 | } 51 | } 52 | }, 53 | "error": { 54 | "connection": "Failed to connect, check hostname and port" 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /custom_components/sunspec/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Połączenie z SunSpec Modbus TCP", 6 | "description": "Jeśli potrzebujesz pomocy w konfiguracji, zajrzyj tu: https://github.com/cjne/ha-sunspec", 7 | "data": { 8 | "host": "Nazwa hosta/Adres IP", 9 | "port": "Port", 10 | "slave_id": "Identyfikator urządzenia" 11 | } 12 | }, 13 | "settings": { 14 | "title": "Opcje sensorów", 15 | "description": "Wpisz opcjonalny przedrostek dla nazw sensorów oraz wybierz dodatkowe modele SunSpec (rejestry danych) dla których chcesz utworzyć sensory. Można to zmienić także później w konfiguracji komponentu.", 16 | "data": { 17 | "models_enabled": "Odczytuj modele", 18 | "prefix": "Przedrostek dla sensorów", 19 | "scan_interval": "Częstotliwość pobierania danych (sekundy)" 20 | } 21 | } 22 | }, 23 | "error": { 24 | "connection": "Wystąpiłbłąd w trakcie połączenia, sprawdż nazwę hosta i/lub port" 25 | }, 26 | "abort": { 27 | "single_instance_allowed": "Dozwolona jest tylko jedna instancja." 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "model_options": { 33 | "title": "Opcje sensorów", 34 | "description": "Wybierz dla których modeli SunSpec (rejestry danych) chcesz utworzyć sensory.", 35 | "data": { 36 | "models_enabled": "Odczytuj modele", 37 | "scan_interval": "Częstotliwość pobierania danych (sekundy)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /custom_components/sunspec/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SunSpec Modbus TCP pripojenie", 6 | "description": "Ak potrebujete pomoc s konfiguráciou, pozrite sa sem: https://github.com/cjne/ha-sunspec", 7 | "data": { 8 | "host": "Meno hosťa/IP", 9 | "port": "Port", 10 | "slave_id": "Slave ID" 11 | } 12 | }, 13 | "settings": { 14 | "title": "Možnosti snímača", 15 | "description": "Zadajte voliteľnú predponu pre názvy snímačov a vyberte akékoľvek ďalšie modely SunSpec (údajové registre), pre ktoré chcete vytvoriť snímače. Toto je možné zmeniť aj neskôr v konfigurácii komponentov.", 16 | "data": { 17 | "models_enabled": "Čítať modely", 18 | "prefix": "Predpona snímačov", 19 | "scan_interval": "Interval skenovania (sekundy)" 20 | } 21 | } 22 | }, 23 | "error": { 24 | "connection": "Nepodarilo sa pripojiť, skontrolujte názov hostiteľa a port" 25 | }, 26 | "abort": { 27 | "single_instance_allowed": "Povolený je len jeden prípad." 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "host_options": { 33 | "title": "SunSpec Modbus TCP pripojenie", 34 | "description": "Ak potrebujete pomoc s konfiguráciou, pozrite sa sem: https://github.com/cjne/ha-sunspec", 35 | "data": { 36 | "host": "Meno hosťa/IP", 37 | "port": "Port", 38 | "slave_id": "Slave ID" 39 | } 40 | }, 41 | "model_options": { 42 | "title": "Možnosti zariadenia", 43 | "description": "Vyberte modely SunSpec (údajové registre), pre ktoré chcete vytvoriť senzory.", 44 | "data": { 45 | "host": "Meno hosťa/IP", 46 | "port": "Port", 47 | "slave_id": "Slave ID", 48 | "models_enabled": "Čítať modely", 49 | "scan_interval": "Interval skenovania (sekundy)" 50 | } 51 | } 52 | }, 53 | "error": { 54 | "connection": "Nepodarilo sa pripojiť, skontrolujte názov hostiteľa a port" 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /custom_components/sunspec/translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SunSpec Modbus TCP ansulutning", 6 | "description": "Om du behover hjälp gå till https://github.com/cjne/ha-sunspec", 7 | "data": { 8 | "host": "Värdnamn/IP", 9 | "port": "Port", 10 | "slave_id": "Modbus slav-id" 11 | } 12 | }, 13 | "settings": { 14 | "title": "Sensoralternativ", 15 | "description": "Alternativt prefix för sensornamn och val av SunSpec modeller (dataregister) som du vill använda. Kan ändras senare i komponentkonfigurationen.", 16 | "data": { 17 | "models_enabled": "Använd modeller", 18 | "prefix": "Sensors prefix", 19 | "scan_interval": "Updateringsinervall (sekunder)" 20 | } 21 | } 22 | }, 23 | "error": { 24 | "connection": "Kunde inte ansluta, kontrollera värdnamn och port" 25 | }, 26 | "abort": { 27 | "single_instance_allowed": "Endast en instans är tillåten" 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "model_options": { 33 | "title": "Enhetsinställningar", 34 | "description": "Välj de SunSpec modeller (dataregister) du vill skapa använda.", 35 | "data": { 36 | "host": "Värdnamn/IP", 37 | "port": "Port", 38 | "slave_id": "Modbus slav-id", 39 | "models_enabled": "Använd modeller", 40 | "scan_interval": "Updateringsinervall (sekunder)" 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SunSpec", 3 | "hacs": "1.6.0", 4 | "render_readme": true, 5 | "homeassistant": "2021.9.1" 6 | } 7 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CJNE/ha-sunspec/39eacc627f484dd168b139d598061362687be4ae/logo.png -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pre-commit==3.6.2 2 | black==24.2.0 3 | flake8==7.0.0 4 | reorder-python-imports==3.13.0 5 | homeassistant 6 | pysunspec2==1.1.5 7 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements_dev.txt 2 | pre-commit==3.6.2 3 | pytest-homeassistant-custom-component 4 | serial 5 | pytest-mock 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | # splits long import on multiple lines indented by 4 spaces 22 | multi_line_output = 3 23 | include_trailing_comma=True 24 | force_grid_wrap=0 25 | use_parentheses=True 26 | line_length=88 27 | indent = " " 28 | # by default isort don't check module indexes 29 | not_skip = __init__.py 30 | # will group `import x` and `from x import` of the same module. 31 | force_sort_within_sections = true 32 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 33 | default_section = THIRDPARTY 34 | known_first_party = custom_components.myenergi, tests 35 | combine_as_imports = true 36 | 37 | [tool:pytest] 38 | addopts = -qq --cov=custom_components.sunspec 39 | console_output_style = count 40 | asyncio_mode = auto 41 | 42 | [coverage:run] 43 | branch = False 44 | 45 | [coverage:report] 46 | show_missing = true 47 | fail_under = 95 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for SunSpec integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | from unittest.mock import Mock 7 | from unittest.mock import patch 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers import entity_registry as er 12 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 13 | from pytest_homeassistant_custom_component.common import MockConfigEntry 14 | 15 | from custom_components.sunspec import DOMAIN 16 | from custom_components.sunspec import get_sunspec_unique_id 17 | from custom_components.sunspec.api import SunSpecApiClient 18 | 19 | from .const import MOCK_CONFIG 20 | 21 | TEST_CONFIG_ENTRY_ID = "77889900aa" 22 | TEST_SERIAL_NO = "abc123" 23 | TEST_INVERTER_SENSOR_STATE_ENTITY_ID = "sensor.inverter_operating_state" 24 | TEST_INVERTER_SENSOR_POWER_ENTITY_ID = "sensor.inverter_watts" 25 | TEST_INVERTER_SENSOR_VAR_ID = "sensor.inverter_var" 26 | TEST_INVERTER_SENSOR_ENERGY_ENTITY_ID = "sensor.inverter_watthours" 27 | TEST_INVERTER_MM_SENSOR_STATE_ENTITY_ID = "sensor.dermeasureac_1_operating_state" 28 | TEST_INVERTER_MM_SENSOR_POWER_ENTITY_ID = "sensor.dermeasureac_1_active_power" 29 | TEST_INVERTER_RG_SENSOR_INCLX_ENTITY_ID = ( 30 | "sensor.inclinometer_incl_2_x_axis_inclination" 31 | ) 32 | TEST_INVERTER_SENSOR_DC_ENTITY_ID = "sensor.mppt_module_0_dc_current" 33 | TEST_INVERTER_PREFIX_SENSOR_DC_ENTITY_ID = "sensor.test_mppt_module_0_dc_current" 34 | 35 | 36 | def create_mock_sunspec_client(hass: HomeAssistant): 37 | """Create a mock modubs client""" 38 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 39 | return api 40 | 41 | 42 | def create_mock_sunspec_config_entry( 43 | hass: HomeAssistant, 44 | data: dict[str, Any] | None = None, 45 | options: dict[str, Any] | None = None, 46 | ) -> ConfigEntry: 47 | """Add a test config entry.""" 48 | config_entry: MockConfigEntry = MockConfigEntry( 49 | entry_id=TEST_CONFIG_ENTRY_ID, 50 | domain=DOMAIN, 51 | data=data or MOCK_CONFIG, 52 | title="", 53 | options=options or {}, 54 | ) 55 | config_entry.add_to_hass(hass) 56 | return config_entry 57 | 58 | 59 | async def setup_mock_sunspec_config_entry( 60 | hass: HomeAssistant, 61 | data: dict[str, Any] | None = None, 62 | config_entry: ConfigEntry | None = None, 63 | client: Mock | None = None, 64 | ) -> ConfigEntry: 65 | """Add a mock sunspec config entry to hass.""" 66 | config_entry = config_entry or create_mock_sunspec_config_entry(hass, data) 67 | client = client or create_mock_sunspec_client(hass) 68 | 69 | with patch( 70 | "custom_components.sunspec.SunSpecApiClient", 71 | return_value=client, 72 | ): 73 | await hass.config_entries.async_setup(config_entry.entry_id) 74 | await hass.async_block_till_done() 75 | return config_entry 76 | 77 | 78 | def register_test_entity( 79 | hass: HomeAssistant, 80 | platform: str, 81 | entity_id: str, 82 | key: str, 83 | model_id: str, 84 | model_index: int, 85 | ) -> None: 86 | """Register a test entity.""" 87 | 88 | unique_id = get_sunspec_unique_id(TEST_CONFIG_ENTRY_ID, key, model_id, model_index) 89 | entity_id = entity_id.split(".")[1] 90 | 91 | entity_registry = er.async_get(hass) 92 | entity_registry.async_get_or_create( 93 | platform, 94 | DOMAIN, 95 | unique_id, 96 | suggested_object_id=entity_id, 97 | disabled_by=None, 98 | ) 99 | 100 | 101 | def get_sunspec_device_identifier(serial_no: str) -> tuple[str, str]: 102 | """Get the identifiers for a SunSpec device.""" 103 | return (DOMAIN, serial_no) 104 | 105 | 106 | class MockSunSpecDataUpdateCoordinator(DataUpdateCoordinator): 107 | """Class to manage fetching data from the API.""" 108 | 109 | def __init__(self, hass, models) -> None: 110 | """Initialize.""" 111 | self.api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 112 | self.option_model_filter = set(map(lambda m: int(m), models)) 113 | 114 | async def _async_update_data(self): 115 | """Update data via library.""" 116 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for SunSpec integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | from unittest.mock import Mock 6 | from unittest.mock import patch 7 | 8 | import pytest 9 | import sunspec2.file.client as modbus_client 10 | 11 | from custom_components.sunspec.api import ConnectionError 12 | from custom_components.sunspec.api import ConnectionTimeoutError 13 | 14 | pytest_plugins = "pytest_homeassistant_custom_component" 15 | _LOGGER: logging.Logger = logging.getLogger(__package__) 16 | 17 | 18 | class MockFileClientDeviceNotConnected(modbus_client.FileClientDevice): 19 | def is_connected(self): 20 | return False 21 | 22 | def connect(self): 23 | return True 24 | 25 | 26 | class MockFileClientDevice(modbus_client.FileClientDevice): 27 | def is_connected(self): 28 | return True 29 | 30 | def scan(self, progress=None): 31 | print(progress) 32 | if progress is not None: 33 | if not progress("Mock scan"): 34 | return 35 | return super().scan() 36 | 37 | def connect(self): 38 | return True 39 | 40 | 41 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 42 | # notifications. These calls would fail without this fixture since the persistent_notification 43 | # integration is never loaded during a test. 44 | @pytest.fixture(name="skip_notifications", autouse=True) 45 | def skip_notifications_fixture(): 46 | """Skip notification calls.""" 47 | with patch("homeassistant.components.persistent_notification.async_create"), patch( 48 | "homeassistant.components.persistent_notification.async_dismiss" 49 | ): 50 | yield 51 | 52 | 53 | @pytest.fixture(name="auto_enable_custom_integrations", autouse=True) 54 | def auto_enable_custom_integrations( 55 | hass: Any, enable_custom_integrations: Any # noqa: F811 56 | ) -> None: 57 | """Enable custom integrations defined in the test dir.""" 58 | 59 | 60 | # This fixture, when used, will result in calls to async_get_data to return None. To have the call 61 | # return a value, we would add the `return_value=` parameter to the patch call. 62 | @pytest.fixture(name="bypass_get_device_info") 63 | def bypass_get_device_info_fixture(): 64 | """Skip calls to get data from API.""" 65 | with patch("custom_components.sunspec.SunSpecApiClient.async_get_device_info"): 66 | yield 67 | 68 | 69 | # This fixture, when used, will result in calls to async_get_data to return None. To have the call 70 | # return a value, we would add the `return_value=` parameter to the patch call. 71 | @pytest.fixture(name="bypass_get_data") 72 | def bypass_get_data_fixture(): 73 | """Skip calls to get data from API.""" 74 | with patch("custom_components.sunspec.SunSpecApiClient.async_get_data"): 75 | yield 76 | 77 | 78 | @pytest.fixture 79 | def sunspec_client_mock(): 80 | """Skip calls to get data from API.""" 81 | client = MockFileClientDevice("./tests/test_data/inverter.json") 82 | client.scan() 83 | with patch( 84 | "custom_components.sunspec.SunSpecApiClient.modbus_connect", return_value=client 85 | ), patch( 86 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 87 | ): 88 | yield 89 | 90 | 91 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 92 | # for exception handling. 93 | @pytest.fixture 94 | def sunspec_client_mock_connect_error(): 95 | """Simulate connection error when retrieving data from API.""" 96 | client = MockFileClientDevice("./tests/test_data/inverter.json") 97 | with patch( 98 | "custom_components.sunspec.SunSpecApiClient.modbus_connect", return_value=client 99 | ), patch( 100 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 101 | ), patch( 102 | "custom_components.sunspec.SunSpecApiClient.async_get_models", 103 | side_effect=ConnectionError, 104 | ): 105 | yield 106 | 107 | 108 | @pytest.fixture 109 | def sunspec_client_mock_not_connected(): 110 | """Skip calls to get data from API.""" 111 | client = MockFileClientDeviceNotConnected("./tests/test_data/inverter.json") 112 | client.scan() 113 | with patch( 114 | "custom_components.sunspec.SunSpecApiClient.modbus_connect", return_value=client 115 | ), patch( 116 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 117 | ): 118 | yield 119 | 120 | 121 | @pytest.fixture(name="sunspec_modbus_client_mock") 122 | def sunspec_modbus_client_mock(): 123 | """Skip calls to get data from API.""" 124 | mock = Mock() 125 | with patch( 126 | "sunspec2.modbus.client.SunSpecModbusClientDeviceTCP", return_value=mock 127 | ), patch( 128 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 129 | ): 130 | yield 131 | 132 | 133 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 134 | # for exception handling. 135 | @pytest.fixture(name="error_on_get_device_info") 136 | def error_get_device_info_fixture(): 137 | """Simulate error when retrieving data from API.""" 138 | with patch( 139 | "custom_components.sunspec.SunSpecApiClient.async_get_device_info", 140 | side_effect=Exception, 141 | ), patch( 142 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 143 | ): 144 | yield 145 | 146 | 147 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 148 | # for exception handling. 149 | @pytest.fixture 150 | def error_on_get_data(): 151 | """Simulate error when retrieving data from API.""" 152 | client = MockFileClientDevice("./tests/test_data/inverter.json") 153 | client.scan() 154 | with patch( 155 | "custom_components.sunspec.SunSpecApiClient.modbus_connect", return_value=client 156 | ), patch( 157 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 158 | ), patch( 159 | "custom_components.sunspec.SunSpecApiClient.async_get_data", 160 | side_effect=ConnectionError, 161 | ): 162 | yield 163 | 164 | 165 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 166 | # for exception handling. 167 | @pytest.fixture 168 | def timeout_error_on_get_data(): 169 | """Simulate timeout error when retrieving data from API.""" 170 | client = MockFileClientDevice("./tests/test_data/inverter.json") 171 | client.scan() 172 | with patch( 173 | "custom_components.sunspec.SunSpecApiClient.get_client", return_value=client 174 | ), patch( 175 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 176 | ), patch( 177 | "custom_components.sunspec.SunSpecApiClient.async_get_data", 178 | side_effect=ConnectionTimeoutError, 179 | ): 180 | yield 181 | 182 | 183 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 184 | # for exception handling. 185 | @pytest.fixture 186 | def connect_error_on_get_data(): 187 | """Simulate connection error when retrieving data from API.""" 188 | client = MockFileClientDevice("./tests/test_data/inverter.json") 189 | with patch( 190 | "custom_components.sunspec.SunSpecApiClient.modbus_connect", return_value=client 191 | ), patch( 192 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 193 | ), patch( 194 | "custom_components.sunspec.SunSpecApiClient.async_get_data", 195 | side_effect=ConnectionError, 196 | ): 197 | yield 198 | 199 | 200 | @pytest.fixture 201 | def overflow_error_dca(): 202 | """Simulate overflow error for getValue from API.""" 203 | 204 | def my_side_effect(*args, **kwargs): 205 | if args[0] == "DCA": 206 | raise OverflowError() 207 | return 1 208 | 209 | with patch( 210 | "custom_components.sunspec.api.SunSpecModelWrapper.getValue", 211 | side_effect=my_side_effect, 212 | ): 213 | yield 214 | -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Constants for SunSpec tests.""" 2 | 3 | from custom_components.sunspec.const import CONF_ENABLED_MODELS 4 | from custom_components.sunspec.const import CONF_HOST 5 | from custom_components.sunspec.const import CONF_PORT 6 | from custom_components.sunspec.const import CONF_PREFIX 7 | from custom_components.sunspec.const import CONF_SCAN_INTERVAL 8 | from custom_components.sunspec.const import CONF_SLAVE_ID 9 | 10 | MOCK_SETTINGS_PREFIX = { 11 | CONF_ENABLED_MODELS: [160], 12 | CONF_PREFIX: "test", 13 | CONF_SCAN_INTERVAL: 10, 14 | } 15 | MOCK_SETTINGS = {CONF_ENABLED_MODELS: [103, 160], CONF_SCAN_INTERVAL: 10} 16 | MOCK_SETTINGS_MM = {CONF_ENABLED_MODELS: [701], CONF_SCAN_INTERVAL: 10} 17 | MOCK_CONFIG_STEP_1 = {CONF_HOST: "test_host", CONF_PORT: 123, CONF_SLAVE_ID: 1} 18 | MOCK_CONFIG = { 19 | CONF_HOST: "test_host", 20 | CONF_PORT: 123, 21 | CONF_SLAVE_ID: 1, 22 | CONF_PREFIX: "", 23 | CONF_SCAN_INTERVAL: 10, 24 | CONF_ENABLED_MODELS: MOCK_SETTINGS[CONF_ENABLED_MODELS], 25 | } 26 | MOCK_CONFIG_MM = { 27 | CONF_HOST: "test_host", 28 | CONF_PORT: 123, 29 | CONF_SLAVE_ID: 1, 30 | CONF_PREFIX: "", 31 | CONF_ENABLED_MODELS: MOCK_SETTINGS_MM[CONF_ENABLED_MODELS], 32 | } 33 | MOCK_CONFIG_PREFIX = { 34 | CONF_HOST: "test_host", 35 | CONF_PORT: 123, 36 | CONF_SLAVE_ID: 1, 37 | CONF_PREFIX: "test", 38 | CONF_ENABLED_MODELS: MOCK_SETTINGS_PREFIX[CONF_ENABLED_MODELS], 39 | } 40 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for SunSpec api.""" 2 | 3 | import pytest 4 | from sunspec2.modbus.client import SunSpecModbusClientException 5 | from sunspec2.modbus.client import SunSpecModbusClientTimeout 6 | from sunspec2.modbus.modbus import ModbusClientError 7 | 8 | from custom_components.sunspec.api import ConnectionError 9 | from custom_components.sunspec.api import ConnectionTimeoutError 10 | from custom_components.sunspec.api import SunSpecApiClient 11 | 12 | 13 | async def test_api(hass, sunspec_client_mock): 14 | """Test API calls.""" 15 | 16 | # To test the api submodule, we first create an instance of our API client 17 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 18 | 19 | models = await api.async_get_models() 20 | assert models == [ 21 | 1, 22 | 103, 23 | 160, 24 | 304, 25 | 701, 26 | 702, 27 | 703, 28 | 704, 29 | 705, 30 | 706, 31 | 707, 32 | 708, 33 | 709, 34 | 710, 35 | 711, 36 | 712, 37 | ] 38 | 39 | device_info = await api.async_get_device_info() 40 | 41 | assert device_info.getValue("Mn") == "SunSpecTest" 42 | assert device_info.getValue("SN") == "sn-123456789" 43 | 44 | model = await api.async_get_data(701) 45 | assert model.getValue("W") == 9800 46 | assert model.getMeta("W")["label"] == "Active Power" 47 | 48 | model = await api.async_get_data(705) 49 | keys = model.getKeys() 50 | assert len(keys) == 22 51 | 52 | 53 | async def test_get_client(hass, sunspec_modbus_client_mock): 54 | SunSpecApiClient.CLIENT_CACHE = {} 55 | """Test API calls.""" 56 | 57 | # To test the api submodule, we first create an instance of our API client 58 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 59 | client = api.get_client() 60 | client.scan.assert_called_once() 61 | 62 | SunSpecApiClient.CLIENT_CACHE = {} 63 | 64 | 65 | async def test_modbus_connect(hass, sunspec_modbus_client_mock): 66 | SunSpecApiClient.CLIENT_CACHE = {} 67 | """Test API calls.""" 68 | 69 | # To test the api submodule, we first create an instance of our API client 70 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 71 | SunSpecApiClient.CLIENT_CACHE = {} 72 | client = api.get_client() 73 | client.scan.assert_called_once() 74 | 75 | SunSpecApiClient.CLIENT_CACHE = {} 76 | 77 | 78 | async def test_modbus_connect_fail(hass, mocker): 79 | mocker.patch( 80 | # api_call is from slow.py but imported to main.py 81 | "sunspec2.modbus.client.SunSpecModbusClientDeviceTCP.connect", 82 | return_value={}, 83 | ) 84 | mocker.patch( 85 | # api_call is from slow.py but imported to main.py 86 | "sunspec2.modbus.client.SunSpecModbusClientDeviceTCP.is_connected", 87 | return_value=False, 88 | ) 89 | """Test API calls.""" 90 | 91 | # To test the api submodule, we first create an instance of our API client 92 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 93 | 94 | with pytest.raises(Exception): 95 | api.modbus_connect() 96 | 97 | 98 | async def test_modbus_connect_exception(hass, mocker): 99 | mocker.patch( 100 | # api_call is from slow.py but imported to main.py 101 | "sunspec2.modbus.client.SunSpecModbusClientDeviceTCP.connect", 102 | side_effect=ModbusClientError, 103 | ) 104 | mocker.patch( 105 | # api_call is from slow.py but imported to main.py 106 | "sunspec2.modbus.client.SunSpecModbusClientDeviceTCP.is_connected", 107 | return_value=False, 108 | ) 109 | mocker.patch( 110 | "custom_components.sunspec.SunSpecApiClient.check_port", return_value=True 111 | ) 112 | """Test API calls.""" 113 | 114 | # To test the api submodule, we first create an instance of our API client 115 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 116 | 117 | with pytest.raises(ConnectionError): 118 | api.modbus_connect() 119 | 120 | 121 | async def test_read_model_timeout(hass, mocker): 122 | mocker.patch( 123 | "custom_components.sunspec.api.SunSpecApiClient.read_model", 124 | side_effect=SunSpecModbusClientTimeout, 125 | ) 126 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 127 | 128 | with pytest.raises(ConnectionTimeoutError): 129 | await api.async_get_data(1) 130 | 131 | 132 | async def test_read_model_error(hass, mocker): 133 | mocker.patch( 134 | "custom_components.sunspec.api.SunSpecApiClient.read_model", 135 | side_effect=SunSpecModbusClientException, 136 | ) 137 | api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 138 | 139 | with pytest.raises(ConnectionError): 140 | await api.async_get_data(1) 141 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test SunSpec config flow.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from homeassistant import config_entries 6 | from homeassistant import data_entry_flow 7 | import pytest 8 | from pytest_homeassistant_custom_component.common import MockConfigEntry 9 | 10 | from custom_components.sunspec.const import CONF_ENABLED_MODELS 11 | from custom_components.sunspec.const import CONF_SCAN_INTERVAL 12 | from custom_components.sunspec.const import DOMAIN 13 | 14 | from . import MockSunSpecDataUpdateCoordinator 15 | from .const import MOCK_CONFIG 16 | from .const import MOCK_CONFIG_STEP_1 17 | from .const import MOCK_SETTINGS 18 | 19 | 20 | # This fixture bypasses the actual setup of the integration 21 | # since we only want to test the config flow. We test the 22 | # actual functionality of the integration in other test modules. 23 | @pytest.fixture(autouse=True) 24 | def bypass_setup_fixture(): 25 | """Prevent setup.""" 26 | with patch( 27 | "custom_components.sunspec.async_setup", 28 | return_value=True, 29 | ), patch( 30 | "custom_components.sunspec.async_setup_entry", 31 | return_value=True, 32 | ): 33 | yield 34 | 35 | 36 | # Here we simiulate a successful config flow from the backend. 37 | # Note that we use the `bypass_get_data` fixture here because 38 | # we want the config flow validation to succeed during the test. 39 | async def test_successful_config_flow( 40 | hass, bypass_get_data, enable_custom_integrations, sunspec_client_mock 41 | ): 42 | """Test a successful config flow.""" 43 | # Initialize a config flow 44 | result = await hass.config_entries.flow.async_init( 45 | DOMAIN, context={"source": config_entries.SOURCE_USER} 46 | ) 47 | 48 | # Check that the config flow shows the user form as the first step 49 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 50 | assert result["step_id"] == "user" 51 | 52 | flow_id = result["flow_id"] 53 | # If a user were to enter `test_username` for username and `test_password` 54 | # for password, it would result in this function call 55 | result = await hass.config_entries.flow.async_configure( 56 | flow_id, user_input=MOCK_CONFIG_STEP_1 57 | ) 58 | 59 | # Check that the config flow is complete and a new entry is created with 60 | # the input data 61 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 62 | 63 | result = await hass.config_entries.flow.async_configure( 64 | flow_id, user_input=MOCK_SETTINGS 65 | ) 66 | 67 | assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY 68 | assert result["title"] == "test_host:123:1" 69 | assert result["data"] == MOCK_CONFIG 70 | assert result["result"] 71 | 72 | 73 | # In this case, we want to simulate a failure during the config flow. 74 | # We use the `error_on_get_data` mock instead of `bypass_get_data` 75 | # (note the function parameters) to raise an Exception during 76 | # validation of the input config. 77 | async def test_failed_config_flow( 78 | hass, error_on_get_data, error_on_get_device_info, sunspec_client_mock 79 | ): 80 | """Test a failed config flow due to credential validation failure.""" 81 | 82 | result = await hass.config_entries.flow.async_init( 83 | DOMAIN, context={"source": config_entries.SOURCE_USER} 84 | ) 85 | 86 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 87 | assert result["step_id"] == "user" 88 | 89 | result = await hass.config_entries.flow.async_configure( 90 | result["flow_id"], user_input=MOCK_CONFIG_STEP_1 91 | ) 92 | 93 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 94 | assert result["errors"] == {"base": "connection"} 95 | 96 | 97 | # Our config flow also has an options flow, so we must test it as well. 98 | async def test_options_flow(hass, sunspec_client_mock): 99 | """Test an options flow.""" 100 | # Create a new MockConfigEntry and add to HASS (we're bypassing config 101 | # flow entirely) 102 | entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 103 | entry.add_to_hass(hass) 104 | 105 | coordinator = MockSunSpecDataUpdateCoordinator(hass, [1, 2]) 106 | # api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 107 | hass.data[DOMAIN] = {entry.entry_id: coordinator} 108 | 109 | # Initialize an options flow 110 | # await hass.config_entries.async_setup(entry.entry_id) 111 | result = await hass.config_entries.options.async_init(entry.entry_id) 112 | 113 | # Verify that the first options step is a user form 114 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 115 | assert result["step_id"] == "host_options" 116 | 117 | # Enter some fake data into the form 118 | result = await hass.config_entries.options.async_configure( 119 | result["flow_id"], user_input=MOCK_CONFIG_STEP_1 120 | ) 121 | 122 | # Verify that the second options step is a user form 123 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 124 | assert result["step_id"] == "model_options" 125 | 126 | result = await hass.config_entries.options.async_configure( 127 | result["flow_id"], user_input={CONF_ENABLED_MODELS: [], CONF_SCAN_INTERVAL: 10} 128 | ) 129 | 130 | # Verify that the flow finishes 131 | assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY 132 | assert result["title"] == "" 133 | 134 | # Verify that the options were updated 135 | # assert entry.options == {BINARY_SENSOR: True, SENSOR: False, SWITCH: True} 136 | 137 | 138 | # Test faild connection in options flow 139 | async def test_options_flow_connect_error(hass, sunspec_client_mock_connect_error): 140 | """Test an options flow.""" 141 | # Create a new MockConfigEntry and add to HASS (we're bypassing config 142 | # flow entirely) 143 | entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 144 | entry.add_to_hass(hass) 145 | 146 | coordinator = MockSunSpecDataUpdateCoordinator(hass, [1, 2]) 147 | # api = SunSpecApiClient(host="test", port=123, slave_id=1, hass=hass) 148 | hass.data[DOMAIN] = {entry.entry_id: coordinator} 149 | 150 | # Initialize an options flow 151 | # await hass.config_entries.async_setup(entry.entry_id) 152 | result = await hass.config_entries.options.async_init(entry.entry_id) 153 | 154 | # Verify that the first options step is a user form 155 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 156 | assert result["step_id"] == "host_options" 157 | 158 | # Enter some fake data into the form 159 | result = await hass.config_entries.options.async_configure( 160 | result["flow_id"], user_input=MOCK_CONFIG_STEP_1 161 | ) 162 | 163 | # Verify that we return to host settings 164 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 165 | assert result["step_id"] == "host_options" 166 | -------------------------------------------------------------------------------- /tests/test_data/inverter.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "device_1547", 3 | "models": [ 4 | { 5 | "ID": 1, 6 | "Mn": "SunSpecTest", 7 | "Md": "Test-1547-1", 8 | "Opt": "opt_a_b_c", 9 | "Vr": "1.2.3", 10 | "SN": "sn-123456789", 11 | "DA": 1, 12 | "Pad": 0 13 | }, 14 | { 15 | "ID": 304, 16 | "L": null, 17 | "incl": [ 18 | { 19 | "Inclx": 3000, 20 | "Incly": 3100, 21 | "Inclz": 3200 22 | }, 23 | { 24 | "Inclx": 4000, 25 | "Incly": 4100, 26 | "Inclz": 4200 27 | }, 28 | { 29 | "Inclx": 5000, 30 | "Incly": 5100, 31 | "Inclz": 5200 32 | } 33 | ] 34 | }, 35 | { 36 | "ID": 103, 37 | "L": null, 38 | "A": 20, 39 | "AphA": 7, 40 | "AphB": 7, 41 | "AphC": 6, 42 | "PPVphAB": 233, 43 | "PPVphBC": 231, 44 | "PPVphCA": 232, 45 | "PhVphA": 232, 46 | "PhVphB": 232, 47 | "PhVphC": 232, 48 | "W": 800, 49 | "Hz": 50, 50 | "VA": 800, 51 | "VAr": 800, 52 | "VAr": 800, 53 | "PF": 100, 54 | "WH": 100000, 55 | "DCA": 22, 56 | "DCV": 500, 57 | "DCW": 900, 58 | "TmpCab": 45, 59 | "St": 4, 60 | "StVnd": 4, 61 | "Evt1": 3 62 | }, 63 | { 64 | "ID": 701, 65 | "L": null, 66 | "ACType": 3, 67 | "St": 1, 68 | "Alrm": 0, 69 | "W": 9800, 70 | "VA": 10000, 71 | "Var": 200, 72 | "PF": 985, 73 | "A": 411, 74 | "LLV": 2400, 75 | "LNV": 2400, 76 | "Hz": 60010, 77 | "TotWhInj": 150, 78 | "TotWhAbs": 0, 79 | "TotVarhInj": 9, 80 | "TotVarhAbs": 0, 81 | "TmpAmb": 450, 82 | "TmpCab": 550, 83 | "TmpSnk": 650, 84 | "TmpTrns": 500, 85 | "TmpSw": 400, 86 | "TmpOt": 420, 87 | "WL1": 3200, 88 | "VAL1": 3333, 89 | "VarL1": 80, 90 | "PFL1": 984, 91 | "AL1": 137, 92 | "VL1L2": 120, 93 | "VL1": 120, 94 | "TotWhInjL1": 49, 95 | "TotWhAbsL1": 0, 96 | "TotVarhInjL1": 2, 97 | "TotVarhAbsL1": 0, 98 | "WL2": 3300, 99 | "VAL2": 3333, 100 | "VarL2": 80, 101 | "PFL2": 986, 102 | "AL2": 136, 103 | "VL2L3": 120, 104 | "VL2": 120, 105 | "TotWhInjL2": 50, 106 | "TotWhAbsL2": 0, 107 | "TotVarhInjL2": 3, 108 | "TotVarhAbsL2": 0, 109 | "WL3": 3500, 110 | "VAL3": 3333, 111 | "VarL3": 40, 112 | "PFL3": 987, 113 | "AL3": 138, 114 | "VL3L1": 120, 115 | "VL3N": 120, 116 | "TotWhInjL3": 51, 117 | "TotWhAbsL3": 0, 118 | "TotVarhInjL3": 4, 119 | "TotVarhAbsL3": 0, 120 | "A_SF": -1, 121 | "V_SF": -1, 122 | "Hz_SF": -3, 123 | "W_SF": 0, 124 | "PF_SF": -3, 125 | "VA_SF": 0, 126 | "Var_SF": 0, 127 | "TotWh_SF": 3, 128 | "TotVarh_SF": 3, 129 | "Tmp_SF": -1 130 | }, 131 | { 132 | "ID": 701, 133 | "L": null, 134 | "ACType": 3, 135 | "St": 0, 136 | "Alrm": 0, 137 | "W": 9700, 138 | "VA": 10000, 139 | "Var": 200, 140 | "PF": 985, 141 | "A": 411, 142 | "LLV": 2400, 143 | "LNV": 2400, 144 | "Hz": 60010, 145 | "TotWhInj": 150, 146 | "TotWhAbs": 0, 147 | "TotVarhInj": 9, 148 | "TotVarhAbs": 0, 149 | "TmpAmb": 450, 150 | "TmpCab": 550, 151 | "TmpSnk": 650, 152 | "TmpTrns": 500, 153 | "TmpSw": 400, 154 | "TmpOt": 420, 155 | "WL1": 3200, 156 | "VAL1": 3333, 157 | "VarL1": 80, 158 | "PFL1": 984, 159 | "AL1": 137, 160 | "VL1L2": 120, 161 | "VL1": 120, 162 | "TotWhInjL1": 49, 163 | "TotWhAbsL1": 0, 164 | "TotVarhInjL1": 2, 165 | "TotVarhAbsL1": 0, 166 | "WL2": 3300, 167 | "VAL2": 3333, 168 | "VarL2": 80, 169 | "PFL2": 986, 170 | "AL2": 136, 171 | "VL2L3": 120, 172 | "VL2": 120, 173 | "TotWhInjL2": 50, 174 | "TotWhAbsL2": 0, 175 | "TotVarhInjL2": 3, 176 | "TotVarhAbsL2": 0, 177 | "WL3": 3500, 178 | "VAL3": 3333, 179 | "VarL3": 40, 180 | "PFL3": 987, 181 | "AL3": 138, 182 | "VL3L1": 120, 183 | "VL3N": 120, 184 | "TotWhInjL3": 51, 185 | "TotWhAbsL3": 0, 186 | "TotVarhInjL3": 4, 187 | "TotVarhAbsL3": 0, 188 | "A_SF": -1, 189 | "V_SF": -1, 190 | "Hz_SF": -3, 191 | "W_SF": 0, 192 | "PF_SF": -3, 193 | "VA_SF": 0, 194 | "Var_SF": 0, 195 | "TotWh_SF": 3, 196 | "TotVarh_SF": 3, 197 | "Tmp_SF": -1 198 | }, 199 | { 200 | "ID": 702, 201 | "L": null, 202 | "WMaxRtg": 10000, 203 | "WOvrExtRtg": 10000, 204 | "WOvrExtRtgPF": 1000, 205 | "WUndExtRtg": 10000, 206 | "WUndExtRtgPF": 1000, 207 | "VAMaxRtg": 11000, 208 | "VarMaxInjRtg": 2500, 209 | "VarMaxAbsRtg": 0, 210 | "WChaRteMaxRtg": 0, 211 | "WDisChaRteMaxRtg": 0, 212 | "VAChaRteMaxRtg": 0, 213 | "VADisChaRteMaxRtg": 0, 214 | "VNomRtg": 240, 215 | "VMaxRtg": 270, 216 | "VMinRtg": 210, 217 | "AMaxRtg": 50, 218 | "PFOvrExtRtg": 850, 219 | "PFUndExtRtg": 850, 220 | "ReactSusceptRtg": null, 221 | "NorOpCatRtg": 2, 222 | "AbnOpCatRtg": 3, 223 | "CtrlModes": null, 224 | "IntIslandCatRtg": null, 225 | "WMax": 10000, 226 | "WMaxOvrExt": null, 227 | "WOvrExtPF": null, 228 | "WMaxUndExt": null, 229 | "WUndExtPF": null, 230 | "VAMax": 10000, 231 | "AMax": null, 232 | "Vnom": null, 233 | "VRefOfs": null, 234 | "VMax": null, 235 | "VMin": null, 236 | "VarMaxInj": null, 237 | "VarMaxAbs": null, 238 | "WChaRteMax": null, 239 | "WDisChaRteMax": null, 240 | "VAChaRteMax": null, 241 | "VADisChaRteMax": null, 242 | "IntIslandCat": null, 243 | "W_SF": 0, 244 | "PF_SF": -3, 245 | "VA_SF": 0, 246 | "Var_SF": 0, 247 | "V_SF": 0, 248 | "A_SF": 0, 249 | "S_SF": 0 250 | }, 251 | { 252 | "ID": 703, 253 | "ES": 1, 254 | "ESVHi": 1050, 255 | "ESVLo": 917, 256 | "ESHzHi": 6010, 257 | "ESHzLo": 5950, 258 | "ESDlyTms": 300, 259 | "ESRndTms": 100, 260 | "ESRmpTms": 60, 261 | "V_SF": -3, 262 | "Hz_SF": -2 263 | }, 264 | { 265 | "ID": 704, 266 | "L": null, 267 | "PFWInjEna": 0, 268 | "PFWInjEnaRvrt": null, 269 | "PFWInjRvrtTms": null, 270 | "PFWInjRvrtRem": null, 271 | "PFWAbsEna": 0, 272 | "PFWAbsEnaRvrt": null, 273 | "PFWAbsRvrtTms": null, 274 | "PFWAbsRvrtRem": null, 275 | "WMaxLimEna": 0, 276 | "WMaxLim": 1000, 277 | "WMaxLimRvrt": null, 278 | "WMaxLimEnaRvrt": null, 279 | "WMaxLimRvrtTms": null, 280 | "WMaxLimRvrtRem": null, 281 | "WSetEna": null, 282 | "WSetMod": null, 283 | "WSet": null, 284 | "WSetRvrt": null, 285 | "WSetPct": null, 286 | "WSetPctRvrt": null, 287 | "WSetEnaRvrt": null, 288 | "WSetRvrtTms": null, 289 | "WSetRvrtRem": null, 290 | "VarSetEna": null, 291 | "VarSetMod": null, 292 | "VarSetPri": null, 293 | "VarSet": null, 294 | "VarSetRvrt": null, 295 | "VarSetPct": null, 296 | "VarSetPctRvrt": null, 297 | "VarSetRvrtTms": null, 298 | "VarSetRvrtRem": null, 299 | "RGra": null, 300 | "PF_SF": -3, 301 | "WMaxLim_SF": -1, 302 | "WSet_SF": null, 303 | "WSetPct_SF": null, 304 | "VarSet_SF": null, 305 | "VarSetPct_SF": null, 306 | "PFWInj": { 307 | "PF": 950, 308 | "Ext": 1 309 | }, 310 | "PFWInjRvrt": { 311 | "PF": null, 312 | "Ext": null 313 | }, 314 | "PFWAbs": { 315 | "PF": null, 316 | "Ext": null 317 | }, 318 | "PFWAbsRvrt": { 319 | "PF": null, 320 | "Ext": null 321 | } 322 | }, 323 | { 324 | "ID": 705, 325 | "Ena": 1, 326 | "CrvSt": 1, 327 | "AdptCrvReq": 0, 328 | "AdptCrvRslt": 0, 329 | "NPt": 4, 330 | "NCrv": 3, 331 | "RvrtTms": 0, 332 | "RvrtRem": 0, 333 | "RvrtCrv": 0, 334 | "V_SF": -2, 335 | "DeptRef_SF": -2, 336 | "Crv": [ 337 | { 338 | "ActPt": 4, 339 | "DeptRef": 1, 340 | "Pri": 1, 341 | "VRef": 1, 342 | "VRefAuto": 0, 343 | "VRefTms": 5, 344 | "RspTms": 6, 345 | "ReadOnly": 1, 346 | "Pt": [ 347 | { 348 | "V": 9200, 349 | "Var": 3000 350 | }, 351 | { 352 | "V": 9670, 353 | "Var": 0 354 | }, 355 | { 356 | "V": 10300, 357 | "Var": 0 358 | }, 359 | { 360 | "V": 10700, 361 | "Var": -3000 362 | } 363 | ] 364 | }, 365 | { 366 | "ActPt": 4, 367 | "DeptRef": 1, 368 | "Pri": 1, 369 | "VRef": 1, 370 | "VRefAuto": 0, 371 | "VRefTms": 5, 372 | "RspTms": 6, 373 | "ReadOnly": 0, 374 | "Pt": [ 375 | { 376 | "V": 9300, 377 | "Var": 3000 378 | }, 379 | { 380 | "V": 9570, 381 | "Var": 0 382 | }, 383 | { 384 | "V": 10200, 385 | "Var": 0 386 | }, 387 | { 388 | "V": 10600, 389 | "Var": -4000 390 | } 391 | ] 392 | }, 393 | { 394 | "ActPt": 4, 395 | "DeptRef": 1, 396 | "Pri": 1, 397 | "VRef": 1, 398 | "VRefAuto": 0, 399 | "VRefTms": 5, 400 | "RspTms": 6, 401 | "ReadOnly": 0, 402 | "Pt": [ 403 | { 404 | "V": 9400, 405 | "Var": 2000 406 | }, 407 | { 408 | "V": 9570, 409 | "Var": 0 410 | }, 411 | { 412 | "V": 10500, 413 | "Var": 0 414 | }, 415 | { 416 | "V": 10800, 417 | "Var": -2000 418 | } 419 | ] 420 | } 421 | ] 422 | }, 423 | { 424 | "ID": 706, 425 | "Ena": 0, 426 | "CrvSt": 1, 427 | "AdptCrvReq": 0, 428 | "AdptCrvRslt": 0, 429 | "NPt": 2, 430 | "NCrv": 2, 431 | "RvrtTms": null, 432 | "RvrtRem": null, 433 | "RvrtCrv": null, 434 | "V_SF": 0, 435 | "DeptRef_SF": 0, 436 | "Crv": [ 437 | { 438 | "ActPt": 2, 439 | "DeptRef": 1, 440 | "RspTms": 10, 441 | "ReadOnly": 1, 442 | "Pt": [ 443 | { 444 | "V": 106, 445 | "W": 100 446 | }, 447 | { 448 | "V": 110, 449 | "W": 0 450 | } 451 | ] 452 | }, 453 | { 454 | "ActPt": 2, 455 | "DeptRef": 1, 456 | "RspTms": 5, 457 | "ReadOnly": 0, 458 | "Pt": [ 459 | { 460 | "V": 105, 461 | "W": 100 462 | }, 463 | { 464 | "V": 109, 465 | "W": 0 466 | } 467 | ] 468 | } 469 | ] 470 | }, 471 | { 472 | "ID": 707, 473 | "L": null, 474 | "Ena": 1, 475 | "CrvSt": null, 476 | "AdptCrvReq": null, 477 | "AdptCrvRslt": null, 478 | "NPt": 1, 479 | "NCrvSet": 1, 480 | "V_SF": -2, 481 | "Tms_SF": 0, 482 | "Crv": [ 483 | { 484 | "MustTrip": { 485 | "ActPt": 1, 486 | "Pt": [ 487 | { 488 | "V": 5000, 489 | "Tms": 5 490 | } 491 | ] 492 | }, 493 | "MayTrip": { 494 | "ActPt": 1, 495 | "Pt": [ 496 | { 497 | "V": 7000, 498 | "Tms": 5 499 | } 500 | ] 501 | }, 502 | "MomCess": { 503 | "ActPt": 1, 504 | "Pt": [ 505 | { 506 | "V": 6000, 507 | "Tms": 5 508 | } 509 | ] 510 | } 511 | } 512 | ] 513 | }, 514 | { 515 | "ID": 708, 516 | "L": null, 517 | "Ena": 1, 518 | "CrvSt": null, 519 | "AdptCrvReq": null, 520 | "AdptCrvRslt": null, 521 | "NPt": 1, 522 | "NCrvSet": 1, 523 | "V_SF": -2, 524 | "Tms_SF": 0, 525 | "Crv": [ 526 | { 527 | "MustTrip": { 528 | "ActPt": 1, 529 | "Pt": [ 530 | { 531 | "V": 12000, 532 | "Tms": 5 533 | } 534 | ] 535 | }, 536 | "MayTrip": { 537 | "ActPt": 1, 538 | "Pt": [ 539 | { 540 | "V": 10000, 541 | "Tms": 5 542 | } 543 | ] 544 | }, 545 | "MomCess": { 546 | "ActPt": 1, 547 | "Pt": [ 548 | { 549 | "V": 10000, 550 | "Tms": 5 551 | } 552 | ] 553 | } 554 | } 555 | ] 556 | }, 557 | { 558 | "ID": 709, 559 | "L": null, 560 | "Ena": 1, 561 | "CrvSt": null, 562 | "AdptCrvReq": null, 563 | "AdptCrvRslt": null, 564 | "NPt": 1, 565 | "NCrvSet": 1, 566 | "Freq_SF": null, 567 | "Tms_SF": -2, 568 | "Crv": [ 569 | { 570 | "MustTrip": { 571 | "ActPt": 1, 572 | "Pt": [ 573 | { 574 | "Freq": 5300, 575 | "Tms": 5 576 | } 577 | ] 578 | }, 579 | "MayTrip": { 580 | "ActPt": 1, 581 | "Pt": [ 582 | { 583 | "Freq": 5850, 584 | "Tms": 5 585 | } 586 | ] 587 | }, 588 | "MomCess": { 589 | "ActPt": 1, 590 | "Pt": [ 591 | { 592 | "Freq": 5850, 593 | "Tms": 5 594 | } 595 | ] 596 | } 597 | } 598 | ] 599 | }, 600 | { 601 | "ID": 710, 602 | "L": null, 603 | "Ena": null, 604 | "CrvSt": null, 605 | "AdptCrvReq": null, 606 | "AdptCrvRslt": null, 607 | "NPt": 1, 608 | "NCrvSet": 1, 609 | "Freq_SF": null, 610 | "Tms_SF": -2, 611 | "Crv": [ 612 | { 613 | "MustTrip": { 614 | "ActPt": 1, 615 | "Pt": [ 616 | { 617 | "Freq": 6500, 618 | "Tms": 5 619 | } 620 | ] 621 | }, 622 | "MayTrip": { 623 | "ActPt": 1, 624 | "Pt": [ 625 | { 626 | "Freq": 6050, 627 | "Tms": 5 628 | } 629 | ] 630 | }, 631 | "MomCess": { 632 | "ActPt": 1, 633 | "Pt": [ 634 | { 635 | "Freq": 6050, 636 | "Tms": 5 637 | } 638 | ] 639 | } 640 | } 641 | ] 642 | }, 643 | { 644 | "ID": 711, 645 | "L": null, 646 | "Ena": null, 647 | "CrvSt": null, 648 | "AdptCrvReq": null, 649 | "AdptCrvRslt": null, 650 | "NCtl": 1, 651 | "RvrtTms": 0, 652 | "RvrtRem": 0, 653 | "RvrtCrv": 0, 654 | "Db_SF": -2, 655 | "K_SF": -2, 656 | "RspTms_SF": 0, 657 | "Ctl": [ 658 | { 659 | "DbOf": 60030, 660 | "DbUf": 59970, 661 | "KOf": 40, 662 | "KUf": 40, 663 | "RspTms": 600 664 | } 665 | ] 666 | }, 667 | { 668 | "ID": 712, 669 | "L": null, 670 | "Ena": null, 671 | "CrvSt": null, 672 | "AdptCrvReq": null, 673 | "AdptCrvRslt": null, 674 | "NPt": 1, 675 | "NCrv": 1, 676 | "RvrtTms": 0, 677 | "RvrtRem": 0, 678 | "RvrtCrv": 0, 679 | "DeptRef_SF": -2, 680 | "Crv": [ 681 | { 682 | "ActPt": 1, 683 | "DeptRef": null, 684 | "Pri": null, 685 | "ReadOnly": null, 686 | "Pt": [ 687 | { 688 | "W": null, 689 | "Var": null 690 | } 691 | ] 692 | } 693 | ] 694 | }, 695 | { 696 | "ID": 160, 697 | "L": null, 698 | "Evt": 0, 699 | "N": 2, 700 | "TmsPer": 123, 701 | "module": [ 702 | { 703 | "ID": 1, 704 | "IDStr": "MPPT 1", 705 | "DCA": 90, 706 | "DCV": 900, 707 | "DCW": 900, 708 | "DCWH": 1, 709 | "Tms": 123, 710 | "Tmp": 20, 711 | "DCSt": 4, 712 | "DCEvt": 0 713 | }, 714 | { 715 | "ID": 2, 716 | "IDStr": "MPPT 2", 717 | "DCA": 92, 718 | "DCV": 920, 719 | "DCW": 920, 720 | "DCWH": 2, 721 | "Tms": 223, 722 | "Tmp": 22, 723 | "DCSt": 3, 724 | "DCEvt": 0 725 | } 726 | ] 727 | }, 728 | { 729 | "ID": 65535, 730 | "L": 0 731 | } 732 | ] 733 | } 734 | -------------------------------------------------------------------------------- /tests/test_data/inverter_secondreading.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "device_1547", 3 | "models": [ 4 | { 5 | "ID": 1, 6 | "Mn": "SunSpecTest", 7 | "Md": "Test-1547-1", 8 | "Opt": "opt_a_b_c", 9 | "Vr": "1.2.3", 10 | "SN": "sn-123456789", 11 | "DA": 1, 12 | "Pad": 0 13 | }, 14 | { 15 | "ID": 304, 16 | "L": null, 17 | "incl": [ 18 | { 19 | "Inclx": 3000, 20 | "Incly": 3100, 21 | "Inclz": 3200 22 | }, 23 | { 24 | "Inclx": 4000, 25 | "Incly": 4100, 26 | "Inclz": 4200 27 | }, 28 | { 29 | "Inclx": 5000, 30 | "Incly": 5100, 31 | "Inclz": 5200 32 | } 33 | ] 34 | }, 35 | { 36 | "ID": 103, 37 | "L": null, 38 | "A": 20, 39 | "AphA": 7, 40 | "AphB": 7, 41 | "AphC": 6, 42 | "PPVphAB": 233, 43 | "PPVphBC": 231, 44 | "PPVphCA": 232, 45 | "PhVphA": 232, 46 | "PhVphB": 232, 47 | "PhVphC": 232, 48 | "W": 800, 49 | "Hz": 50, 50 | "VA": 800, 51 | "VAr": 800, 52 | "VAr": 800, 53 | "PF": 100, 54 | "WH": 0, 55 | "DCA": 22, 56 | "DCV": 500, 57 | "DCW": 900, 58 | "TmpCab": 45, 59 | "St": 4, 60 | "StVnd": 4, 61 | "Evt1": 3 62 | }, 63 | { 64 | "ID": 701, 65 | "L": null, 66 | "ACType": 3, 67 | "St": 1, 68 | "Alrm": 0, 69 | "W": 9800, 70 | "VA": 10000, 71 | "Var": 200, 72 | "PF": 985, 73 | "A": 411, 74 | "LLV": 2400, 75 | "LNV": 2400, 76 | "Hz": 60010, 77 | "TotWhInj": 150, 78 | "TotWhAbs": 0, 79 | "TotVarhInj": 9, 80 | "TotVarhAbs": 0, 81 | "TmpAmb": 450, 82 | "TmpCab": 550, 83 | "TmpSnk": 650, 84 | "TmpTrns": 500, 85 | "TmpSw": 400, 86 | "TmpOt": 420, 87 | "WL1": 3200, 88 | "VAL1": 3333, 89 | "VarL1": 80, 90 | "PFL1": 984, 91 | "AL1": 137, 92 | "VL1L2": 120, 93 | "VL1": 120, 94 | "TotWhInjL1": 49, 95 | "TotWhAbsL1": 0, 96 | "TotVarhInjL1": 2, 97 | "TotVarhAbsL1": 0, 98 | "WL2": 3300, 99 | "VAL2": 3333, 100 | "VarL2": 80, 101 | "PFL2": 986, 102 | "AL2": 136, 103 | "VL2L3": 120, 104 | "VL2": 120, 105 | "TotWhInjL2": 50, 106 | "TotWhAbsL2": 0, 107 | "TotVarhInjL2": 3, 108 | "TotVarhAbsL2": 0, 109 | "WL3": 3500, 110 | "VAL3": 3333, 111 | "VarL3": 40, 112 | "PFL3": 987, 113 | "AL3": 138, 114 | "VL3L1": 120, 115 | "VL3N": 120, 116 | "TotWhInjL3": 51, 117 | "TotWhAbsL3": 0, 118 | "TotVarhInjL3": 4, 119 | "TotVarhAbsL3": 0, 120 | "A_SF": -1, 121 | "V_SF": -1, 122 | "Hz_SF": -3, 123 | "W_SF": 0, 124 | "PF_SF": -3, 125 | "VA_SF": 0, 126 | "Var_SF": 0, 127 | "TotWh_SF": 3, 128 | "TotVarh_SF": 3, 129 | "Tmp_SF": -1 130 | }, 131 | { 132 | "ID": 701, 133 | "L": null, 134 | "ACType": 3, 135 | "St": 0, 136 | "Alrm": 0, 137 | "W": 9700, 138 | "VA": 10000, 139 | "Var": 200, 140 | "PF": 985, 141 | "A": 411, 142 | "LLV": 2400, 143 | "LNV": 2400, 144 | "Hz": 60010, 145 | "TotWhInj": 150, 146 | "TotWhAbs": 0, 147 | "TotVarhInj": 9, 148 | "TotVarhAbs": 0, 149 | "TmpAmb": 450, 150 | "TmpCab": 550, 151 | "TmpSnk": 650, 152 | "TmpTrns": 500, 153 | "TmpSw": 400, 154 | "TmpOt": 420, 155 | "WL1": 3200, 156 | "VAL1": 3333, 157 | "VarL1": 80, 158 | "PFL1": 984, 159 | "AL1": 137, 160 | "VL1L2": 120, 161 | "VL1": 120, 162 | "TotWhInjL1": 49, 163 | "TotWhAbsL1": 0, 164 | "TotVarhInjL1": 2, 165 | "TotVarhAbsL1": 0, 166 | "WL2": 3300, 167 | "VAL2": 3333, 168 | "VarL2": 80, 169 | "PFL2": 986, 170 | "AL2": 136, 171 | "VL2L3": 120, 172 | "VL2": 120, 173 | "TotWhInjL2": 50, 174 | "TotWhAbsL2": 0, 175 | "TotVarhInjL2": 3, 176 | "TotVarhAbsL2": 0, 177 | "WL3": 3500, 178 | "VAL3": 3333, 179 | "VarL3": 40, 180 | "PFL3": 987, 181 | "AL3": 138, 182 | "VL3L1": 120, 183 | "VL3N": 120, 184 | "TotWhInjL3": 51, 185 | "TotWhAbsL3": 0, 186 | "TotVarhInjL3": 4, 187 | "TotVarhAbsL3": 0, 188 | "A_SF": -1, 189 | "V_SF": -1, 190 | "Hz_SF": -3, 191 | "W_SF": 0, 192 | "PF_SF": -3, 193 | "VA_SF": 0, 194 | "Var_SF": 0, 195 | "TotWh_SF": 3, 196 | "TotVarh_SF": 3, 197 | "Tmp_SF": -1 198 | }, 199 | { 200 | "ID": 702, 201 | "L": null, 202 | "WMaxRtg": 10000, 203 | "WOvrExtRtg": 10000, 204 | "WOvrExtRtgPF": 1000, 205 | "WUndExtRtg": 10000, 206 | "WUndExtRtgPF": 1000, 207 | "VAMaxRtg": 11000, 208 | "VarMaxInjRtg": 2500, 209 | "VarMaxAbsRtg": 0, 210 | "WChaRteMaxRtg": 0, 211 | "WDisChaRteMaxRtg": 0, 212 | "VAChaRteMaxRtg": 0, 213 | "VADisChaRteMaxRtg": 0, 214 | "VNomRtg": 240, 215 | "VMaxRtg": 270, 216 | "VMinRtg": 210, 217 | "AMaxRtg": 50, 218 | "PFOvrExtRtg": 850, 219 | "PFUndExtRtg": 850, 220 | "ReactSusceptRtg": null, 221 | "NorOpCatRtg": 2, 222 | "AbnOpCatRtg": 3, 223 | "CtrlModes": null, 224 | "IntIslandCatRtg": null, 225 | "WMax": 10000, 226 | "WMaxOvrExt": null, 227 | "WOvrExtPF": null, 228 | "WMaxUndExt": null, 229 | "WUndExtPF": null, 230 | "VAMax": 10000, 231 | "AMax": null, 232 | "Vnom": null, 233 | "VRefOfs": null, 234 | "VMax": null, 235 | "VMin": null, 236 | "VarMaxInj": null, 237 | "VarMaxAbs": null, 238 | "WChaRteMax": null, 239 | "WDisChaRteMax": null, 240 | "VAChaRteMax": null, 241 | "VADisChaRteMax": null, 242 | "IntIslandCat": null, 243 | "W_SF": 0, 244 | "PF_SF": -3, 245 | "VA_SF": 0, 246 | "Var_SF": 0, 247 | "V_SF": 0, 248 | "A_SF": 0, 249 | "S_SF": 0 250 | }, 251 | { 252 | "ID": 703, 253 | "ES": 1, 254 | "ESVHi": 1050, 255 | "ESVLo": 917, 256 | "ESHzHi": 6010, 257 | "ESHzLo": 5950, 258 | "ESDlyTms": 300, 259 | "ESRndTms": 100, 260 | "ESRmpTms": 60, 261 | "V_SF": -3, 262 | "Hz_SF": -2 263 | }, 264 | { 265 | "ID": 704, 266 | "L": null, 267 | "PFWInjEna": 0, 268 | "PFWInjEnaRvrt": null, 269 | "PFWInjRvrtTms": null, 270 | "PFWInjRvrtRem": null, 271 | "PFWAbsEna": 0, 272 | "PFWAbsEnaRvrt": null, 273 | "PFWAbsRvrtTms": null, 274 | "PFWAbsRvrtRem": null, 275 | "WMaxLimEna": 0, 276 | "WMaxLim": 1000, 277 | "WMaxLimRvrt": null, 278 | "WMaxLimEnaRvrt": null, 279 | "WMaxLimRvrtTms": null, 280 | "WMaxLimRvrtRem": null, 281 | "WSetEna": null, 282 | "WSetMod": null, 283 | "WSet": null, 284 | "WSetRvrt": null, 285 | "WSetPct": null, 286 | "WSetPctRvrt": null, 287 | "WSetEnaRvrt": null, 288 | "WSetRvrtTms": null, 289 | "WSetRvrtRem": null, 290 | "VarSetEna": null, 291 | "VarSetMod": null, 292 | "VarSetPri": null, 293 | "VarSet": null, 294 | "VarSetRvrt": null, 295 | "VarSetPct": null, 296 | "VarSetPctRvrt": null, 297 | "VarSetRvrtTms": null, 298 | "VarSetRvrtRem": null, 299 | "RGra": null, 300 | "PF_SF": -3, 301 | "WMaxLim_SF": -1, 302 | "WSet_SF": null, 303 | "WSetPct_SF": null, 304 | "VarSet_SF": null, 305 | "VarSetPct_SF": null, 306 | "PFWInj": { 307 | "PF": 950, 308 | "Ext": 1 309 | }, 310 | "PFWInjRvrt": { 311 | "PF": null, 312 | "Ext": null 313 | }, 314 | "PFWAbs": { 315 | "PF": null, 316 | "Ext": null 317 | }, 318 | "PFWAbsRvrt": { 319 | "PF": null, 320 | "Ext": null 321 | } 322 | }, 323 | { 324 | "ID": 705, 325 | "Ena": 1, 326 | "CrvSt": 1, 327 | "AdptCrvReq": 0, 328 | "AdptCrvRslt": 0, 329 | "NPt": 4, 330 | "NCrv": 3, 331 | "RvrtTms": 0, 332 | "RvrtRem": 0, 333 | "RvrtCrv": 0, 334 | "V_SF": -2, 335 | "DeptRef_SF": -2, 336 | "Crv": [ 337 | { 338 | "ActPt": 4, 339 | "DeptRef": 1, 340 | "Pri": 1, 341 | "VRef": 1, 342 | "VRefAuto": 0, 343 | "VRefTms": 5, 344 | "RspTms": 6, 345 | "ReadOnly": 1, 346 | "Pt": [ 347 | { 348 | "V": 9200, 349 | "Var": 3000 350 | }, 351 | { 352 | "V": 9670, 353 | "Var": 0 354 | }, 355 | { 356 | "V": 10300, 357 | "Var": 0 358 | }, 359 | { 360 | "V": 10700, 361 | "Var": -3000 362 | } 363 | ] 364 | }, 365 | { 366 | "ActPt": 4, 367 | "DeptRef": 1, 368 | "Pri": 1, 369 | "VRef": 1, 370 | "VRefAuto": 0, 371 | "VRefTms": 5, 372 | "RspTms": 6, 373 | "ReadOnly": 0, 374 | "Pt": [ 375 | { 376 | "V": 9300, 377 | "Var": 3000 378 | }, 379 | { 380 | "V": 9570, 381 | "Var": 0 382 | }, 383 | { 384 | "V": 10200, 385 | "Var": 0 386 | }, 387 | { 388 | "V": 10600, 389 | "Var": -4000 390 | } 391 | ] 392 | }, 393 | { 394 | "ActPt": 4, 395 | "DeptRef": 1, 396 | "Pri": 1, 397 | "VRef": 1, 398 | "VRefAuto": 0, 399 | "VRefTms": 5, 400 | "RspTms": 6, 401 | "ReadOnly": 0, 402 | "Pt": [ 403 | { 404 | "V": 9400, 405 | "Var": 2000 406 | }, 407 | { 408 | "V": 9570, 409 | "Var": 0 410 | }, 411 | { 412 | "V": 10500, 413 | "Var": 0 414 | }, 415 | { 416 | "V": 10800, 417 | "Var": -2000 418 | } 419 | ] 420 | } 421 | ] 422 | }, 423 | { 424 | "ID": 706, 425 | "Ena": 0, 426 | "CrvSt": 1, 427 | "AdptCrvReq": 0, 428 | "AdptCrvRslt": 0, 429 | "NPt": 2, 430 | "NCrv": 2, 431 | "RvrtTms": null, 432 | "RvrtRem": null, 433 | "RvrtCrv": null, 434 | "V_SF": 0, 435 | "DeptRef_SF": 0, 436 | "Crv": [ 437 | { 438 | "ActPt": 2, 439 | "DeptRef": 1, 440 | "RspTms": 10, 441 | "ReadOnly": 1, 442 | "Pt": [ 443 | { 444 | "V": 106, 445 | "W": 100 446 | }, 447 | { 448 | "V": 110, 449 | "W": 0 450 | } 451 | ] 452 | }, 453 | { 454 | "ActPt": 2, 455 | "DeptRef": 1, 456 | "RspTms": 5, 457 | "ReadOnly": 0, 458 | "Pt": [ 459 | { 460 | "V": 105, 461 | "W": 100 462 | }, 463 | { 464 | "V": 109, 465 | "W": 0 466 | } 467 | ] 468 | } 469 | ] 470 | }, 471 | { 472 | "ID": 707, 473 | "L": null, 474 | "Ena": 1, 475 | "CrvSt": null, 476 | "AdptCrvReq": null, 477 | "AdptCrvRslt": null, 478 | "NPt": 1, 479 | "NCrvSet": 1, 480 | "V_SF": -2, 481 | "Tms_SF": 0, 482 | "Crv": [ 483 | { 484 | "MustTrip": { 485 | "ActPt": 1, 486 | "Pt": [ 487 | { 488 | "V": 5000, 489 | "Tms": 5 490 | } 491 | ] 492 | }, 493 | "MayTrip": { 494 | "ActPt": 1, 495 | "Pt": [ 496 | { 497 | "V": 7000, 498 | "Tms": 5 499 | } 500 | ] 501 | }, 502 | "MomCess": { 503 | "ActPt": 1, 504 | "Pt": [ 505 | { 506 | "V": 6000, 507 | "Tms": 5 508 | } 509 | ] 510 | } 511 | } 512 | ] 513 | }, 514 | { 515 | "ID": 708, 516 | "L": null, 517 | "Ena": 1, 518 | "CrvSt": null, 519 | "AdptCrvReq": null, 520 | "AdptCrvRslt": null, 521 | "NPt": 1, 522 | "NCrvSet": 1, 523 | "V_SF": -2, 524 | "Tms_SF": 0, 525 | "Crv": [ 526 | { 527 | "MustTrip": { 528 | "ActPt": 1, 529 | "Pt": [ 530 | { 531 | "V": 12000, 532 | "Tms": 5 533 | } 534 | ] 535 | }, 536 | "MayTrip": { 537 | "ActPt": 1, 538 | "Pt": [ 539 | { 540 | "V": 10000, 541 | "Tms": 5 542 | } 543 | ] 544 | }, 545 | "MomCess": { 546 | "ActPt": 1, 547 | "Pt": [ 548 | { 549 | "V": 10000, 550 | "Tms": 5 551 | } 552 | ] 553 | } 554 | } 555 | ] 556 | }, 557 | { 558 | "ID": 709, 559 | "L": null, 560 | "Ena": 1, 561 | "CrvSt": null, 562 | "AdptCrvReq": null, 563 | "AdptCrvRslt": null, 564 | "NPt": 1, 565 | "NCrvSet": 1, 566 | "Freq_SF": null, 567 | "Tms_SF": -2, 568 | "Crv": [ 569 | { 570 | "MustTrip": { 571 | "ActPt": 1, 572 | "Pt": [ 573 | { 574 | "Freq": 5300, 575 | "Tms": 5 576 | } 577 | ] 578 | }, 579 | "MayTrip": { 580 | "ActPt": 1, 581 | "Pt": [ 582 | { 583 | "Freq": 5850, 584 | "Tms": 5 585 | } 586 | ] 587 | }, 588 | "MomCess": { 589 | "ActPt": 1, 590 | "Pt": [ 591 | { 592 | "Freq": 5850, 593 | "Tms": 5 594 | } 595 | ] 596 | } 597 | } 598 | ] 599 | }, 600 | { 601 | "ID": 710, 602 | "L": null, 603 | "Ena": null, 604 | "CrvSt": null, 605 | "AdptCrvReq": null, 606 | "AdptCrvRslt": null, 607 | "NPt": 1, 608 | "NCrvSet": 1, 609 | "Freq_SF": null, 610 | "Tms_SF": -2, 611 | "Crv": [ 612 | { 613 | "MustTrip": { 614 | "ActPt": 1, 615 | "Pt": [ 616 | { 617 | "Freq": 6500, 618 | "Tms": 5 619 | } 620 | ] 621 | }, 622 | "MayTrip": { 623 | "ActPt": 1, 624 | "Pt": [ 625 | { 626 | "Freq": 6050, 627 | "Tms": 5 628 | } 629 | ] 630 | }, 631 | "MomCess": { 632 | "ActPt": 1, 633 | "Pt": [ 634 | { 635 | "Freq": 6050, 636 | "Tms": 5 637 | } 638 | ] 639 | } 640 | } 641 | ] 642 | }, 643 | { 644 | "ID": 711, 645 | "L": null, 646 | "Ena": null, 647 | "CrvSt": null, 648 | "AdptCrvReq": null, 649 | "AdptCrvRslt": null, 650 | "NCtl": 1, 651 | "RvrtTms": 0, 652 | "RvrtRem": 0, 653 | "RvrtCrv": 0, 654 | "Db_SF": -2, 655 | "K_SF": -2, 656 | "RspTms_SF": 0, 657 | "Ctl": [ 658 | { 659 | "DbOf": 60030, 660 | "DbUf": 59970, 661 | "KOf": 40, 662 | "KUf": 40, 663 | "RspTms": 600 664 | } 665 | ] 666 | }, 667 | { 668 | "ID": 712, 669 | "L": null, 670 | "Ena": null, 671 | "CrvSt": null, 672 | "AdptCrvReq": null, 673 | "AdptCrvRslt": null, 674 | "NPt": 1, 675 | "NCrv": 1, 676 | "RvrtTms": 0, 677 | "RvrtRem": 0, 678 | "RvrtCrv": 0, 679 | "DeptRef_SF": -2, 680 | "Crv": [ 681 | { 682 | "ActPt": 1, 683 | "DeptRef": null, 684 | "Pri": null, 685 | "ReadOnly": null, 686 | "Pt": [ 687 | { 688 | "W": null, 689 | "Var": null 690 | } 691 | ] 692 | } 693 | ] 694 | }, 695 | { 696 | "ID": 160, 697 | "L": null, 698 | "Evt": 0, 699 | "N": 2, 700 | "TmsPer": 123, 701 | "module": [ 702 | { 703 | "ID": 1, 704 | "IDStr": "MPPT 1", 705 | "DCA": 90, 706 | "DCV": 900, 707 | "DCW": 900, 708 | "DCWH": 1, 709 | "Tms": 123, 710 | "Tmp": 20, 711 | "DCSt": 4, 712 | "DCEvt": 0 713 | }, 714 | { 715 | "ID": 2, 716 | "IDStr": "MPPT 2", 717 | "DCA": 92, 718 | "DCV": 920, 719 | "DCW": 920, 720 | "DCWH": 2, 721 | "Tms": 223, 722 | "Tmp": 22, 723 | "DCSt": 3, 724 | "DCEvt": 0 725 | } 726 | ] 727 | }, 728 | { 729 | "ID": 65535, 730 | "L": 0 731 | } 732 | ] 733 | } 734 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test SunSpec setup process.""" 2 | 3 | from homeassistant.config_entries import ConfigEntryState 4 | from homeassistant.exceptions import ConfigEntryNotReady 5 | import pytest 6 | from pytest_homeassistant_custom_component.common import MockConfigEntry 7 | 8 | from custom_components.sunspec import SunSpecDataUpdateCoordinator 9 | from custom_components.sunspec import async_reload_entry 10 | from custom_components.sunspec import async_setup_entry 11 | from custom_components.sunspec import async_unload_entry 12 | from custom_components.sunspec.const import DOMAIN 13 | 14 | from . import setup_mock_sunspec_config_entry 15 | from .const import MOCK_CONFIG 16 | 17 | 18 | # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture 19 | # for a given test. We can also leverage fixtures and mocks that are available in 20 | # Home Assistant using the pytest_homeassistant_custom_component plugin. 21 | # Assertions allow you to verify that the return value of whatever is on the left 22 | # side of the assertion matches with the right side. 23 | async def test_setup_unload_and_reload_entry( 24 | hass, bypass_get_data, sunspec_client_mock 25 | ): 26 | """Test entry setup and unload.""" 27 | # Create a mock entry so we don't have to go through config flow 28 | config_entry = MockConfigEntry( 29 | domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", state=ConfigEntryState.LOADED 30 | ) 31 | 32 | # Set up the entry and assert that the values set during setup are where we expect 33 | # them to be. Because we have patched the SunSpecDataUpdateCoordinator.async_get_data 34 | # call, no code from custom_components/sunspec/api.py actually runs. 35 | assert await async_setup_entry(hass, config_entry) 36 | assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] 37 | assert ( 38 | type(hass.data[DOMAIN][config_entry.entry_id]) is SunSpecDataUpdateCoordinator 39 | ) 40 | 41 | # Reload the entry and assert that the data from above is still there 42 | assert await async_reload_entry(hass, config_entry) is None 43 | assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] 44 | assert ( 45 | type(hass.data[DOMAIN][config_entry.entry_id]) is SunSpecDataUpdateCoordinator 46 | ) 47 | 48 | # Unload the entry and verify that the data has been removed 49 | assert await async_unload_entry(hass, config_entry) 50 | assert config_entry.entry_id not in hass.data[DOMAIN] 51 | 52 | 53 | async def test_setup_entry_exception(hass, error_on_get_data): 54 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 55 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 56 | 57 | # In this case we are testing the condition where async_setup_entry raises 58 | # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates 59 | # an error. 60 | with pytest.raises(ConfigEntryNotReady): 61 | assert await async_setup_entry(hass, config_entry) 62 | 63 | 64 | async def test_fetch_data_timeout(hass, timeout_error_on_get_data): 65 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 66 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 67 | 68 | # In this case we are testing the condition where async_setup_entry raises 69 | # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates 70 | # an error. 71 | with pytest.raises(ConfigEntryNotReady): 72 | assert await async_setup_entry(hass, config_entry) 73 | 74 | 75 | async def test_fetch_data_connect_error(hass, connect_error_on_get_data): 76 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 77 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 78 | 79 | # In this case we are testing the condition where async_setup_entry raises 80 | # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates 81 | # an error. 82 | with pytest.raises(ConfigEntryNotReady): 83 | assert await async_setup_entry(hass, config_entry) 84 | 85 | 86 | async def test_client_reconnect(hass, sunspec_client_mock_not_connected) -> None: 87 | await setup_mock_sunspec_config_entry(hass, MOCK_CONFIG) 88 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Test SunSpec sensor.""" 2 | 3 | from homeassistant.core import HomeAssistant 4 | 5 | from custom_components.sunspec.sensor import ICON_DC_AMPS 6 | 7 | from . import TEST_INVERTER_MM_SENSOR_POWER_ENTITY_ID 8 | from . import TEST_INVERTER_MM_SENSOR_STATE_ENTITY_ID 9 | from . import TEST_INVERTER_PREFIX_SENSOR_DC_ENTITY_ID 10 | from . import TEST_INVERTER_SENSOR_DC_ENTITY_ID 11 | from . import TEST_INVERTER_SENSOR_ENERGY_ENTITY_ID 12 | from . import TEST_INVERTER_SENSOR_POWER_ENTITY_ID 13 | from . import TEST_INVERTER_SENSOR_STATE_ENTITY_ID 14 | from . import TEST_INVERTER_SENSOR_VAR_ID 15 | from . import setup_mock_sunspec_config_entry 16 | from .const import MOCK_CONFIG_MM 17 | from .const import MOCK_CONFIG_PREFIX 18 | 19 | 20 | async def test_sensor_overflow_error( 21 | hass: HomeAssistant, sunspec_client_mock, overflow_error_dca 22 | ) -> None: 23 | """Verify device information includes expected details.""" 24 | 25 | await setup_mock_sunspec_config_entry(hass) 26 | 27 | entity_state = hass.states.get(TEST_INVERTER_SENSOR_DC_ENTITY_ID) 28 | assert entity_state 29 | 30 | 31 | async def test_sensor_dc(hass: HomeAssistant, sunspec_client_mock) -> None: 32 | """Verify device information includes expected details.""" 33 | 34 | await setup_mock_sunspec_config_entry(hass) 35 | 36 | entity_state = hass.states.get(TEST_INVERTER_SENSOR_DC_ENTITY_ID) 37 | assert entity_state 38 | assert entity_state.attributes["icon"] == ICON_DC_AMPS 39 | 40 | 41 | async def test_sensor_var(hass: HomeAssistant, sunspec_client_mock) -> None: 42 | """Verify device information includes expected details.""" 43 | 44 | await setup_mock_sunspec_config_entry(hass) 45 | 46 | entity_state = hass.states.get(TEST_INVERTER_SENSOR_VAR_ID) 47 | assert entity_state 48 | 49 | 50 | async def test_sensor_with_prefix(hass: HomeAssistant, sunspec_client_mock) -> None: 51 | """Verify device information includes expected details.""" 52 | 53 | await setup_mock_sunspec_config_entry(hass, MOCK_CONFIG_PREFIX) 54 | 55 | entity_state = hass.states.get(TEST_INVERTER_PREFIX_SENSOR_DC_ENTITY_ID) 56 | assert entity_state 57 | 58 | 59 | async def test_sensor_state(hass: HomeAssistant, sunspec_client_mock) -> None: 60 | """Verify device information includes expected details.""" 61 | 62 | await setup_mock_sunspec_config_entry(hass) 63 | 64 | entity_state = hass.states.get(TEST_INVERTER_SENSOR_STATE_ENTITY_ID) 65 | assert entity_state 66 | assert entity_state.state == "MPPT" 67 | 68 | 69 | async def test_sensor_power(hass: HomeAssistant, sunspec_client_mock) -> None: 70 | """Verify device information includes expected details.""" 71 | 72 | await setup_mock_sunspec_config_entry(hass) 73 | 74 | entity_state = hass.states.get(TEST_INVERTER_SENSOR_POWER_ENTITY_ID) 75 | assert entity_state 76 | assert entity_state.state == "800" 77 | 78 | 79 | async def test_sensor_energy(hass: HomeAssistant, sunspec_client_mock) -> None: 80 | """Verify device information includes expected details.""" 81 | 82 | await setup_mock_sunspec_config_entry(hass) 83 | 84 | entity_state = hass.states.get(TEST_INVERTER_SENSOR_ENERGY_ENTITY_ID) 85 | assert entity_state 86 | assert entity_state.state == "100000" 87 | 88 | 89 | async def test_sensor_state_mm(hass: HomeAssistant, sunspec_client_mock) -> None: 90 | """Verify device information includes expected details.""" 91 | 92 | await setup_mock_sunspec_config_entry(hass, MOCK_CONFIG_MM) 93 | 94 | entity_state = hass.states.get(TEST_INVERTER_MM_SENSOR_STATE_ENTITY_ID) 95 | assert entity_state 96 | assert entity_state.state == "OFF" 97 | 98 | 99 | async def test_sensor_power_mm(hass: HomeAssistant, sunspec_client_mock) -> None: 100 | """Verify device information includes expected details.""" 101 | 102 | await setup_mock_sunspec_config_entry(hass, MOCK_CONFIG_MM) 103 | 104 | entity_state = hass.states.get(TEST_INVERTER_MM_SENSOR_POWER_ENTITY_ID) 105 | assert entity_state 106 | assert entity_state.state == "9700" 107 | --------------------------------------------------------------------------------