├── config ├── scenes.yaml ├── scripts.yaml ├── configuration.yaml └── automations.yaml ├── requirements_dev.txt ├── tests ├── __init__.py ├── test_enable.py ├── test_templating.py ├── conftest.py ├── commons.py ├── test_underlying_change.py ├── test_max_on_time.py ├── test_battery.py ├── test_config_flow.py └── test_priority.py ├── custom_components ├── __init__.py └── solar_optimizer │ ├── services.yaml │ ├── manifest.json │ ├── __init__.py │ ├── select.py │ ├── config_schema.py │ ├── const.py │ ├── config_flow.py │ ├── coordinator.py │ ├── switch.py │ ├── sensor.py │ └── simulated_annealing_algo.py ├── setup.cfg ├── images ├── icon.png ├── tips.png ├── icon@2x.png ├── logos.xcf ├── new-icon.png ├── add-card-1.png ├── add-card-2.png ├── add-card-3.png ├── add-card-4.png ├── logos-fusion.xcf ├── use-card-1.png ├── use-card-2.png ├── use-card-3.png ├── dashboard-edit.png ├── lovelace-eqts.png ├── config-add-device.png ├── dashboard-edit2.png ├── dashboard-edit3.png ├── dashboard-edit4.png ├── entities-priority.png ├── event-listening.png ├── config-device-type.png ├── entities-attributes.png ├── use-card-blue-check.png ├── use-card-blue-moon.png ├── use-card-red-cancel.png ├── config-simple-device.png ├── entities-configuration.png ├── entities-simple-device.png ├── entity-priority-weight.png ├── use-card-green-check.png ├── use-card-orange-check.png ├── config-common-parameters.png ├── install-hacs-streamline.png └── run-action-reset-on-time.png ├── .devcontainer ├── Dockerfile ├── devcontainer.json └── configuration.yaml ├── requirements_test.txt ├── scripts ├── start_coverage.sh └── starts_ha.sh ├── pyproject.toml ├── hacs.json ├── .pylintrc ├── .github ├── workflows │ ├── hacs.yml │ ├── cron.yaml │ ├── push.yml │ ├── release.yaml │ ├── testus.yaml │ └── pull.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── CONTRIBUTING.md ├── CONTRIBUTING-fr.md └── .gitignore /config/scenes.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/scripts.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant==2025.9.3 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ To make this repo a module """ 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """ To make this repo a module """ 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | asyncio_mode = auto -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | /workspaces/solar_optimizer/.devcontainer/configuration.yaml -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/icon.png -------------------------------------------------------------------------------- /images/tips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/tips.png -------------------------------------------------------------------------------- /images/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/icon@2x.png -------------------------------------------------------------------------------- /images/logos.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/logos.xcf -------------------------------------------------------------------------------- /images/new-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/new-icon.png -------------------------------------------------------------------------------- /images/add-card-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/add-card-1.png -------------------------------------------------------------------------------- /images/add-card-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/add-card-2.png -------------------------------------------------------------------------------- /images/add-card-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/add-card-3.png -------------------------------------------------------------------------------- /images/add-card-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/add-card-4.png -------------------------------------------------------------------------------- /images/logos-fusion.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/logos-fusion.xcf -------------------------------------------------------------------------------- /images/use-card-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-1.png -------------------------------------------------------------------------------- /images/use-card-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-2.png -------------------------------------------------------------------------------- /images/use-card-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-3.png -------------------------------------------------------------------------------- /images/dashboard-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/dashboard-edit.png -------------------------------------------------------------------------------- /images/lovelace-eqts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/lovelace-eqts.png -------------------------------------------------------------------------------- /images/config-add-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/config-add-device.png -------------------------------------------------------------------------------- /images/dashboard-edit2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/dashboard-edit2.png -------------------------------------------------------------------------------- /images/dashboard-edit3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/dashboard-edit3.png -------------------------------------------------------------------------------- /images/dashboard-edit4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/dashboard-edit4.png -------------------------------------------------------------------------------- /images/entities-priority.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/entities-priority.png -------------------------------------------------------------------------------- /images/event-listening.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/event-listening.png -------------------------------------------------------------------------------- /images/config-device-type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/config-device-type.png -------------------------------------------------------------------------------- /images/entities-attributes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/entities-attributes.png -------------------------------------------------------------------------------- /images/use-card-blue-check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-blue-check.png -------------------------------------------------------------------------------- /images/use-card-blue-moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-blue-moon.png -------------------------------------------------------------------------------- /images/use-card-red-cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-red-cancel.png -------------------------------------------------------------------------------- /images/config-simple-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/config-simple-device.png -------------------------------------------------------------------------------- /images/entities-configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/entities-configuration.png -------------------------------------------------------------------------------- /images/entities-simple-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/entities-simple-device.png -------------------------------------------------------------------------------- /images/entity-priority-weight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/entity-priority-weight.png -------------------------------------------------------------------------------- /images/use-card-green-check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-green-check.png -------------------------------------------------------------------------------- /images/use-card-orange-check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/use-card-orange-check.png -------------------------------------------------------------------------------- /images/config-common-parameters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/config-common-parameters.png -------------------------------------------------------------------------------- /images/install-hacs-streamline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/install-hacs-streamline.png -------------------------------------------------------------------------------- /images/run-action-reset-on-time.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmcollin78/solar_optimizer/HEAD/images/run-action-reset-on-time.png -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:1-3.13 2 | RUN apt update && apt install -y ffmpeg libturbojpeg0-dev 3 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | # Strictly for tests 2 | -r requirements_dev.txt 3 | aiodiscover 4 | ulid_transform 5 | pytest 6 | coverage 7 | pytest-asyncio 8 | pytest-homeassistant-custom-component -------------------------------------------------------------------------------- /scripts/start_coverage.sh: -------------------------------------------------------------------------------- 1 | rm -rf htmlcov/* 2 | echo "Starting coverage tests" 3 | coverage run -m pytest tests/ 4 | echo "Starting coverage report" 5 | coverage report 6 | echo "Starting coverage html" 7 | coverage html -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | # work ! 3 | line-length = 180 4 | 5 | [tool.pytest.ini_options] 6 | asyncio_mode = "auto" 7 | asyncio_default_fixture_loop_scope = "function" 8 | markers = ["no_parallel: Tests that must not be run in parallel"] -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Solar Optimizer", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "hide_default_branch": false, 6 | "filename": "solar_optimizer.zip", 7 | "zip_release": true, 8 | "homeassistant": "2025.9.3" 9 | } -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | [FORMAT] 4 | max-line-length=180 5 | 6 | [TYPECHECK] 7 | 8 | [REPORTS] 9 | 10 | [BASIC] 11 | 12 | [DESIGN] 13 | 14 | [CLASSES] 15 | 16 | [IMPORTS] 17 | 18 | [EXCEPTIONS] 19 | 20 | [FUNCTIONS] 21 | 22 | [VARIABLES] 23 | error -------------------------------------------------------------------------------- /custom_components/solar_optimizer/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | name: Reload 3 | description: Reload Solar Optimizer configuration 4 | 5 | reset_on_time: 6 | name: Reset on time 7 | description: Reset all on time for all devices 8 | target: 9 | entity: 10 | integration: solar_optimizer 11 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" -------------------------------------------------------------------------------- /.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 | "name": "Home Assistant (debug)", 7 | "type": "debugpy", 8 | "request": "launch", 9 | "module": "homeassistant", 10 | "justMyCode": false, 11 | "args": ["--debug", "-c", "config"] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "solar_optimizer", 3 | "name": "Solar Optimizer", 4 | "codeowners": [ 5 | "@jmcollin78" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/jmcollin78/solar_optimizer", 9 | "integration_type": "device", 10 | "iot_class": "local_polling", 11 | "issue_tracker": "https://github.com/jmcollin78/solar_optimizer/issues", 12 | "quality_scale": "silver", 13 | "version": "0.0.0" 14 | } -------------------------------------------------------------------------------- /.github/workflows/cron.yaml: -------------------------------------------------------------------------------- 1 | name: Cron actions 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | validate: 10 | runs-on: "ubuntu-latest" 11 | name: Validate 12 | steps: 13 | - uses: "actions/checkout@v3.5.2" 14 | 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | 20 | - name: Hassfest validation 21 | uses: "home-assistant/actions/hassfest@master" 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter", 4 | "editor.formatOnSave": true, 5 | "editor.formatOnSaveMode": "modifications" 6 | }, 7 | "files.associations": { 8 | "*.yaml": "home-assistant" 9 | }, 10 | "python.testing.pytestArgs": [], 11 | "python.testing.unittestEnabled": false, 12 | "python.testing.pytestEnabled": true, 13 | "python.analysis.extraPaths": [ 14 | "/workspaces/solar_optimizer/custom_components/solar_optimizer", 15 | "/home/vscode/.local/lib/python3.13/site-packages/homeassistant" 16 | ] 17 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.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": "scripts/starts_ha.sh", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Restart Home Assistant on port 9123", 12 | "type": "shell", 13 | "command": "pkill hass ; scripts/starts_ha.sh", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Start coverage", 18 | "type": "shell", 19 | "command": "scripts/start_coverage.sh", 20 | "problemMatcher": [] 21 | }, 22 | ] 23 | } -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push actions 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | name: Validate 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | 16 | - name: HACS validation 17 | uses: "hacs/action@main" 18 | with: 19 | category: "integration" 20 | ignore: brands 21 | 22 | - name: Hassfest validation 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | style: 26 | runs-on: "ubuntu-latest" 27 | name: Check style formatting 28 | steps: 29 | - uses: "actions/checkout@v3" 30 | - uses: "actions/setup-python@v4.6.0" 31 | with: 32 | python-version: "3.x" 33 | - run: python3 -m pip install black 34 | - run: black . 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | id-token: write 10 | 11 | jobs: 12 | release: 13 | name: Prepare release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: "Set version number" 18 | run: | 19 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 20 | "${{ github.workspace }}/custom_components/solar_optimizer/manifest.json" 21 | # Pack the HACS dir as a zip and upload to the release 22 | - name: ZIP Solar Optimizer Dir 23 | run: | 24 | cd "${{ github.workspace }}/custom_components/solar_optimizer" 25 | zip solar_optimizer.zip -r ./ 26 | - name: Upload zip to release 27 | uses: softprops/action-gh-release@v2.2.1 28 | with: 29 | files: ${{ github.workspace }}/custom_components/solar_optimizer/solar_optimizer.zip 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 16 | 17 | ## Version of the custom_component 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | 26 | Add your logs here. 27 | 28 | ``` 29 | 30 | ## Describe the bug 31 | A clear and concise description of what the bug is. 32 | 33 | 34 | ## Debug log 35 | 36 | 37 | 38 | ```text 39 | 40 | Add your logs here. 41 | 42 | ``` -------------------------------------------------------------------------------- /.github/workflows/testus.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | testu: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.13 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip3 install -r requirements_test.txt 26 | 27 | - name: Run Tests 28 | run: | 29 | pytest \ 30 | -qq \ 31 | --timeout=9 \ 32 | --durations=10 \ 33 | -n auto \ 34 | -o console_output_style=count \ 35 | -p no:sugar \ 36 | tests 37 | 38 | - name: Coverage 39 | run: | 40 | coverage run -m pytest tests/ 41 | coverage report 42 | 43 | - name: Generate HTML Coverage Report 44 | run: coverage html 45 | -------------------------------------------------------------------------------- /scripts/starts_ha.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | cd "$(dirname "$0")/.." 7 | pwd 8 | 9 | # Create config dir if not present 10 | if [[ ! -d "${PWD}/config" ]]; then 11 | mkdir -p "${PWD}/config" 12 | # Add defaults configuration 13 | hass --config "${PWD}/config" --script ensure_config 14 | fi 15 | 16 | # Overwrite configuration.yaml if provided 17 | if [ -f ${PWD}/.devcontainer/configuration.yaml ]; then 18 | rm -f ${PWD}/config/configuration.yaml 19 | ln -s ${PWD}/.devcontainer/configuration.yaml ${PWD}/config/configuration.yaml 20 | fi 21 | 22 | # Set the path to custom_components 23 | ## This let's us have the structure we want /custom_components/integration_blueprint 24 | ## while at the same time have Home Assistant configuration inside /config 25 | ## without resulting to symlinks. 26 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 27 | 28 | ## Link custom_components into config 29 | # rm -f ${PWD}/config/custom_components 30 | # ln -s ${PWD}/custom_components ${PWD}/config/ 31 | 32 | # Start Home Assistant 33 | hass --config "${PWD}/config" --debug -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jean-Marc Collin 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 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: Pull actions 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | validate: 8 | runs-on: "ubuntu-latest" 9 | name: Validate 10 | steps: 11 | - uses: "actions/checkout@v3.5.2" 12 | 13 | - name: HACS validation 14 | uses: "hacs/action@main" 15 | with: 16 | category: "integration" 17 | ignore: brands 18 | 19 | - name: Hassfest validation 20 | uses: "home-assistant/actions/hassfest@master" 21 | 22 | style: 23 | runs-on: "ubuntu-latest" 24 | name: Check style formatting 25 | steps: 26 | - uses: "actions/checkout@v3.5.2" 27 | - uses: "actions/setup-python@v4.6.0" 28 | with: 29 | python-version: "3.x" 30 | - run: python3 -m pip install black 31 | - run: black . 32 | 33 | tests: 34 | # Tests don't run in Gitlab ci environment 35 | if: 0 36 | runs-on: "ubuntu-latest" 37 | name: Run tests 38 | steps: 39 | - name: Check out code from GitHub 40 | uses: "actions/checkout@v3" 41 | - name: Setup Python 42 | uses: "actions/setup-python@v4.6.0" 43 | with: 44 | python-version: "3.8" 45 | - name: Install requirements 46 | run: python3 -m pip install -r requirements_test.txt 47 | - name: Run tests 48 | run: | 49 | pytest \ 50 | -qq \ 51 | --timeout=9 \ 52 | --durations=10 \ 53 | -n auto \ 54 | --cov custom_components.integration_blueprint \ 55 | -o console_output_style=count \ 56 | -p no:sugar \ 57 | tests 58 | -------------------------------------------------------------------------------- /config/automations.yaml: -------------------------------------------------------------------------------- 1 | - id: '1686422006482' 2 | alias: Gestion des events de Solar Optimizer 3 | description: '' 4 | triggers: 5 | - event_type: solar_optimizer_change_power_event 6 | id: power_event 7 | trigger: event 8 | - event_type: solar_optimizer_state_change_event 9 | id: state_change 10 | trigger: event 11 | - entity_id: 12 | - input_number.consommation_brut 13 | - input_number.production_solaire 14 | - input_number.batterie_charging_power 15 | trigger: state 16 | id: power_change 17 | conditions: [] 18 | actions: 19 | - choose: 20 | - conditions: 21 | - condition: trigger 22 | id: power_event 23 | sequence: 24 | - data: 25 | message: '{{ trigger.event.data.action_type }} pour entité {{ trigger.event.data.entity_id}} avec 26 | requested_power {{ trigger.event.data.requested_power }}. (current_power 27 | is {{ trigger.event.data.current_power }})' 28 | title: ChangePower Event de Solar Optimizer 29 | action: persistent_notification.create 30 | - conditions: 31 | - condition: trigger 32 | id: state_change 33 | sequence: 34 | - data: 35 | message: '{{ trigger.event.data.action_type }} pour entité {{ trigger.event.data.entity_id}} avec 36 | requested_power {{ trigger.event.data.requested_power }}. (current_power 37 | is {{ trigger.event.data.current_power }})' 38 | title: StateChange Event de Solar Optimizer 39 | action: persistent_notification.create 40 | - data: 41 | value: '{{ states(''input_number.consommation_brut'') | float(0) - states(''input_number.production_solaire'') 42 | | float(0) + states(''sensor.total_power'') | float(0) + states(''input_number.batterie_charging_power'') 43 | | float(0)}}' 44 | target: 45 | entity_id: input_number.consommation_net 46 | action: input_number.set_value 47 | mode: parallel 48 | max: 50 49 | -------------------------------------------------------------------------------- /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) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on best practices described here [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. -------------------------------------------------------------------------------- /tests/test_enable.py: -------------------------------------------------------------------------------- 1 | """ Test the "enable" flag """ 2 | # from unittest.mock import patch 3 | # from datetime import datetime 4 | 5 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 6 | 7 | from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import 8 | 9 | 10 | async def test_set_enable( 11 | hass: HomeAssistant, 12 | init_solar_optimizer_central_config, 13 | ): 14 | """Testing set_enable feature""" 15 | 16 | entry_a = MockConfigEntry( 17 | domain=DOMAIN, 18 | title="Equipement A", 19 | unique_id="eqtAUniqueId", 20 | data={ 21 | CONF_NAME: "Equipement A", 22 | CONF_DEVICE_TYPE: CONF_DEVICE, 23 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 24 | CONF_POWER_MAX: 1000, 25 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 26 | CONF_DURATION_MIN: 0.3, 27 | CONF_DURATION_STOP_MIN: 0.1, 28 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 29 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 30 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 31 | CONF_BATTERY_SOC_THRESHOLD: 30, 32 | CONF_MAX_ON_TIME_PER_DAY_MIN: 10, 33 | }, 34 | ) 35 | 36 | device = await create_managed_device( 37 | hass, 38 | entry_a, 39 | "equipement_a", 40 | ) 41 | assert device is not None 42 | assert device.name == "Equipement A" 43 | 44 | # 45 | # Disable the device by simulating a call into the switch enable sensor 46 | # 47 | enable_switch = search_entity( 48 | hass, "switch.enable_solar_optimizer_equipement_a", SWITCH_DOMAIN 49 | ) 50 | assert enable_switch is not None 51 | 52 | device_switch = search_entity( 53 | hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN 54 | ) 55 | assert device_switch is not None 56 | 57 | assert enable_switch.state == "on" 58 | # The state of the underlying switch 59 | assert device_switch.state == "off" 60 | # The enable state should be True 61 | assert device_switch.get_attr_extra_state_attributes.get("is_enabled") is True 62 | 63 | await enable_switch.async_turn_off() 64 | await hass.async_block_till_done() 65 | 66 | assert enable_switch.state == "off" 67 | assert device.is_enabled is False 68 | # The enable state should be True 69 | assert device_switch.get_attr_extra_state_attributes.get("is_enabled") is False 70 | 71 | # 72 | # Enable the switch device 73 | # 74 | await enable_switch.async_turn_on() 75 | await hass.async_block_till_done() 76 | 77 | assert enable_switch.state == "on" 78 | assert device.is_enabled is True 79 | assert device_switch.state == "off" 80 | # The enable state should be False 81 | assert device_switch.get_attr_extra_state_attributes.get("is_enabled") is True 82 | -------------------------------------------------------------------------------- /CONTRIBUTING-fr.md: -------------------------------------------------------------------------------- 1 | # Consignes de contribution 2 | 3 | Contribuer à ce projet doit être aussi simple et transparent que possible, que ce soit : 4 | 5 | - Signaler un bug 6 | - Discuter de l'état actuel du code 7 | - Soumettre un correctif 8 | - Proposer de nouvelles fonctionnalités 9 | 10 | ## Github est utilisé pour tout 11 | 12 | Github est utilisé pour héberger du code, pour suivre les problèmes et les demandes de fonctionnalités, ainsi que pour accepter les demandes d'extraction. 13 | 14 | Les demandes d'extraction sont le meilleur moyen de proposer des modifications à la base de code. 15 | 16 | 1. Fourchez le dépôt et créez votre branche à partir de `master`. 17 | 2. Si vous avez modifié quelque chose, mettez à jour la documentation. 18 | 3. Assurez-vous que votre code peluche (en utilisant du noir). 19 | 4. Testez votre contribution. 20 | 5. Émettez cette pull request ! 21 | 22 | ## Toutes les contributions que vous ferez seront sous la licence logicielle MIT 23 | 24 | En bref, lorsque vous soumettez des modifications de code, vos soumissions sont considérées comme étant sous la même [licence MIT](http://choosealicense.com/licenses/mit/) qui couvre le projet. N'hésitez pas à contacter les mainteneurs si cela vous préoccupe. 25 | 26 | ## Signaler les bogues en utilisant les [issues] de Github (../../issues) 27 | 28 | Les problèmes GitHub sont utilisés pour suivre les bogues publics. 29 | Signalez un bogue en [ouvrant un nouveau problème](../../issues/new/choose) ; C'est si facile! 30 | 31 | ## Rédiger des rapports de bogue avec des détails, un arrière-plan et un exemple de code 32 | 33 | Les **rapports de bogues géniaux** ont tendance à avoir : 34 | 35 | - Un résumé rapide et/ou un historique 36 | - Étapes à reproduire 37 | - Être spécifique! 38 | - Donnez un exemple de code si vous le pouvez. 39 | - Ce à quoi vous vous attendiez arriverait 40 | - Que se passe-t-il réellement 41 | - Notes (y compris éventuellement pourquoi vous pensez que cela pourrait se produire, ou des choses que vous avez essayées qui n'ont pas fonctionné) 42 | 43 | Les gens *adorent* les rapports de bogues approfondis. Je ne plaisante même pas. 44 | 45 | ## Utilisez un style de codage cohérent 46 | 47 | Utilisez [black](https://github.com/ambv/black) pour vous assurer que le code suit le style. 48 | 49 | ## Testez votre modification de code 50 | 51 | Ce composant personnalisé est basé sur les meilleures pratiques décrites ici [modèle d'intégration_blueprint](https://github.com/custom-components/integration_blueprint). 52 | 53 | Il est livré avec un environnement de développement dans un conteneur, facile à lancer 54 | si vous utilisez Visual Studio Code. Avec ce conteneur, vous aurez un stand alone 55 | Instance de Home Assistant en cours d'exécution et déjà configurée avec le inclus 56 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 57 | déposer. 58 | 59 | ## Licence 60 | 61 | En contribuant, vous acceptez que vos contributions soient autorisées sous sa licence MIT. -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Solar Optimizer", 5 | "build": { 6 | "dockerfile": "Dockerfile" 7 | }, 8 | "appPort": [ 9 | "9123:8123" 10 | ], 11 | "postCreateCommand": "pip install --upgrade pip ; pip install -r requirements_test.txt", 12 | 13 | "mounts": [ 14 | "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" 15 | ], 16 | 17 | "customizations": { 18 | "vscode": { 19 | "extensions": [ 20 | "ms-python.python", 21 | "ms-python.pylint", 22 | "ms-python.isort", 23 | "ms-python.black-formatter", 24 | "visualstudioexptteam.vscodeintellicode", 25 | "redhat.vscode-yaml", 26 | "github.vscode-pull-request-github", 27 | "ryanluker.vscode-coverage-gutters", 28 | "ferrierbenjamin.fold-unfold-all-icone", 29 | "donjayamanne.githistory", 30 | "waderyan.gitblame", 31 | "keesschollaart.vscode-home-assistant", 32 | "vscode.markdown-math", 33 | "yzhang.markdown-all-in-one", 34 | "github.vscode-github-actions", 35 | "azuretools.vscode-docker", 36 | "huizhou.githd", 37 | "github.copilot", 38 | "github.copilot-chat", 39 | "ms-azuretools.vscode-docker", 40 | "openai.chatgpt" 41 | 42 | ], 43 | 44 | "settings": { 45 | "files.eol": "\n", 46 | "editor.tabSize": 4, 47 | "terminal.integrated.profiles.linux": { 48 | "bash": { 49 | "path": "bash", 50 | "args": [] 51 | } 52 | }, 53 | "terminal.integrated.defaultProfile.linux": "bash", 54 | // "terminal.integrated.shell.linux": "/bin/bash", 55 | "python.pythonPath": "/usr/bin/python3", 56 | "python.analysis.autoSearchPaths": true, 57 | "pylint.lintOnChange": false, 58 | "python.formatting.provider": "black", 59 | "black-formatter.args": [ 60 | "--line-length", 61 | "180" 62 | ], 63 | // "python.formatting.blackArgs": ["--line-length", "180"], 64 | "black-formatter.path": ["/usr/local/py-utils/bin/black"], 65 | // "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 66 | "editor.formatOnPaste": false, 67 | "editor.formatOnSave": true, 68 | "editor.formatOnType": true, 69 | "files.trimTrailingWhitespace": true 70 | // "python.experiments.optOutFrom": ["pythonTestAdapter"], 71 | // "python.analysis.logLevel": "Trace" 72 | } 73 | } 74 | } 75 | 76 | // Features to add to the dev container. More info: https://containers.dev/features. 77 | // "features": {}, 78 | 79 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 80 | // "forwardPorts": [], 81 | 82 | // Use 'postCreateCommand' to run commands after the container is created. 83 | // "postCreateCommand": "pip3 install --user -r requirements.txt", 84 | 85 | // Configure tool-specific properties. 86 | // "customizations": {}, 87 | 88 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 89 | // "remoteUser": "root" 90 | } 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | config/** 163 | custom_components/versatile_thermostat 164 | custom_components/hacs 165 | custom_components/homeconnect_ws -------------------------------------------------------------------------------- /tests/test_templating.py: -------------------------------------------------------------------------------- 1 | """ Test all templating features """ 2 | from unittest.mock import patch, call 3 | 4 | from homeassistant.helpers.template import Template 5 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 6 | import pytest 7 | 8 | from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import 9 | 10 | @pytest.mark.parametrize( 11 | "input_value, expected_output, is_template, expect_exception", 12 | [ 13 | ("{{ 1 }}", 1, True, False), 14 | ("{% set a = 1 %}{{ a }}", 1, True, False), 15 | ("1", 1, False, False), 16 | ("1.0", 1.0, False, False), 17 | ("True", True, False, False), 18 | ("False", False, False, False), 19 | ("None", None, False, False), 20 | ("test", "test", False, True), 21 | (1, 1, False, False), 22 | (1.0, 1.0, False, False), 23 | (True, True, False, False), 24 | (False, False, False, False), 25 | (None, None, False, False), 26 | ], 27 | ) 28 | async def test_templating_conversion(hass: HomeAssistant, input_value, expected_output, is_template, expect_exception): 29 | """Test the convert_to_template_or_value function""" 30 | if expect_exception: 31 | with pytest.raises(ValueError): 32 | convert_to_template_or_value(hass, input_value) 33 | else: 34 | value = convert_to_template_or_value(hass, input_value) 35 | assert isinstance(value, Template) == is_template 36 | assert get_template_or_value(hass, value) == expected_output 37 | 38 | @pytest.mark.parametrize( 39 | "power_max, battery_soc_threshold, max_on_time_per_day_min, min_on_time_per_day_min, power_max_value, battery_soc_threshold_value, max_on_time_per_day_min_value, min_on_time_per_day_min_value", 40 | [ 41 | (1000, 30, 10, 5, 1000, 30, 10, 5), 42 | ("{{ 1000 }}", "{{ 30 }}", "{{ 10 }}", "{{ 5 }}", 1000, 30, 10, 5), 43 | ("{{ 1000 }}", "{{ 30 }}", 10, "{{ 5 }}", 1000, 30, 10, 5), 44 | (1000, "{{ 30 }}", "{{ 10 }}", 5, 1000, 30, 10, 5), 45 | ("{% if 1 > 2 %}{{ 10 }}{% else %}{{ 0 }}{% endif %}", "{{ '30' | float(0) }}", "{{ 'coucou' | float(10) }}", "{{ 5 }}", 0, 30, 10, 5), 46 | ], 47 | ) 48 | async def tests_device_with_template( 49 | hass: HomeAssistant, 50 | init_solar_optimizer_central_config, 51 | power_max, 52 | battery_soc_threshold, 53 | max_on_time_per_day_min, 54 | min_on_time_per_day_min, 55 | power_max_value, 56 | battery_soc_threshold_value, 57 | max_on_time_per_day_min_value, 58 | min_on_time_per_day_min_value, 59 | ): 60 | """" Test the creation of a device with template """ 61 | entry_a = MockConfigEntry( 62 | domain=DOMAIN, 63 | title="Equipement A", 64 | unique_id="eqtAUniqueId", 65 | data={ 66 | CONF_NAME: "Equipement A", 67 | CONF_DEVICE_TYPE: CONF_DEVICE, 68 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 69 | CONF_POWER_MAX: power_max, 70 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 71 | CONF_DURATION_MIN: 0.3, 72 | CONF_DURATION_STOP_MIN: 0.1, 73 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 74 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 75 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 76 | CONF_BATTERY_SOC_THRESHOLD: battery_soc_threshold, 77 | CONF_MAX_ON_TIME_PER_DAY_MIN: max_on_time_per_day_min, 78 | CONF_MIN_ON_TIME_PER_DAY_MIN: min_on_time_per_day_min, 79 | CONF_OFFPEAK_TIME: "06:00", 80 | }, 81 | ) 82 | 83 | device = await create_managed_device( 84 | hass, 85 | entry_a, 86 | "equipement_a", 87 | ) 88 | 89 | assert device is not None 90 | assert device.name == "Equipement A" 91 | assert device.power_max == power_max_value 92 | assert device.battery_soc_threshold == battery_soc_threshold_value 93 | assert device.max_on_time_per_day_sec == max_on_time_per_day_min_value * 60 94 | assert device.min_on_time_per_day_sec == min_on_time_per_day_min_value * 60 -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for integration_blueprint integration.""" 2 | # Fixtures allow you to replace functions with a Mock object. You can perform 3 | # many options via the Mock to reflect a particular behavior from the original 4 | # function that you want to see without going through the function's actual logic. 5 | # Fixtures can either be passed into tests as parameters, or if autouse=True, they 6 | # will automatically be used across all tests. 7 | # 8 | # Fixtures that are defined in conftest.py are available across all tests. You can also 9 | # define fixtures within a particular test file to scope them locally. 10 | # 11 | # pytest_homeassistant_custom_component provides some fixtures that are provided by 12 | # Home Assistant core. You can find those fixture definitions here: 13 | # https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py 14 | # 15 | # See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that 16 | # pytest includes fixtures OOB which you can use as defined on this page) 17 | from unittest.mock import patch 18 | 19 | import pytest 20 | 21 | from homeassistant.setup import async_setup_component 22 | from homeassistant.config_entries import ConfigEntryState 23 | from homeassistant.core import StateMachine 24 | 25 | from pytest_homeassistant_custom_component.common import MockConfigEntry 26 | 27 | from custom_components.solar_optimizer.const import * # pylint: disable=wildcard-import, unused-wildcard-import 28 | from custom_components.solar_optimizer.coordinator import SolarOptimizerCoordinator 29 | from .commons import create_managed_device 30 | 31 | # from homeassistant.core import StateMachine 32 | 33 | pytest_plugins = "pytest_homeassistant_custom_component" # pylint: disable=invalid-name 34 | 35 | 36 | # This fixture enables loading custom integrations in all tests. 37 | # Remove to enable selective use of this fixture 38 | @pytest.fixture(autouse=True) 39 | def auto_enable_custom_integrations(enable_custom_integrations): # pylint: disable=unused-argument 40 | """Enable all integration in tests""" 41 | yield 42 | 43 | 44 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 45 | # notifications. These calls would fail without this fixture since the persistent_notification 46 | # integration is never loaded during a test. 47 | @pytest.fixture(name="skip_notifications", autouse=True) 48 | def skip_notifications_fixture(): 49 | """Skip notification calls.""" 50 | with patch("homeassistant.components.persistent_notification.async_create"), patch( 51 | "homeassistant.components.persistent_notification.async_dismiss" 52 | ): 53 | yield 54 | 55 | 56 | @pytest.fixture(name="skip_hass_states_get") 57 | def skip_hass_states_get_fixture(): 58 | """Skip the get state in HomeAssistant""" 59 | with patch.object(StateMachine, "get"): 60 | yield 61 | 62 | 63 | @pytest.fixture(name="reset_coordinator") 64 | def reset_coordinator_fixture(): 65 | """Reset the coordinator""" 66 | SolarOptimizerCoordinator.reset() 67 | yield 68 | 69 | @pytest.fixture(name="init_solar_optimizer_entry") 70 | async def init_solar_optimizer_entry(hass): 71 | """ Initialization of the integration from an Entry """ 72 | entry = MockConfigEntry( 73 | domain=DOMAIN, 74 | title="TheSolarOptimizer", 75 | unique_id="uniqueId", 76 | data={}, 77 | ) 78 | 79 | entry.add_to_hass(hass) 80 | await hass.config_entries.async_setup(entry.entry_id) 81 | await hass.async_block_till_done() 82 | 83 | assert entry.state is ConfigEntryState.LOADED 84 | 85 | 86 | @pytest.fixture(name="init_solar_optimizer_central_config") 87 | async def init_solar_optimizer_central_config(hass): 88 | """Initialization of the integration from an Entry""" 89 | entry_central = MockConfigEntry( 90 | domain=DOMAIN, 91 | title="Central", 92 | unique_id="centralUniqueId", 93 | data={ 94 | CONF_NAME: "Configuration", 95 | CONF_REFRESH_PERIOD_SEC: 60, 96 | CONF_DEVICE_TYPE: CONF_DEVICE_CENTRAL, 97 | CONF_POWER_CONSUMPTION_ENTITY_ID: "sensor.fake_power_consumption", 98 | CONF_POWER_PRODUCTION_ENTITY_ID: "sensor.fake_power_production", 99 | CONF_SELL_COST_ENTITY_ID: "input_number.fake_sell_cost", 100 | CONF_BUY_COST_ENTITY_ID: "input_number.fake_buy_cost", 101 | CONF_SELL_TAX_PERCENT_ENTITY_ID: "input_number.fake_sell_tax_percent", 102 | CONF_SMOOTH_PRODUCTION: True, 103 | CONF_BATTERY_SOC_ENTITY_ID: "sensor.fake_battery_soc", 104 | CONF_BATTERY_CHARGE_POWER_ENTITY_ID: "sensor.fake_battery_charge_power", 105 | CONF_RAZ_TIME: "05:00", 106 | }, 107 | ) 108 | device_central = await create_managed_device( 109 | hass, 110 | entry_central, 111 | "centralUniqueId", 112 | ) 113 | -------------------------------------------------------------------------------- /tests/commons.py: -------------------------------------------------------------------------------- 1 | """ Some common resources """ 2 | # pylint: disable=unused-import 3 | 4 | import asyncio 5 | import logging 6 | from typing import Any, Dict, Callable 7 | from unittest.mock import patch, MagicMock 8 | import pytest # pylint: disable=unused-import 9 | 10 | from homeassistant.const import STATE_ON, STATE_OFF 11 | from homeassistant.core import HomeAssistant, Event, EVENT_STATE_CHANGED, State 12 | from homeassistant.config_entries import ConfigEntryState 13 | from homeassistant.helpers.entity import Entity 14 | 15 | from homeassistant.helpers.entity_component import EntityComponent 16 | from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN 17 | from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN 18 | from homeassistant.setup import async_setup_component 19 | from pytest_homeassistant_custom_component.common import MockConfigEntry 20 | 21 | from custom_components.solar_optimizer.const import * # pylint: disable=wildcard-import, unused-wildcard-import 22 | from custom_components.solar_optimizer.coordinator import SolarOptimizerCoordinator 23 | from custom_components.solar_optimizer.managed_device import ManagedDevice 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | async def create_managed_device( 29 | hass: HomeAssistant, 30 | entry: MockConfigEntry, 31 | entity_id: str, 32 | ) -> ManagedDevice: 33 | """Creates and return a ManagedDevice""" 34 | entry.add_to_hass(hass) 35 | await hass.config_entries.async_setup(entry.entry_id) 36 | assert entry.state is ConfigEntryState.LOADED 37 | 38 | coordinator: SolarOptimizerCoordinator = SolarOptimizerCoordinator.get_coordinator() 39 | assert coordinator is not None 40 | 41 | return coordinator.get_device_by_unique_id(entity_id) 42 | 43 | 44 | def search_entity(hass: HomeAssistant, entity_id, domain) -> Entity: 45 | """Search and return the entity in the domain""" 46 | # component = hass.data["components"].domain #hass.data[domain] 47 | 48 | component = hass.data["entity_components"].get(domain) 49 | for entity in component.entities: 50 | if entity.entity_id == entity_id: 51 | return entity 52 | return None 53 | 54 | async def send_state_change(hass, entity_id, old_state, new_state, date, sleep=False): 55 | """ Send a state change event for an entity """ 56 | _LOGGER.info( 57 | "------- Testu: sending send_change_event, new_state=%s old_state=%s date=%s on %s", 58 | new_state, 59 | old_state, 60 | date, 61 | entity_id, 62 | ) 63 | event = { 64 | "entity_id": entity_id, 65 | "new_state": State( 66 | entity_id=entity_id, 67 | state=STATE_ON if new_state else STATE_OFF, 68 | last_changed=date, 69 | last_updated=date, 70 | ), 71 | "old_state": State( 72 | entity_id=entity_id, 73 | state=STATE_ON if old_state else STATE_OFF, 74 | last_changed=date, 75 | last_updated=date, 76 | ), 77 | } 78 | hass.bus.fire( 79 | event_type=EVENT_STATE_CHANGED, 80 | event_data=event 81 | ) 82 | if sleep: 83 | await asyncio.sleep(0.1) 84 | 85 | async def create_test_input_boolean(hass, entity_id, name): 86 | """ Creation of an input_boolean """ 87 | 88 | # Remove domain prefix if present in entity_id 89 | if "." in entity_id: 90 | entity_id = entity_id.split(".", 1)[1] 91 | 92 | config = {"input_boolean": {entity_id: {"name": name, "icon": "mdi:window-closed-variant"}}} 93 | await async_setup_component(hass, INPUT_BOOLEAN_DOMAIN, config) 94 | return search_entity(hass, INPUT_BOOLEAN_DOMAIN + "." + entity_id, INPUT_BOOLEAN_DOMAIN) 95 | 96 | 97 | async def create_test_input_number(hass, entity_id, name): 98 | """Creation of an input_number""" 99 | 100 | if "." in entity_id: 101 | entity_id = entity_id.split(".", 1)[1] 102 | 103 | config = {"input_number": {entity_id: {"name": name, "min": 0, "max": 9999, "initial": 0, "icon": "mdi:numeric"}}} 104 | await async_setup_component(hass, INPUT_NUMBER_DOMAIN, config) 105 | return search_entity(hass, INPUT_NUMBER_DOMAIN + "." + entity_id, INPUT_NUMBER_DOMAIN) 106 | 107 | 108 | # 109 | # Side effects management 110 | # 111 | SideEffectDict = Dict[str, Any] 112 | 113 | 114 | class SideEffects: 115 | """A class to manage sideEffects for mock""" 116 | 117 | def __init__(self, side_effects: SideEffectDict, default_side_effect: Any): 118 | """Initialise the side effects""" 119 | self._current_side_effects: SideEffectDict = side_effects 120 | self._default_side_effect: Any = default_side_effect 121 | 122 | def get_side_effects(self) -> Callable[[str], Any]: 123 | """returns the method which apply the side effects""" 124 | 125 | def side_effect_method(arg) -> Any: 126 | """Search a side effect definition and return it""" 127 | return self._current_side_effects.get(arg, self._default_side_effect) 128 | 129 | return side_effect_method 130 | 131 | def add_or_update_side_effect(self, key: str, new_value: Any): 132 | """Update the value of a side effect""" 133 | self._current_side_effects[key] = new_value 134 | -------------------------------------------------------------------------------- /tests/test_underlying_change.py: -------------------------------------------------------------------------------- 1 | """ Test the "enable" flag """ 2 | # from unittest.mock import patch 3 | # from datetime import datetime 4 | 5 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 6 | from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN 7 | 8 | from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import 9 | 10 | 11 | async def test_underlying_state_change( 12 | hass: HomeAssistant, init_solar_optimizer_central_config 13 | ): 14 | """Testing underlying state change""" 15 | 16 | entry_a = MockConfigEntry( 17 | domain=DOMAIN, 18 | title="Equipement A", 19 | unique_id="eqtAUniqueId", 20 | data={ 21 | CONF_NAME: "Equipement A", 22 | CONF_DEVICE_TYPE: CONF_DEVICE, 23 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 24 | CONF_POWER_MAX: 1000, 25 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 26 | CONF_DURATION_MIN: 2, 27 | CONF_DURATION_STOP_MIN: 1, 28 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 29 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 30 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 31 | CONF_BATTERY_SOC_THRESHOLD: 30, 32 | CONF_MAX_ON_TIME_PER_DAY_MIN: 10, 33 | CONF_MIN_ON_TIME_PER_DAY_MIN: 5, 34 | CONF_OFFPEAK_TIME: "23:00", 35 | }, 36 | ) 37 | 38 | device = await create_managed_device( 39 | hass, 40 | entry_a, 41 | "equipement_a", 42 | ) 43 | assert device is not None 44 | 45 | assert device.name == "Equipement A" 46 | assert device.is_enabled is True 47 | assert device.is_enabled is True 48 | 49 | # Creates the fake input_boolean 50 | await create_test_input_boolean(hass, device.entity_id, "fake underlying A") 51 | 52 | fake_input_bool = search_entity(hass, "input_boolean.fake_device_a", INPUT_BOOLEAN_DOMAIN) 53 | assert fake_input_bool is not None 54 | 55 | # 56 | # Check initial 'active' state 57 | # 58 | device_switch = search_entity( 59 | hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN 60 | ) 61 | assert device_switch is not None 62 | 63 | assert device.is_active is False 64 | # The state of the underlying switch 65 | assert device_switch.state == "off" 66 | # The enable state should be True 67 | assert device_switch.get_attr_extra_state_attributes.get("is_active") is False 68 | 69 | # 70 | # Send a state change to ON for the underlying switch 71 | # 72 | await fake_input_bool.async_turn_on() 73 | await hass.async_block_till_done() 74 | 75 | # The state of the underlying switch 76 | # The state should be True 77 | assert device_switch.state == "on" 78 | assert device.is_active is True 79 | assert device_switch.get_attr_extra_state_attributes.get("is_active") is True 80 | 81 | # 82 | # Send a state change to OFF for the underlying switch 83 | # 84 | await fake_input_bool.async_turn_off() 85 | await hass.async_block_till_done() 86 | 87 | # The state of the underlying switch 88 | # The state should be True 89 | assert device_switch.state == "off" 90 | assert device.is_active is False 91 | assert device_switch.get_attr_extra_state_attributes.get("is_active") is False 92 | 93 | 94 | async def test_underlying_state_initialize(hass, init_solar_optimizer_central_config): 95 | """ Test the initialization of underlying device """ 96 | 97 | # Creates the fake input_boolean 98 | device_id="input_boolean.fake_device_a" 99 | await create_test_input_boolean(hass, device_id, "fake underlying A") 100 | 101 | fake_input_bool = search_entity(hass, device_id, INPUT_BOOLEAN_DOMAIN) 102 | assert fake_input_bool is not None 103 | 104 | await fake_input_bool.async_turn_on() 105 | await hass.async_block_till_done() 106 | 107 | entry_a = MockConfigEntry( 108 | domain=DOMAIN, 109 | title="Equipement A", 110 | unique_id="eqtAUniqueId", 111 | data={ 112 | CONF_NAME: "Equipement A", 113 | CONF_DEVICE_TYPE: CONF_DEVICE, 114 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 115 | CONF_POWER_MAX: 1000, 116 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 117 | CONF_DURATION_MIN: 2, 118 | CONF_DURATION_STOP_MIN: 1, 119 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 120 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 121 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 122 | CONF_BATTERY_SOC_THRESHOLD: 30, 123 | CONF_MAX_ON_TIME_PER_DAY_MIN: 10, 124 | CONF_MIN_ON_TIME_PER_DAY_MIN: 5, 125 | CONF_OFFPEAK_TIME: "23:00", 126 | }, 127 | ) 128 | 129 | device = await create_managed_device( 130 | hass, 131 | entry_a, 132 | "equipement_a", 133 | ) 134 | assert device is not None 135 | 136 | assert device.name == "Equipement A" 137 | assert device.is_enabled is True 138 | assert device.is_active is True 139 | 140 | # 141 | # Creates the entry manually (after underlying change) 142 | # 143 | entry = MockConfigEntry( 144 | domain=DOMAIN, 145 | title="TheSolarOptimizer", 146 | unique_id="uniqueId", 147 | data={}, 148 | ) 149 | 150 | entry.add_to_hass(hass) 151 | await hass.config_entries.async_setup(entry.entry_id) 152 | await hass.async_block_till_done() 153 | assert entry.state is ConfigEntryState.LOADED 154 | 155 | # 156 | # test the device_switch is on 157 | # 158 | device_switch = search_entity( 159 | hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN 160 | ) 161 | assert device_switch is not None 162 | assert device_switch.state == "on" 163 | assert device_switch.get_attr_extra_state_attributes.get("is_active") is True 164 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialisation du package de l'intégration HACS Tuto""" 2 | 3 | import logging 4 | import asyncio 5 | import voluptuous as vol 6 | 7 | from homeassistant.const import EVENT_HOMEASSISTANT_START, SERVICE_RELOAD 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.setup import async_setup_component 10 | 11 | 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.helpers.typing import ConfigType 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers import selector 16 | 17 | from homeassistant.helpers.service import async_register_admin_service 18 | from homeassistant.helpers.reload import ( 19 | async_setup_reload_service, 20 | async_reload_integration_platforms, 21 | _resetup_platform, 22 | ) 23 | 24 | 25 | from .const import ( 26 | DOMAIN, 27 | PLATFORMS, 28 | CONFIG_VERSION, 29 | CONFIG_MINOR_VERSION, 30 | SERVICE_RESET_ON_TIME, 31 | validate_time_format, 32 | name_to_unique_id, 33 | CONF_NAME, 34 | CONF_POWER_MAX, 35 | CONF_BATTERY_SOC_THRESHOLD, 36 | CONF_MAX_ON_TIME_PER_DAY_MIN, 37 | CONF_MIN_ON_TIME_PER_DAY_MIN, 38 | ) 39 | from .coordinator import SolarOptimizerCoordinator 40 | 41 | # from .input_boolean import async_setup_entry as async_setup_entry_input_boolean 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | CONFIG_SCHEMA = vol.Schema( 46 | { 47 | DOMAIN: vol.Schema( 48 | { 49 | "algorithm": vol.Schema( 50 | { 51 | vol.Required("initial_temp", default=1000): vol.Coerce(float), 52 | vol.Required("min_temp", default=0.1): vol.Coerce(float), 53 | vol.Required("cooling_factor", default=0.95): vol.Coerce(float), 54 | vol.Required( 55 | "max_iteration_number", default=1000 56 | ): cv.positive_int, 57 | } 58 | ), 59 | } 60 | ), 61 | }, 62 | extra=vol.ALLOW_EXTRA, 63 | ) 64 | 65 | 66 | async def async_setup( 67 | hass: HomeAssistant, config: ConfigType 68 | ): # pylint: disable=unused-argument 69 | """Initialisation de l'intégration""" 70 | _LOGGER.info( 71 | "Initializing %s integration with plaforms: %s with config: %s", 72 | DOMAIN, 73 | PLATFORMS, 74 | config.get(DOMAIN), 75 | ) 76 | 77 | hass.data.setdefault(DOMAIN, {}) 78 | 79 | # L'argument config contient votre fichier configuration.yaml 80 | solar_optimizer_config = config.get(DOMAIN) 81 | 82 | hass.data[DOMAIN]["coordinator"] = coordinator = SolarOptimizerCoordinator( 83 | hass, solar_optimizer_config 84 | ) 85 | 86 | async def _handle_reload(_): 87 | """The reload callback""" 88 | await reload_config(hass) 89 | 90 | async_register_admin_service( 91 | hass, 92 | DOMAIN, 93 | SERVICE_RELOAD, 94 | _handle_reload, 95 | ) 96 | 97 | await async_setup_reload_service(hass, DOMAIN, PLATFORMS) 98 | 99 | hass.bus.async_listen_once("homeassistant_started", coordinator.on_ha_started) 100 | return True 101 | 102 | 103 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 104 | """Creation des entités à partir d'une configEntry""" 105 | 106 | _LOGGER.debug( 107 | "Appel de async_setup_entry entry: entry_id='%s', data='%s'", 108 | entry.entry_id, 109 | entry.data, 110 | ) 111 | 112 | hass.data.setdefault(DOMAIN, {}) 113 | 114 | # Enregistrement de l'écouteur de changement 'update_listener' 115 | entry.async_on_unload(entry.add_update_listener(update_listener)) 116 | 117 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 118 | 119 | return True 120 | 121 | 122 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 123 | """Fonction qui force le rechargement des entités associées à une configEntry""" 124 | await hass.config_entries.async_reload(entry.entry_id) 125 | 126 | 127 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 128 | """Handle removal of an entry.""" 129 | if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 130 | if (coordinator := SolarOptimizerCoordinator.get_coordinator()) is not None: 131 | coordinator.remove_device(name_to_unique_id(entry.data[CONF_NAME])) 132 | # hass.data[DOMAIN].pop(entry.entry_id) 133 | return unloaded 134 | 135 | 136 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 137 | """Reload config entry.""" 138 | await async_unload_entry(hass, entry) 139 | await async_setup_entry(hass, entry) 140 | 141 | 142 | # Migration function (not used yet) 143 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): 144 | """Migrate old entry.""" 145 | _LOGGER.debug( 146 | "Migrating from version %s/%s", config_entry.version, config_entry.minor_version 147 | ) 148 | 149 | if config_entry.version == CONFIG_VERSION and config_entry.minor_version == 0: 150 | _LOGGER.debug("Migration from version 0 to %s/%s is needed", CONFIG_VERSION, CONFIG_MINOR_VERSION) 151 | new = {**config_entry.data} 152 | for key in (CONF_POWER_MAX, CONF_BATTERY_SOC_THRESHOLD, CONF_MAX_ON_TIME_PER_DAY_MIN, CONF_MIN_ON_TIME_PER_DAY_MIN): 153 | if key in new: 154 | new[key] = str(new[key]) 155 | 156 | hass.config_entries.async_update_entry( 157 | config_entry, 158 | data=new, 159 | version=CONFIG_VERSION, 160 | minor_version=CONFIG_MINOR_VERSION, 161 | ) 162 | 163 | _LOGGER.info( 164 | "Migration to version %s (%s) successful", 165 | config_entry.version, 166 | config_entry.minor_version, 167 | ) 168 | 169 | return True 170 | 171 | 172 | async def reload_config(hass): 173 | """Handle reload service call.""" 174 | _LOGGER.info("Service %s.reload called: reloading integration", DOMAIN) 175 | 176 | # await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) 177 | # await _resetup_platform(hass, DOMAIN, DOMAIN, None) 178 | await async_setup_component(hass, DOMAIN, None) 179 | -------------------------------------------------------------------------------- /tests/test_max_on_time.py: -------------------------------------------------------------------------------- 1 | """ Nominal Unit test module""" 2 | 3 | # pylint: disable=protected-access 4 | 5 | # from unittest.mock import patch 6 | from datetime import timedelta 7 | 8 | 9 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 10 | from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN 11 | from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN 12 | 13 | 14 | from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import 15 | 16 | 17 | async def test_max_on_time_calculation( 18 | hass: HomeAssistant, init_solar_optimizer_central_config 19 | ): 20 | """Testing on_time calculation change""" 21 | 22 | entry_a = MockConfigEntry( 23 | domain=DOMAIN, 24 | title="Equipement A", 25 | unique_id="eqtAUniqueId", 26 | data={ 27 | CONF_NAME: "Equipement A", 28 | CONF_DEVICE_TYPE: CONF_DEVICE, 29 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 30 | CONF_POWER_MAX: 1000, 31 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 32 | CONF_DURATION_MIN: 0.3, 33 | CONF_DURATION_STOP_MIN: 0.1, 34 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 35 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 36 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 37 | CONF_BATTERY_SOC_THRESHOLD: 30, 38 | CONF_MAX_ON_TIME_PER_DAY_MIN: 10, 39 | }, 40 | ) 41 | 42 | device = await create_managed_device( 43 | hass, 44 | entry_a, 45 | "equipement_a", 46 | ) 47 | assert device is not None 48 | assert device.name == "Equipement A" 49 | 50 | assert device.is_enabled is True 51 | assert device.max_on_time_per_day_sec == 10 * 60 52 | 53 | # Creates the fake input_boolean 54 | await create_test_input_boolean(hass, device.entity_id, "fake underlying A") 55 | 56 | fake_input_bool = search_entity( 57 | hass, "input_boolean.fake_device_a", INPUT_BOOLEAN_DOMAIN 58 | ) 59 | assert fake_input_bool is not None 60 | 61 | # 62 | # Get the swotch and on_time_sensor 63 | # 64 | device_switch = search_entity( 65 | hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN 66 | ) 67 | 68 | device_on_time_sensor = search_entity( 69 | hass, "sensor.on_time_today_solar_optimizer_equipement_a", SENSOR_DOMAIN 70 | ) 71 | assert device_switch is not None 72 | assert device.is_active is False 73 | # The state of the underlying switch 74 | assert device_switch.state == "off" 75 | # The enable state should be True 76 | assert device_switch.get_attr_extra_state_attributes.get("is_active") is False 77 | 78 | # 79 | # 1. check initial extra_attributes and state 80 | # 81 | assert ( 82 | device_switch.get_attr_extra_state_attributes.get("device_name") 83 | == "Equipement A" 84 | ) 85 | assert ( 86 | device_on_time_sensor.get_attr_extra_state_attributes.get("last_datetime_on") 87 | is None 88 | ) 89 | assert ( 90 | device_on_time_sensor.get_attr_extra_state_attributes.get( 91 | "max_on_time_per_day_sec" 92 | ) 93 | == 60 * 10 94 | ) 95 | assert ( 96 | device_on_time_sensor.get_attr_extra_state_attributes.get( 97 | "max_on_time_per_day_min" 98 | ) 99 | == 10 100 | ) 101 | assert ( 102 | device_on_time_sensor.get_attr_extra_state_attributes.get("on_time_hms") 103 | == "0:00" 104 | ) 105 | assert ( 106 | device_on_time_sensor.get_attr_extra_state_attributes.get("max_on_time_hms") 107 | == "10:00" 108 | ) 109 | 110 | # The on_time should be 0 111 | assert device_on_time_sensor.state == 0 112 | assert device_on_time_sensor.last_datetime_on is None 113 | 114 | # 115 | # 2. Activate the underlying switch 116 | # 117 | await fake_input_bool.async_turn_on() 118 | now = device.now 119 | device._set_now(now) 120 | 121 | await hass.async_block_till_done() 122 | 123 | # 124 | # 3. check We have start counting on_time 125 | # 126 | assert device_on_time_sensor.last_datetime_on is not None 127 | assert device_on_time_sensor.last_datetime_on == now 128 | assert device_on_time_sensor.state == 0 129 | 130 | # 131 | # 4. de-activate the underlying switch 132 | # Change now 133 | now = now + timedelta(seconds=13) 134 | device._set_now(now) 135 | 136 | await fake_input_bool.async_turn_off() 137 | await hass.async_block_till_done() 138 | 139 | # 140 | # 5. we should have increment counter and stop counting 141 | # 142 | assert device_on_time_sensor.last_datetime_on is None 143 | assert device_on_time_sensor.state == 13 144 | 145 | # 146 | # 6. reactivate and call on_update_on_time 147 | # 148 | # Change now 149 | now = now + timedelta(minutes=1) 150 | device._set_now(now) 151 | 152 | await fake_input_bool.async_turn_on() 153 | await hass.async_block_till_done() 154 | assert device_on_time_sensor.last_datetime_on == now 155 | assert device_on_time_sensor.state == 13 156 | 157 | # 158 | # 7. Simulate the call to the periodic _on_update_on_time 159 | # 160 | # Change now 161 | now = now + timedelta(seconds=55) 162 | device._set_now(now) 163 | 164 | await device_on_time_sensor._on_update_on_time() 165 | await hass.async_block_till_done() 166 | 167 | assert device_on_time_sensor.last_datetime_on == now 168 | assert device_on_time_sensor.state == 68 # 55+13 169 | 170 | # 171 | # 8. Call _at_midnight to reset the counter 172 | # Change now 173 | now = now + timedelta(hours=1) 174 | device._set_now(now) 175 | 176 | await device_on_time_sensor._on_midnight() 177 | await hass.async_block_till_done() 178 | 179 | assert device_on_time_sensor.last_datetime_on == now 180 | assert device_on_time_sensor.state == 0 181 | 182 | # 183 | # 9. Stop the switch -> it should count from last reset dateetime now 184 | # 185 | now = now + timedelta(minutes=12) 186 | device._set_now(now) 187 | 188 | await fake_input_bool.async_turn_off() 189 | await hass.async_block_till_done() 190 | 191 | assert device_on_time_sensor.last_datetime_on is None 192 | assert device_on_time_sensor.state == 12 * 60 193 | -------------------------------------------------------------------------------- /tests/test_battery.py: -------------------------------------------------------------------------------- 1 | """ Test the "is_usable" flag with battery """ 2 | 3 | from unittest.mock import patch, call 4 | 5 | # from datetime import datetime 6 | 7 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 8 | 9 | from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import 10 | from custom_components.solar_optimizer.coordinator import SolarOptimizerCoordinator 11 | 12 | async def test_is_usable( 13 | hass: HomeAssistant, 14 | init_solar_optimizer_central_config, 15 | ): 16 | """Testing is_usable feature""" 17 | entry_a = MockConfigEntry( 18 | domain=DOMAIN, 19 | title="Equipement A", 20 | unique_id="eqtAUniqueId", 21 | data={ 22 | CONF_NAME: "Equipement A", 23 | CONF_DEVICE_TYPE: CONF_DEVICE, 24 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 25 | CONF_POWER_MAX: 1000, 26 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 27 | CONF_DURATION_MIN: 0.3, 28 | CONF_DURATION_STOP_MIN: 0.1, 29 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 30 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 31 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 32 | CONF_BATTERY_SOC_THRESHOLD: 30, 33 | CONF_MAX_ON_TIME_PER_DAY_MIN: 10, 34 | }, 35 | ) 36 | 37 | device = await create_managed_device( 38 | hass, 39 | entry_a, 40 | "equipement_a", 41 | ) 42 | 43 | assert device is not None 44 | assert device.name == "Equipement A" 45 | device_switch = search_entity( 46 | hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN 47 | ) 48 | 49 | assert device_switch is not None 50 | 51 | assert ( 52 | device_switch.get_attr_extra_state_attributes.get("battery_soc_threshold") == 30 53 | ) 54 | 55 | # no soc set 56 | assert device.is_usable is True 57 | assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True 58 | 59 | device.set_battery_soc(20) 60 | # device A threshold is 30 61 | assert device.is_usable is False 62 | # Change state to force writing new state 63 | device_switch.update_custom_attributes(device) 64 | assert device_switch.get_attr_extra_state_attributes.get("is_usable") is False 65 | 66 | device.set_battery_soc(30) 67 | # device A threshold is 30 68 | assert device.is_usable is True 69 | device_switch.update_custom_attributes(device) 70 | assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True 71 | 72 | device.set_battery_soc(40) 73 | # device A threshold is 30 74 | assert device.is_usable is True 75 | device_switch.update_custom_attributes(device) 76 | assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True 77 | 78 | device.set_battery_soc(None) 79 | # device A threshold is 30 80 | assert device.is_usable is True 81 | device_switch.update_custom_attributes(device) 82 | assert device_switch.get_attr_extra_state_attributes.get("is_usable") is True 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "consumption_power, production_power, battery_charge_power, device_power_max, battery_soc, battery_soc_threshold, is_activated", 87 | [ 88 | # fmt: off 89 | # enough production + battery charge power 90 | ( -1000, 2000, -500, 1000, 10, 0, True), 91 | # not enough 92 | ( 1000, 2000, 500, 1000, 10, 0, False), 93 | # enough but battery_soc too low 94 | ( -1000, 2000, -500, 1000, 10, 20, False), 95 | # not enough production charge power 96 | ( -500, 2000, 0, 1000, 10, 0, False), 97 | # enough production with battery discharge charge power 98 | ( -500, 2000, -500, 1000, 10, 0, True), 99 | # fmt: on 100 | ], 101 | ) 102 | async def test_battery_power( 103 | hass: HomeAssistant, 104 | init_solar_optimizer_central_config, 105 | consumption_power, 106 | production_power, 107 | battery_charge_power, 108 | device_power_max, 109 | battery_soc, 110 | battery_soc_threshold, 111 | is_activated, 112 | ): 113 | """Testing battery power feature""" 114 | entry_a = MockConfigEntry( 115 | domain=DOMAIN, 116 | title="Equipement A", 117 | unique_id="eqtAUniqueId", 118 | data={ 119 | CONF_NAME: "Equipement A", 120 | CONF_DEVICE_TYPE: CONF_DEVICE, 121 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 122 | CONF_POWER_MAX: device_power_max, 123 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 124 | CONF_DURATION_MIN: 0.3, 125 | CONF_DURATION_STOP_MIN: 0.1, 126 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 127 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 128 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 129 | CONF_BATTERY_SOC_THRESHOLD: battery_soc_threshold, 130 | }, 131 | ) 132 | 133 | device = await create_managed_device( 134 | hass, 135 | entry_a, 136 | "equipement_a", 137 | ) 138 | 139 | assert device is not None 140 | assert device.name == "Equipement A" 141 | device_switch = search_entity( 142 | hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN 143 | ) 144 | 145 | assert device_switch is not None 146 | 147 | # starts the algorithm 148 | coordinator = SolarOptimizerCoordinator.get_coordinator() 149 | 150 | side_effects = SideEffects( 151 | { 152 | "sensor.fake_power_consumption": State( 153 | "sensor.fake_power_consumption", consumption_power 154 | ), 155 | "sensor.fake_power_production": State( 156 | "sensor.fake_power_production", production_power 157 | ), 158 | "sensor.fake_battery_charge_power": State( 159 | "sensor.fake_battery_charge_power", battery_charge_power 160 | ), 161 | "input_number.fake_sell_cost": State("input_number.fake_sell_cost", 1), 162 | "input_number.fake_buy_cost": State("input_number.fake_buy_cost", 1), 163 | "input_number.fake_sell_tax_percent": State( 164 | "input_number.fake_sell_tax_percent", 0 165 | ), 166 | "sensor.fake_battery_soc": State("sensor.fake_battery_soc", battery_soc), 167 | }, 168 | State("unknown.entity_id", "unknown"), 169 | ) 170 | 171 | # fmt:off 172 | with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()): 173 | # fmt:on 174 | calculated_data = await coordinator._async_update_data() 175 | 176 | assert calculated_data["total_power"] == (1000 if is_activated else 0) 177 | assert calculated_data["equipement_a"].is_waiting is is_activated 178 | 179 | assert calculated_data["best_solution"][0]["name"] == "Equipement A" 180 | assert calculated_data["best_solution"][0]["state"] is is_activated 181 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/select.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-argument 2 | 3 | """A select component for holding the priority""" 4 | import logging 5 | 6 | from homeassistant.core import HomeAssistant 7 | 8 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 9 | from homeassistant.components.select import SelectEntity 10 | from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.helpers.restore_state import RestoreEntity 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.components.select import SelectEntity, DOMAIN as SELECT_DOMAIN 15 | 16 | from .const import * # pylint: disable=wildcard-import, unused-wildcard-import 17 | from .coordinator import SolarOptimizerCoordinator 18 | from .managed_device import ManagedDevice 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | entry: ConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | ) -> None: 27 | """Set up the entries of type select for each ManagedDevice""" 28 | _LOGGER.debug( 29 | "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data 30 | ) 31 | 32 | coordinator: SolarOptimizerCoordinator = SolarOptimizerCoordinator.get_coordinator() 33 | 34 | entities = [] 35 | if entry.data[CONF_DEVICE_TYPE] == CONF_DEVICE_CENTRAL: 36 | # Central config, create the select entity which holds the priority weight 37 | entity = SolarOptimizerPriorityWeightSelect(hass, coordinator) 38 | async_add_entities([entity], False) 39 | return 40 | 41 | unique_id = name_to_unique_id(entry.data[CONF_NAME]) 42 | device = coordinator.get_device_by_unique_id(unique_id) 43 | if device is None: 44 | _LOGGER.error("Calling switch.async_setup_entry in error cause device with unique_id %s not found", unique_id) 45 | return 46 | 47 | entities = [ 48 | SolarOptimizerPrioritySelect(hass, coordinator, device), 49 | ] 50 | 51 | async_add_entities(entities) 52 | 53 | 54 | class SolarOptimizerPriorityWeightSelect(SelectEntity, RestoreEntity): 55 | """Representation of the central mode choice""" 56 | 57 | def __init__(self, hass: HomeAssistant, coordinator: SolarOptimizerCoordinator): 58 | """Initialize the PriorityWeightSelect entity.""" 59 | self._hass = hass 60 | self._coordinator = coordinator 61 | self._attr_name = "Priority weight" 62 | self.entity_id = f"{SELECT_DOMAIN}.solar_optimizer_priority_weight" 63 | self._attr_unique_id = "solar_optimizer_priority_weight" 64 | self._attr_options = PRIORITY_WEIGHTS 65 | self._attr_current_option = PRIORITY_WEIGHT_NULL 66 | 67 | @property 68 | def icon(self) -> str: 69 | return "mdi:weight" 70 | 71 | @property 72 | def device_info(self) -> DeviceInfo: 73 | """Return the device info.""" 74 | return DeviceInfo( 75 | entry_type=DeviceEntryType.SERVICE, 76 | identifiers={(DOMAIN, CONF_DEVICE_CENTRAL)}, 77 | name="Solar Optimizer", 78 | manufacturer=DEVICE_MANUFACTURER, 79 | model=INTEGRATION_MODEL, 80 | ) 81 | 82 | @overrides 83 | async def async_added_to_hass(self) -> None: 84 | await super().async_added_to_hass() 85 | 86 | old_state = await self.async_get_last_state() 87 | _LOGGER.debug( 88 | "%s - Calling async_added_to_hass old_state is %s", self, old_state 89 | ) 90 | if old_state is not None and old_state.state in PRIORITY_WEIGHTS: 91 | self._attr_current_option = old_state.state 92 | 93 | self._coordinator.set_priority_weight_entity(self) 94 | 95 | @overrides 96 | async def async_select_option(self, option: str) -> None: 97 | """Change the selected option.""" 98 | old_option = self._attr_current_option 99 | 100 | if option == old_option: 101 | return 102 | 103 | if option in PRIORITY_WEIGHTS: 104 | self._attr_current_option = option 105 | _LOGGER.info("%s - priority weight has been changed to %s", self, self._attr_current_option) 106 | 107 | @overrides 108 | def select_option(self, option: str) -> None: 109 | """Change the selected option""" 110 | self.hass.create_task(self.async_select_option(option)) 111 | 112 | @property 113 | def current_priority_weight(self) -> int: 114 | """Return the current weight""" 115 | # Map the current option string to its corresponding weight value 116 | return PRIORITY_WEIGHT_MAP.get(self._attr_current_option, 0) # Default to 0 if not found 117 | 118 | 119 | class SolarOptimizerPrioritySelect(SelectEntity, RestoreEntity): 120 | """Representation of the central mode choice""" 121 | 122 | def __init__(self, hass: HomeAssistant, coordinator: SolarOptimizerCoordinator, device: ManagedDevice): 123 | """Initialize the PriorityWeightSelect entity.""" 124 | self._hass = hass 125 | idx = name_to_unique_id(device.name) 126 | self._coordinator = coordinator 127 | self._device = device 128 | self._attr_name = "Priority" 129 | self._attr_has_entity_name = True 130 | self.entity_id = f"{SELECT_DOMAIN}.solar_optimizer_priority_{idx}" 131 | self._attr_unique_id = "solar_optimizer_priority_" + idx 132 | self._attr_options = PRIORITIES 133 | self._attr_current_option = PRIORITY_MEDIUM 134 | self._attr_translation_key = "priority" 135 | 136 | @property 137 | def icon(self) -> str: 138 | return "mdi:priority-high" 139 | 140 | @property 141 | def device_info(self) -> DeviceInfo: 142 | """Return the device info.""" 143 | return DeviceInfo( 144 | entry_type=DeviceEntryType.SERVICE, 145 | identifiers={(DOMAIN, self._device.name)}, 146 | name="Solar Optimizer-" + self._device.name, 147 | manufacturer=DEVICE_MANUFACTURER, 148 | model=DEVICE_MODEL, 149 | ) 150 | 151 | @overrides 152 | async def async_added_to_hass(self) -> None: 153 | await super().async_added_to_hass() 154 | 155 | old_state = await self.async_get_last_state() 156 | _LOGGER.debug("%s - Calling async_added_to_hass old_state is %s", self, old_state) 157 | if old_state is not None and old_state.state in PRIORITIES: 158 | self._attr_current_option = old_state.state 159 | 160 | self._device.set_priority_entity(self) 161 | 162 | @overrides 163 | async def async_select_option(self, option: str) -> None: 164 | """Change the selected option.""" 165 | old_option = self._attr_current_option 166 | 167 | if option == old_option: 168 | return 169 | 170 | if option in PRIORITIES: 171 | self._attr_current_option = option 172 | _LOGGER.info("%s - priority has been changed to %s", self, self._attr_current_option) 173 | 174 | @overrides 175 | def select_option(self, option: str) -> None: 176 | """Change the selected option""" 177 | self.hass.create_task(self.async_select_option(option)) 178 | 179 | @property 180 | def current_priority(self) -> int: 181 | """Return the current priority""" 182 | # Map the current option string to its corresponding priorityvalue 183 | return PRIORITY_MAP.get(self._attr_current_option, 0) # Default to 0 if not found 184 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/config_schema.py: -------------------------------------------------------------------------------- 1 | """ Alls constants for the Solar Optimizer integration. """ 2 | 3 | import voluptuous as vol 4 | 5 | import homeassistant.helpers.config_validation as cv 6 | from homeassistant.helpers import selector 7 | from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN 8 | from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN 9 | from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN 10 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 11 | from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN 12 | from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN 13 | from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN 14 | from homeassistant.components.fan import DOMAIN as FAN_DOMAIN 15 | from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 16 | from homeassistant.components.select import DOMAIN as SELECT_DOMAIN 17 | from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN 18 | 19 | 20 | from .const import * # pylint: disable=wildcard-import, unused-wildcard-import 21 | 22 | types_schema_devices = vol.Schema( 23 | { 24 | vol.Required( 25 | CONF_DEVICE_TYPE, default=CONF_DEVICE 26 | ): selector.SelectSelector( 27 | selector.SelectSelectorConfig( 28 | options=CONF_DEVICE_TYPES, 29 | translation_key="device_type", 30 | mode="list", 31 | ) 32 | ) 33 | } 34 | ) 35 | 36 | central_config_schema = vol.Schema( 37 | { 38 | vol.Required(CONF_REFRESH_PERIOD_SEC, default=300): int, 39 | vol.Required(CONF_POWER_CONSUMPTION_ENTITY_ID): selector.EntitySelector( 40 | selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) 41 | ), 42 | vol.Required(CONF_POWER_PRODUCTION_ENTITY_ID): selector.EntitySelector( 43 | selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) 44 | ), 45 | vol.Optional(CONF_SUBSCRIBE_TO_EVENTS, default=False): cv.boolean, 46 | vol.Required(CONF_SELL_COST_ENTITY_ID): selector.EntitySelector( 47 | selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) 48 | ), 49 | vol.Required(CONF_BUY_COST_ENTITY_ID): selector.EntitySelector( 50 | selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) 51 | ), 52 | vol.Required(CONF_SELL_TAX_PERCENT_ENTITY_ID): selector.EntitySelector( 53 | selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) 54 | ), 55 | vol.Optional(CONF_SMOOTH_PRODUCTION, default=True): cv.boolean, 56 | vol.Optional(CONF_BATTERY_SOC_ENTITY_ID): selector.EntitySelector( 57 | selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) 58 | ), 59 | vol.Optional(CONF_BATTERY_CHARGE_POWER_ENTITY_ID): selector.EntitySelector( 60 | selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]) 61 | ), 62 | vol.Optional(CONF_RAZ_TIME, default=DEFAULT_RAZ_TIME): str, 63 | } 64 | ) 65 | 66 | managed_device_schema = vol.Schema( 67 | { 68 | vol.Required(CONF_NAME): str, 69 | vol.Required(CONF_ENTITY_ID): selector.EntitySelector( 70 | selector.EntitySelectorConfig(domain=[INPUT_BOOLEAN_DOMAIN, SWITCH_DOMAIN, HUMIDIFIER_DOMAIN, CLIMATE_DOMAIN, FAN_DOMAIN, LIGHT_DOMAIN, SELECT_DOMAIN, BUTTON_DOMAIN]) 71 | ), 72 | vol.Required(CONF_POWER_MAX): str, 73 | vol.Optional(CONF_CHECK_USABLE_TEMPLATE, default="{{ True }}"): str, 74 | vol.Optional(CONF_CHECK_ACTIVE_TEMPLATE): str, 75 | vol.Optional(CONF_DURATION_MIN, default="60"): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1440, step=0.1, mode=selector.NumberSelectorMode.BOX)), 76 | vol.Optional(CONF_DURATION_STOP_MIN): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1440, step=0.1, mode=selector.NumberSelectorMode.BOX)), 77 | vol.Optional(CONF_ACTION_MODE, default=CONF_ACTION_MODE_ACTION): selector.SelectSelector( 78 | selector.SelectSelectorConfig( 79 | options=CONF_ACTION_MODES, 80 | translation_key="action_mode", 81 | mode="dropdown", 82 | ) 83 | ), 84 | vol.Required(CONF_ACTIVATION_SERVICE, default="switch/turn_on"): str, 85 | vol.Optional(CONF_DEACTIVATION_SERVICE, default="switch/turn_off"): str, 86 | vol.Optional(CONF_BATTERY_SOC_THRESHOLD, default=0): str, 87 | vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str, 88 | vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str, 89 | vol.Optional(CONF_OFFPEAK_TIME): str, 90 | } 91 | ) 92 | 93 | power_managed_device_schema = vol.Schema( 94 | { 95 | vol.Required(CONF_NAME): str, 96 | vol.Required(CONF_ENTITY_ID): selector.EntitySelector( 97 | selector.EntitySelectorConfig( 98 | domain=[ 99 | INPUT_BOOLEAN_DOMAIN, 100 | SWITCH_DOMAIN, 101 | HUMIDIFIER_DOMAIN, 102 | CLIMATE_DOMAIN, 103 | FAN_DOMAIN, 104 | LIGHT_DOMAIN, 105 | ] 106 | ) 107 | ), 108 | vol.Optional(CONF_POWER_ENTITY_ID): selector.EntitySelector(selector.EntitySelectorConfig( 109 | domain=[ 110 | INPUT_NUMBER_DOMAIN, 111 | NUMBER_DOMAIN, 112 | FAN_DOMAIN, 113 | LIGHT_DOMAIN, 114 | ] 115 | )), 116 | vol.Optional(CONF_POWER_MIN, default=220): vol.Coerce(float), 117 | vol.Required(CONF_POWER_MAX): str, 118 | vol.Optional(CONF_POWER_STEP, default=220): vol.All(vol.Coerce(float), vol.Range(min=0, min_included=False)), 119 | vol.Optional(CONF_CHECK_USABLE_TEMPLATE, default="{{ True }}"): str, 120 | vol.Optional(CONF_CHECK_ACTIVE_TEMPLATE): str, 121 | vol.Optional(CONF_DURATION_MIN, default="60"): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1440, step=0.1, mode=selector.NumberSelectorMode.BOX)), 122 | vol.Optional(CONF_DURATION_STOP_MIN): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1440, step=0.1, mode=selector.NumberSelectorMode.BOX)), 123 | vol.Optional(CONF_DURATION_POWER_MIN): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1440, step=0.1, mode=selector.NumberSelectorMode.BOX)), 124 | vol.Optional(CONF_ACTION_MODE, default=CONF_ACTION_MODE_ACTION): selector.SelectSelector( 125 | selector.SelectSelectorConfig( 126 | options=CONF_ACTION_MODES, 127 | translation_key="action_mode", 128 | mode="dropdown", 129 | ) 130 | ), 131 | vol.Required(CONF_ACTIVATION_SERVICE, default="switch/turn_on"): str, 132 | vol.Required(CONF_DEACTIVATION_SERVICE, default="switch/turn_off"): str, 133 | vol.Optional(CONF_CHANGE_POWER_SERVICE, default="number/set_value"): str, 134 | vol.Optional(CONF_CONVERT_POWER_DIVIDE_FACTOR, default=220): selector.NumberSelector( 135 | selector.NumberSelectorConfig(min=1.0, max=9999, step=0.1, mode=selector.NumberSelectorMode.BOX) 136 | ), 137 | vol.Optional(CONF_BATTERY_SOC_THRESHOLD, default=0): str, 138 | vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str, 139 | vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str, 140 | vol.Optional(CONF_OFFPEAK_TIME): str, 141 | } 142 | ) 143 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/const.py: -------------------------------------------------------------------------------- 1 | """ Les constantes pour l'intégration Solar Optimizer """ 2 | 3 | import re 4 | import logging 5 | import math 6 | from slugify import slugify 7 | from voluptuous.error import Invalid 8 | 9 | from homeassistant.const import Platform 10 | from homeassistant.core import HomeAssistant, HomeAssistantError 11 | from homeassistant.util import dt as dt_util 12 | from homeassistant.helpers.template import Template, is_template_string 13 | 14 | SOLAR_OPTIMIZER_DOMAIN = DOMAIN = "solar_optimizer" 15 | PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.SELECT] 16 | 17 | DEVICE_MANUFACTURER = "JMCOLLIN" 18 | DEVICE_MODEL = "Solar Optimizer" 19 | 20 | DEFAULT_REFRESH_PERIOD_SEC = 300 21 | DEFAULT_RAZ_TIME = "05:00" 22 | 23 | CONF_ACTION_MODE_ACTION = "action_call" 24 | CONF_ACTION_MODE_EVENT = "event" 25 | 26 | CONF_ACTION_MODES = [CONF_ACTION_MODE_ACTION, CONF_ACTION_MODE_EVENT] 27 | 28 | EVENT_TYPE_SOLAR_OPTIMIZER_CHANGE_POWER = "solar_optimizer_change_power_event" 29 | EVENT_TYPE_SOLAR_OPTIMIZER_STATE_CHANGE = "solar_optimizer_state_change_event" 30 | 31 | EVENT_TYPE_SOLAR_OPTIMIZER_ENABLE_STATE_CHANGE = ( 32 | "solar_optimizer_enable_state_change_event" 33 | ) 34 | 35 | DEVICE_MODEL = "Solar Optimizer device" 36 | INTEGRATION_MODEL = "Solar Optimizer" 37 | DEVICE_MANUFACTURER = "JM. COLLIN" 38 | 39 | SERVICE_RESET_ON_TIME = "reset_on_time" 40 | 41 | TIME_REGEX = r"^(?:[01]\d|2[0-3]):[0-5]\d$" 42 | CONFIG_VERSION = 2 43 | CONFIG_MINOR_VERSION = 1 44 | 45 | CONF_DEVICE_TYPE = "device_type" 46 | CONF_DEVICE_CENTRAL = "central_config" 47 | CONF_DEVICE = "device_type" 48 | CONF_POWERED_DEVICE = "powered_device_type" 49 | CONF_ALL_CONFIG_TYPES = [CONF_DEVICE_CENTRAL, CONF_DEVICE, CONF_POWERED_DEVICE] 50 | CONF_DEVICE_TYPES = [CONF_DEVICE, CONF_POWERED_DEVICE] 51 | CONF_POWER_CONSUMPTION_ENTITY_ID = "power_consumption_entity_id" 52 | CONF_POWER_PRODUCTION_ENTITY_ID = "power_production_entity_id" 53 | CONF_SUBSCRIBE_TO_EVENTS = "subscribe_to_events" 54 | CONF_SELL_COST_ENTITY_ID = "sell_cost_entity_id" 55 | CONF_BUY_COST_ENTITY_ID = "buy_cost_entity_id" 56 | CONF_SELL_TAX_PERCENT_ENTITY_ID = "sell_tax_percent_entity_id" 57 | CONF_SMOOTH_PRODUCTION = "smooth_production" 58 | CONF_REFRESH_PERIOD_SEC = "refresh_period_sec" 59 | CONF_NAME = "name" 60 | CONF_ENTITY_ID = "entity_id" 61 | CONF_POWER_MAX = "power_max" 62 | CONF_CHECK_USABLE_TEMPLATE = "check_usable_template" 63 | CONF_CHECK_ACTIVE_TEMPLATE = "check_active_template" 64 | CONF_DURATION_MIN = "duration_min" 65 | CONF_DURATION_STOP_MIN = "duration_stop_min" 66 | CONF_ACTION_MODE = "action_mode" 67 | CONF_ACTIVATION_SERVICE = "activation_service" 68 | CONF_DEACTIVATION_SERVICE = "deactivation_service" 69 | CONF_POWER_MIN = "power_min" 70 | CONF_POWER_STEP = "power_step" 71 | CONF_POWER_ENTITY_ID = "power_entity_id" 72 | CONF_DURATION_POWER_MIN = "duration_power_min" 73 | CONF_CHANGE_POWER_SERVICE = "change_power_service" 74 | CONF_CONVERT_POWER_DIVIDE_FACTOR = "convert_power_divide_factor" 75 | CONF_RAZ_TIME = "raz_time" 76 | CONF_BATTERY_SOC_ENTITY_ID = "battery_soc_entity_id" 77 | CONF_BATTERY_CHARGE_POWER_ENTITY_ID = "battery_charge_power_entity_id" 78 | CONF_BATTERY_SOC_THRESHOLD = "battery_soc_threshold" 79 | CONF_MAX_ON_TIME_PER_DAY_MIN = "max_on_time_per_day_min" 80 | CONF_MIN_ON_TIME_PER_DAY_MIN = "min_on_time_per_day_min" 81 | CONF_OFFPEAK_TIME = "offpeak_time" 82 | 83 | PRIORITY_WEIGHT_NULL = "None" 84 | PRIORITY_WEIGHT_LOW = "Low" 85 | PRIORITY_WEIGHT_MEDIUM = "Medium" 86 | PRIORITY_WEIGHT_HIGH = "High" 87 | PRIORITY_WEIGHT_VERY_HIGH = "Very high" 88 | PRIORITY_WEIGHTS = [ 89 | PRIORITY_WEIGHT_NULL, 90 | PRIORITY_WEIGHT_LOW, 91 | PRIORITY_WEIGHT_MEDIUM, 92 | PRIORITY_WEIGHT_HIGH, 93 | PRIORITY_WEIGHT_VERY_HIGH, 94 | ] 95 | 96 | # Gives the percentage of priority in the cost calculation. 10 means 10% 97 | PRIORITY_WEIGHT_MAP = { 98 | PRIORITY_WEIGHT_NULL: 0, 99 | PRIORITY_WEIGHT_LOW: 10, 100 | PRIORITY_WEIGHT_MEDIUM: 25, 101 | PRIORITY_WEIGHT_HIGH: 50, 102 | PRIORITY_WEIGHT_VERY_HIGH: 75, 103 | } 104 | 105 | 106 | PRIORITY_VERY_LOW = "Very low" 107 | PRIORITY_LOW = "Low" 108 | PRIORITY_MEDIUM = "Medium" 109 | PRIORITY_HIGH = "High" 110 | PRIORITY_VERY_HIGH = "Very high" 111 | PRIORITIES = [ 112 | PRIORITY_VERY_LOW, 113 | PRIORITY_LOW, 114 | PRIORITY_MEDIUM, 115 | PRIORITY_HIGH, 116 | PRIORITY_VERY_HIGH, 117 | ] 118 | 119 | PRIORITY_MAP = { 120 | PRIORITY_VERY_LOW: 16, 121 | PRIORITY_LOW: 8, 122 | PRIORITY_MEDIUM: 4, 123 | PRIORITY_HIGH: 2, 124 | PRIORITY_VERY_HIGH: 1, 125 | } 126 | 127 | 128 | _LOGGER = logging.getLogger(__name__) 129 | 130 | def get_tz(hass: HomeAssistant): 131 | """Get the current timezone""" 132 | 133 | return dt_util.get_time_zone(hass.config.time_zone) 134 | 135 | 136 | def name_to_unique_id(name: str) -> str: 137 | """Convert a name to a unique id. Replace ' ' by _""" 138 | return slugify(name).replace("-", "_") 139 | 140 | 141 | def seconds_to_hms(seconds): 142 | """Convert seconds to a formatted string of hours:minutes:seconds.""" 143 | seconds = int(seconds) 144 | hours = seconds // 3600 145 | minutes = (seconds % 3600) // 60 146 | secs = seconds % 60 147 | if hours > 0: 148 | return f"{hours}:{minutes:02d}:{secs:02d}" 149 | else: 150 | return f"{minutes}:{secs:02d}" 151 | 152 | 153 | def validate_time_format(value: str) -> str: 154 | """check is a string have format "hh:mm" with hh between 00 and 23 and mm between 00 et 59""" 155 | if value is not None and not re.match(TIME_REGEX, value): 156 | raise Invalid("The time value should be formatted like 'hh:mm'") 157 | return value 158 | 159 | 160 | def get_safe_float(hass, str_as_float) -> float | None: 161 | """Get a safe float state value for an str_as_float string. 162 | Return None if str_as_float is None or not a valid float. 163 | """ 164 | if str_as_float is None: 165 | return None 166 | 167 | if isinstance(str_as_float, int): 168 | return float(str_as_float) 169 | 170 | if isinstance(str_as_float, float): 171 | return str_as_float 172 | 173 | str_as_float = str_as_float.strip() 174 | try: 175 | float_val = float(str_as_float) 176 | return None if math.isinf(float_val) or not math.isfinite(float_val) else float_val 177 | except Exception as exc: 178 | _LOGGER.error("Error converting %s to float %s. SolarOptimizer will not work until this issue is fixed.", str_as_float, exc) 179 | raise exc 180 | 181 | 182 | def get_template_or_value(hass: HomeAssistant, value): 183 | """Get the template or the value""" 184 | if isinstance(value, Template): 185 | return value.async_render(context={}) 186 | return value 187 | 188 | 189 | def convert_to_template_or_value(hass: HomeAssistant, value): 190 | """Convert the value to a template or a value""" 191 | if isinstance(value, str): 192 | if is_template_string(value): 193 | return Template(value, hass) 194 | if isinstance(value, str): 195 | value = value.strip() 196 | if value == "None": 197 | return None 198 | if value == "True": 199 | return True 200 | if value == "False": 201 | return False 202 | return get_safe_float(hass, value) 203 | 204 | 205 | class ConfigurationError(Exception): 206 | """An error in configuration""" 207 | 208 | def __init__(self, message): 209 | super().__init__(message) 210 | 211 | 212 | class overrides: # pylint: disable=invalid-name 213 | """An annotation to inform overrides""" 214 | 215 | def __init__(self, func): 216 | self.func = func 217 | 218 | def __get__(self, instance, owner): 219 | return self.func.__get__(instance, owner) 220 | 221 | def __call__(self, *args, **kwargs): 222 | raise RuntimeError(f"Method {self.func.__name__} should have been overridden") 223 | 224 | class UnknownEntity(HomeAssistantError): 225 | """Error to indicate there is an unknown entity_id given.""" 226 | 227 | 228 | class InvalidTime(HomeAssistantError): 229 | """Error to indicate the give time is invalid""" 230 | -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Loads default set of integrations. Do not remove. 2 | default_config: 3 | 4 | # Load frontend themes from the themes folder 5 | frontend: 6 | themes: !include_dir_merge_named themes 7 | 8 | # Text to speech 9 | tts: 10 | - platform: google_translate 11 | 12 | automation: !include automations.yaml 13 | script: !include scripts.yaml 14 | scene: !include scenes.yaml 15 | 16 | logger: 17 | default: info 18 | logs: 19 | custom_components.solar_optimizer: debug 20 | 21 | debugpy: 22 | start: true 23 | wait: false 24 | port: 5678 25 | 26 | input_boolean: 27 | fake_device_a: 28 | name: "Equipement A (1000 W)" 29 | icon: mdi:radiator 30 | fake_device_b: 31 | name: "Equipement B (500 W)" 32 | icon: mdi:radiator 33 | fake_device_c: 34 | name: "Equipement C (800 W)" 35 | icon: mdi:radiator 36 | fake_device_d: 37 | name: "Equipement D (2100 W)" 38 | icon: mdi:radiator 39 | fake_device_e: 40 | name: "Equipement E (120 W)" 41 | icon: mdi:radiator 42 | fake_device_f: 43 | name: "Equipement F (500W)" 44 | icon: mdi:radiator 45 | fake_device_g: 46 | name: "Equipement G (1200 W)" 47 | icon: mdi:radiator 48 | fake_device_h: 49 | name: "Equipement H (Tesla 0 - 3960 W)" 50 | icon: mdi:car-electric 51 | fake_device_i: 52 | name: "Equipement I (20 W)" 53 | icon: mdi:radiator 54 | device_h_enable: 55 | name: "Enable device H" 56 | icon: mdi:check 57 | 58 | switch: 59 | - platform: template 60 | switches: 61 | fake_switch_1: 62 | friendly_name: "Fake switch 1 (20 W)" 63 | value_template: "{{ is_state('input_boolean.fake_device_i', 'on') }}" 64 | turn_on: 65 | action: input_boolean.turn_on 66 | data: 67 | entity_id: input_boolean.fake_device_i 68 | turn_off: 69 | action: input_boolean.turn_off 70 | data: 71 | entity_id: input_boolean.fake_device_i 72 | 73 | input_number: 74 | consommation_brut: 75 | name: Puissance brut consommée 76 | min: 0 77 | max: 15000 78 | step: 100 79 | icon: mdi:flash 80 | unit_of_measurement: W 81 | mode: slider 82 | consommation_net: 83 | name: Puissance nette consommée 84 | min: -5000 85 | max: 15000 86 | step: 100 87 | icon: mdi:flash 88 | unit_of_measurement: W 89 | mode: slider 90 | production_solaire: 91 | name: Puissance solaire produite 92 | min: 0 93 | max: 4000 94 | step: 100 95 | icon: mdi:flash 96 | unit_of_measurement: W 97 | mode: slider 98 | sell_cost: 99 | name: Cout de revente d'un kWh (en centimes) 100 | min: 0 101 | max: 1 102 | step: 0.01 103 | icon: mdi:euro 104 | unit_of_measurement: € 105 | mode: box 106 | buy_cost: 107 | name: Cout d'achat d'un kWh (en centimes) 108 | min: 0 109 | max: 1 110 | step: 0.01 111 | icon: mdi:euro 112 | unit_of_measurement: € 113 | mode: box 114 | sell_tax_percent: 115 | name: Taxe sur la revente en % 116 | min: 0 117 | max: 100 118 | step: 0.5 119 | icon: mdi:percent 120 | unit_of_measurement: "%" 121 | mode: slider 122 | tesla_amps: 123 | name: Tesla Amps 124 | min: 0 125 | max: 32 126 | icon: mdi:ev-station 127 | unit_of_measurement: A 128 | mode: slider 129 | step: 1 130 | battery_soc: 131 | name: Battery SOC 132 | min: 0 133 | max: 100 134 | icon: mdi:battery 135 | unit_of_measurement: "%" 136 | mode: slider 137 | step: 1 138 | battery_soc_threshold: 139 | name: Battery SOC threshold 140 | min: 0 141 | max: 100 142 | icon: mdi:battery-charging 143 | unit_of_measurement: "%" 144 | mode: slider 145 | step: 1 146 | max_on_time_per_day_min: 147 | name: Max on time per day (min) 148 | min: 0 149 | max: 1440 150 | icon: mdi:clock 151 | unit_of_measurement: "min" 152 | mode: slider 153 | step: 1 154 | min_on_time_per_day_min: 155 | name: Min on time per day (min) 156 | min: 0 157 | max: 1440 158 | icon: mdi:clock 159 | unit_of_measurement: "min" 160 | mode: slider 161 | step: 1 162 | power_max: 163 | name: Max power (W) 164 | min: 0 165 | max: 10000 166 | icon: mdi:flash 167 | unit_of_measurement: "W" 168 | mode: slider 169 | step: 100 170 | #template: 171 | # - trigger: 172 | # - trigger: time_pattern 173 | # # This will update every night 174 | # seconds: "/2" 175 | # sensor: 176 | # - name: "Next availability Tesla (sec)" 177 | # state: "{{ (as_timestamp(state_attr('switch.solar_optimizer_equipement_h', 'next_date_available')) - as_timestamp(now())) | int(0) }}" 178 | # unit_of_measurement: "s" 179 | # state_class: measurement 180 | # - name: "Next availability Tesla Power (sec)" 181 | # state: "{{ (as_timestamp(state_attr('switch.solar_optimizer_equipement_h', 'next_date_available_power')) - as_timestamp(now())) | int(0) }}" 182 | # unit_of_measurement: "s" 183 | # state_class: measurement 184 | 185 | #solar_optimizer: 186 | # algorithm: 187 | # initial_temp: 1000 188 | # min_temp: 0.1 189 | # cooling_factor: 0.95 190 | # max_iteration_number: 1000 191 | # devices: 192 | # - name: "Equipement A" 193 | # entity_id: "input_boolean.fake_device_a" 194 | # power_max: 1000 195 | # check_usable_template: "{{ True }}" 196 | # duration_min: 0.3 197 | # action_mode: "action_call" 198 | # activation_service: "input_boolean/turn_on" 199 | # deactivation_service: "input_boolean/turn_off" 200 | # max_on_time_per_day_min: 10 201 | # min_on_time_per_day_min: 5 202 | # offpeak_time: "14:00" 203 | # - name: "Equipement B" 204 | # entity_id: "input_boolean.fake_device_b" 205 | # power_max: 500 206 | # check_usable_template: "{{ True }}" 207 | # duration_min: 0.6 208 | # action_mode: "action_call" 209 | # activation_service: "input_boolean/turn_on" 210 | # deactivation_service: "input_boolean/turn_off" 211 | # min_on_time_per_day_min: 15 212 | # offpeak_time: "13:50" 213 | # - name: "Equipement C" 214 | # entity_id: "input_boolean.fake_device_c" 215 | # power_max: 800 216 | # check_usable_template: "{{ True }}" 217 | # duration_min: 1 218 | # action_mode: "action_call" 219 | # activation_service: "input_boolean/turn_on" 220 | # deactivation_service: "input_boolean/turn_off" 221 | # - name: "Equipement D" 222 | # entity_id: "input_boolean.fake_device_d" 223 | # power_max: 2100 224 | # check_usable_template: "{{ True }}" 225 | # duration_min: 0.5 226 | # action_mode: "action_call" 227 | # activation_service: "input_boolean/turn_on" 228 | # deactivation_service: "input_boolean/turn_off" 229 | # - name: "Equipement E" 230 | # entity_id: "input_boolean.fake_device_e" 231 | # power_max: 120 232 | # check_usable_template: "{{ True }}" 233 | # duration_min: 2 234 | # action_mode: "action_call" 235 | # activation_service: "input_boolean/turn_on" 236 | # deactivation_service: "input_boolean/turn_off" 237 | # - name: "Equipement F" 238 | # entity_id: "input_boolean.fake_device_f" 239 | # power_max: 500 240 | # check_usable_template: "{{ True }}" 241 | # duration_min: 0.2 242 | # action_mode: "action_call" 243 | # activation_service: "input_boolean/turn_on" 244 | # deactivation_service: "input_boolean/turn_off" 245 | # battery_soc_threshold: 30 246 | # max_on_time_per_day_min: 50 247 | # - name: "Equipement G" 248 | # entity_id: "input_boolean.fake_device_g" 249 | # power_max: 1200 250 | # check_usable_template: "{{ True }}" 251 | # duration_min: 1.5 252 | # action_mode: "action_call" 253 | # activation_service: "input_boolean/turn_on" 254 | # deactivation_service: "input_boolean/turn_off" 255 | # battery_soc_threshold: 40 256 | # max_on_time_per_day_min: 30 257 | # - name: "Equipement H" 258 | # entity_id: "input_boolean.fake_device_h" 259 | # power_entity_id: "input_number.tesla_amps" 260 | # power_min: 660 261 | # power_max: 3960 262 | # power_step: 660 263 | # # check_active_template: "{{ not is_state('input_select.fake_tesla_1', '0 A') }}" 264 | # check_usable_template: "{{ is_state('input_boolean.device_h_enable', 'on') }}" 265 | # duration_min: 1 266 | # duration_stop_min: 0.1 267 | # duration_power_min: 0.1 268 | # action_mode: "action_call" 269 | # activation_service: "input_boolean/turn_on" 270 | # deactivation_service: "input_boolean/turn_off" 271 | # change_power_service: "input_number/set_value" 272 | # convert_power_divide_factor: 660 273 | # battery_soc_threshold: 50 274 | # max_on_time_per_day_min: 10 275 | # - name: "Equipement I" 276 | # entity_id: "switch.fake_switch_1" 277 | # power_max: 20 278 | # check_usable_template: "{{ True }}" 279 | # duration_min: 0.1 280 | # action_mode: "action_call" 281 | # activation_service: "switch/turn_on" 282 | # deactivation_service: "switch/turn_off" 283 | # battery_soc_threshold: 60 284 | # - name: "Equipement J" 285 | # entity_id: "switch.fake_switch_1" 286 | # power_max: 20 287 | # check_usable_template: "{{ True }}" 288 | # duration_min: 0.1 289 | # action_mode: "action_call" 290 | # activation_service: "switch/turn_on" 291 | # deactivation_service: "switch/turn_off" 292 | # battery_soc_threshold: 60 293 | # 294 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """ Testing the ConfigFlow """ 2 | 3 | # pylint: disable=unused-argument, wildcard-import, unused-wildcard-import 4 | 5 | import itertools 6 | import pytest 7 | from datetime import time 8 | 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.data_entry_flow import FlowResultType, InvalidData 11 | 12 | from custom_components.solar_optimizer import config_flow 13 | 14 | from custom_components.solar_optimizer.const import * 15 | from custom_components.solar_optimizer.coordinator import SolarOptimizerCoordinator 16 | 17 | 18 | async def test_empty_config(hass: HomeAssistant, reset_coordinator): 19 | """Test an empty config. This should not work""" 20 | _result = await hass.config_entries.flow.async_init( 21 | config_flow.DOMAIN, context={"source": "user"} 22 | ) 23 | 24 | assert _result["step_id"] == "device_central" 25 | assert _result["type"] == FlowResultType.FORM 26 | assert _result["errors"] == {} 27 | 28 | with pytest.raises(InvalidData) as err: 29 | await hass.config_entries.flow.async_configure( 30 | _result["flow_id"], user_input={} 31 | ) 32 | 33 | assert err.typename == "InvalidData" 34 | assert err.value.error_message == "required key not provided" 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "power_consumption,power_production,sell_cost,buy_cost,raz_time,battery_soc", 39 | [ 40 | ( 41 | "sensor.power_consumption", 42 | "sensor.power_production", 43 | "input_number.sell_cost", 44 | "input_number.buy_cost", 45 | "00:00", 46 | None, 47 | ), 48 | ( 49 | "input_number.power_consumption", 50 | "input_number.power_production", 51 | "input_number.sell_cost", 52 | "input_number.buy_cost", 53 | "04:00", 54 | None, 55 | ), 56 | ( 57 | "sensor.power_consumption", 58 | "sensor.power_production", 59 | "input_number.sell_cost", 60 | "input_number.buy_cost", 61 | "00:00", 62 | "sensor.battery_soc", 63 | ), 64 | ( 65 | "input_number.power_consumption", 66 | "input_number.power_production", 67 | "input_number.sell_cost", 68 | "input_number.buy_cost", 69 | "04:00", 70 | "input_number.battery_soc", 71 | ), 72 | ], 73 | ) 74 | async def test_central_config_inputs( 75 | hass: HomeAssistant, 76 | skip_hass_states_get, 77 | reset_coordinator, 78 | power_consumption, 79 | power_production, 80 | sell_cost, 81 | buy_cost, 82 | raz_time, 83 | battery_soc, 84 | ): 85 | """Test a combinaison of config_flow without battery configuration""" 86 | 87 | _result = await hass.config_entries.flow.async_init( 88 | config_flow.DOMAIN, context={"source": "user"} 89 | ) 90 | 91 | assert _result["step_id"] == "device_central" 92 | assert _result["type"] == FlowResultType.FORM 93 | assert _result["errors"] == {} 94 | 95 | user_input = { 96 | "refresh_period_sec": 300, 97 | "power_consumption_entity_id": power_consumption, 98 | "power_production_entity_id": power_production, 99 | "sell_cost_entity_id": sell_cost, 100 | "buy_cost_entity_id": buy_cost, 101 | "sell_tax_percent_entity_id": "input_number.tax_percent", 102 | "raz_time": raz_time, 103 | } 104 | 105 | if battery_soc: 106 | user_input["battery_soc_entity_id"] = battery_soc 107 | 108 | result = await hass.config_entries.flow.async_configure( 109 | _result["flow_id"], 110 | user_input 111 | ) 112 | await hass.async_block_till_done() 113 | 114 | assert result["type"] == FlowResultType.CREATE_ENTRY 115 | data = result.get("data") 116 | assert data is not None 117 | 118 | for key, value in user_input.items(): 119 | assert data.get(key) == value 120 | 121 | if battery_soc: 122 | assert data["battery_soc_entity_id"] == battery_soc 123 | 124 | assert data["smooth_production"] 125 | 126 | assert result["title"] == "Configuration" 127 | 128 | 129 | async def test_default_values_central_config( 130 | hass: HomeAssistant, skip_hass_states_get, reset_coordinator 131 | ): 132 | """Test a combinaison of config_flow with battery configuration""" 133 | _result = await hass.config_entries.flow.async_init( 134 | config_flow.DOMAIN, context={"source": "user"} 135 | ) 136 | 137 | assert _result["step_id"] == "device_central" 138 | assert _result["type"] == FlowResultType.FORM 139 | assert _result["errors"] == {} 140 | 141 | user_input = { 142 | # "refresh_period_sec": 300, 143 | # "raz_time": "04:00", 144 | # "smooth_production": True, 145 | # battery_soc_entity_id: None, 146 | "power_consumption_entity_id": "input_number.power_consumption", 147 | "power_production_entity_id": "input_number.power_production", 148 | "sell_cost_entity_id": "input_number.sell_cost", 149 | "buy_cost_entity_id": "input_number.buy_cost", 150 | "sell_tax_percent_entity_id": "input_number.tax_percent", 151 | } 152 | 153 | result = await hass.config_entries.flow.async_configure( 154 | _result["flow_id"], user_input 155 | ) 156 | await hass.async_block_till_done() 157 | 158 | assert result["type"] == FlowResultType.CREATE_ENTRY 159 | data = result.get("data") 160 | assert data is not None 161 | 162 | for key, value in user_input.items(): 163 | assert data.get(key) == value 164 | 165 | assert data.get("refresh_period_sec") == 300 166 | assert data.get("raz_time") == DEFAULT_RAZ_TIME 167 | 168 | assert data["smooth_production"] 169 | assert data.get("battery_soc_entity_id") is None 170 | 171 | assert result["title"] == "Configuration" 172 | 173 | 174 | async def test_wrong_raz_time( 175 | hass: HomeAssistant, skip_hass_states_get, reset_coordinator 176 | ): 177 | """Test an empty config. This should not work""" 178 | _result = await hass.config_entries.flow.async_init( 179 | config_flow.DOMAIN, context={"source": "user"} 180 | ) 181 | 182 | assert _result["step_id"] == "device_central" 183 | assert _result["type"] == FlowResultType.FORM 184 | assert _result["errors"] == {} 185 | 186 | user_input = { 187 | # "refresh_period_sec": 300, 188 | "raz_time": "04h00", # Not valid ! 189 | # "smooth_production": True, 190 | # battery_soc_entity_id: None, 191 | "power_consumption_entity_id": "input_number.power_consumption", 192 | "power_production_entity_id": "input_number.power_production", 193 | "sell_cost_entity_id": "input_number.sell_cost", 194 | "buy_cost_entity_id": "input_number.buy_cost", 195 | "sell_tax_percent_entity_id": "input_number.tax_percent", 196 | } 197 | 198 | _result = await hass.config_entries.flow.async_configure( 199 | _result["flow_id"], user_input=user_input 200 | ) 201 | 202 | assert _result["step_id"] == "device_central" 203 | assert _result["type"] == FlowResultType.FORM 204 | assert _result["errors"] == {"raz_time": "format_time_invalid"} 205 | 206 | 207 | async def test_simple_device_config( 208 | hass: HomeAssistant, skip_hass_states_get, init_solar_optimizer_central_config 209 | ): 210 | """Test a simple device configuration""" 211 | result = await hass.config_entries.flow.async_init( 212 | config_flow.DOMAIN, context={"source": "user"} 213 | ) 214 | 215 | assert result["step_id"] == "user" 216 | assert result["type"] == FlowResultType.FORM 217 | assert result["errors"] == {} 218 | 219 | result = await hass.config_entries.flow.async_configure( 220 | result["flow_id"], user_input={CONF_DEVICE_TYPE: CONF_DEVICE} 221 | ) 222 | 223 | assert result["step_id"] == "device" 224 | assert result["type"] == FlowResultType.FORM 225 | assert result["errors"] == {} 226 | 227 | user_input = { 228 | CONF_NAME: "Equipement A", 229 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 230 | CONF_POWER_MAX: "{{ 1000 }}", 231 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 232 | CONF_CHECK_ACTIVE_TEMPLATE: "{{ False }}", 233 | CONF_DURATION_MIN: 0.3, 234 | CONF_DURATION_STOP_MIN: 0.1, 235 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 236 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 237 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 238 | CONF_BATTERY_SOC_THRESHOLD: "{{ 50 }}", 239 | CONF_MAX_ON_TIME_PER_DAY_MIN: "{{ 10 }}", 240 | CONF_MIN_ON_TIME_PER_DAY_MIN: "{{ 1 }}", 241 | CONF_OFFPEAK_TIME: "22:00", 242 | } 243 | 244 | result = await hass.config_entries.flow.async_configure( 245 | result["flow_id"], user_input 246 | ) 247 | await hass.async_block_till_done() 248 | 249 | assert result["type"] == FlowResultType.CREATE_ENTRY 250 | data = result.get("data") 251 | assert data is not None 252 | 253 | assert data.get(CONF_NAME) == "Equipement A" 254 | assert data.get(CONF_POWER_MAX) == "{{ 1000 }}" 255 | assert data.get(CONF_POWER_MIN) is None 256 | assert data.get(CONF_POWER_STEP) is None 257 | assert data.get(CONF_CHECK_USABLE_TEMPLATE) == "{{ True }}" 258 | assert data.get(CONF_CHECK_ACTIVE_TEMPLATE) == "{{ False }}" 259 | assert data.get(CONF_DURATION_MIN) == 0.3 260 | assert data.get(CONF_DURATION_STOP_MIN) == 0.1 261 | assert data.get(CONF_DURATION_POWER_MIN) is None 262 | assert data.get(CONF_ACTION_MODE) == CONF_ACTION_MODE_ACTION 263 | assert data.get(CONF_ACTIVATION_SERVICE) == "input_boolean/turn_on" 264 | assert data.get(CONF_DEACTIVATION_SERVICE) == "input_boolean/turn_off" 265 | assert data.get(CONF_CONVERT_POWER_DIVIDE_FACTOR) is None 266 | assert data.get(CONF_CHANGE_POWER_SERVICE) is None 267 | assert data.get(CONF_POWER_ENTITY_ID) is None 268 | assert data.get(CONF_BATTERY_SOC_THRESHOLD) == "{{ 50 }}" 269 | assert data.get(CONF_MAX_ON_TIME_PER_DAY_MIN) == "{{ 10 }}" 270 | assert data.get(CONF_MIN_ON_TIME_PER_DAY_MIN) == "{{ 1 }}" 271 | assert data.get(CONF_OFFPEAK_TIME) == "22:00" 272 | 273 | 274 | async def test_powered_device_config( 275 | hass: HomeAssistant, skip_hass_states_get, init_solar_optimizer_central_config 276 | ): 277 | """Test a simple device configuration""" 278 | result = await hass.config_entries.flow.async_init( 279 | config_flow.DOMAIN, context={"source": "user"} 280 | ) 281 | 282 | assert result["step_id"] == "user" 283 | assert result["type"] == FlowResultType.FORM 284 | assert result["errors"] == {} 285 | 286 | result = await hass.config_entries.flow.async_configure( 287 | result["flow_id"], user_input={CONF_DEVICE_TYPE: CONF_POWERED_DEVICE} 288 | ) 289 | 290 | assert result["step_id"] == "powered_device" 291 | assert result["type"] == FlowResultType.FORM 292 | assert result["errors"] == {} 293 | 294 | user_input = { 295 | CONF_NAME: "Equipement A", 296 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 297 | CONF_POWER_MAX: "1000", 298 | CONF_POWER_MIN: 100, 299 | CONF_POWER_STEP: 150, 300 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 301 | CONF_CHECK_ACTIVE_TEMPLATE: "{{ False }}", 302 | CONF_DURATION_MIN: 0.3, 303 | CONF_DURATION_STOP_MIN: 0.1, 304 | CONF_DURATION_POWER_MIN: 3, 305 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 306 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 307 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 308 | CONF_CONVERT_POWER_DIVIDE_FACTOR: 6, 309 | CONF_CHANGE_POWER_SERVICE: "input_number/set_value", 310 | CONF_POWER_ENTITY_ID: "input_number.tesla_amps", 311 | CONF_BATTERY_SOC_THRESHOLD: "50", 312 | CONF_MAX_ON_TIME_PER_DAY_MIN: "10", 313 | CONF_MIN_ON_TIME_PER_DAY_MIN: "1", 314 | CONF_OFFPEAK_TIME: "22:00", 315 | } 316 | 317 | result = await hass.config_entries.flow.async_configure( 318 | result["flow_id"], user_input 319 | ) 320 | await hass.async_block_till_done() 321 | 322 | assert result["type"] == FlowResultType.CREATE_ENTRY 323 | data = result.get("data") 324 | assert data is not None 325 | 326 | assert data.get(CONF_NAME) == "Equipement A" 327 | assert data.get(CONF_POWER_MAX) == "1000" 328 | assert data.get(CONF_POWER_MIN) == 100 329 | assert data.get(CONF_POWER_STEP) == 150 330 | assert data.get(CONF_CHECK_USABLE_TEMPLATE) == "{{ True }}" 331 | assert data.get(CONF_CHECK_ACTIVE_TEMPLATE) == "{{ False }}" 332 | assert data.get(CONF_DURATION_MIN) == 0.3 333 | assert data.get(CONF_DURATION_STOP_MIN) == 0.1 334 | assert data.get(CONF_DURATION_POWER_MIN) == 3 335 | assert data.get(CONF_ACTION_MODE) == CONF_ACTION_MODE_ACTION 336 | assert data.get(CONF_ACTIVATION_SERVICE) == "input_boolean/turn_on" 337 | assert data.get(CONF_DEACTIVATION_SERVICE) == "input_boolean/turn_off" 338 | assert data.get(CONF_CONVERT_POWER_DIVIDE_FACTOR) == 6 339 | assert data.get(CONF_CHANGE_POWER_SERVICE) == "input_number/set_value" 340 | assert data.get(CONF_POWER_ENTITY_ID) == "input_number.tesla_amps" 341 | assert data.get(CONF_BATTERY_SOC_THRESHOLD) == "50" 342 | assert data.get(CONF_MAX_ON_TIME_PER_DAY_MIN) == "10" 343 | assert data.get(CONF_MIN_ON_TIME_PER_DAY_MIN) == "1" 344 | assert data.get(CONF_OFFPEAK_TIME) == "22:00" 345 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/config_flow.py: -------------------------------------------------------------------------------- 1 | """ Le Config Flow """ 2 | 3 | import logging 4 | import voluptuous as vol 5 | 6 | from homeassistant.core import callback 7 | from homeassistant.config_entries import ( 8 | ConfigFlow, 9 | FlowResult, 10 | OptionsFlow, 11 | ConfigEntry, 12 | ) 13 | from homeassistant.data_entry_flow import FlowHandler 14 | 15 | from .const import * # pylint: disable=wildcard-import, unused-wildcard-import 16 | from .config_schema import ( 17 | types_schema_devices, 18 | central_config_schema, 19 | managed_device_schema, 20 | power_managed_device_schema, 21 | ) 22 | 23 | from .coordinator import SolarOptimizerCoordinator 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | class SolarOptimizerBaseConfigFlow(FlowHandler): 29 | """La classe qui implémente le config flow pour notre DOMAIN. 30 | Elle doit dériver de FlowHandler""" 31 | 32 | # La version de notre configFlow. Va permettre de migrer les entités 33 | # vers une version plus récente en cas de changement 34 | VERSION = CONFIG_VERSION 35 | MINOR_VERSION = CONFIG_MINOR_VERSION 36 | 37 | def __init__(self, infos) -> None: 38 | super().__init__() 39 | _LOGGER.debug("CTOR BaseConfigFlow infos: %s", infos) 40 | self._infos: dict = infos 41 | 42 | # Coordinator should be initialized 43 | self._coordinator = SolarOptimizerCoordinator.get_coordinator() 44 | if not self._coordinator: 45 | _LOGGER.warning("Coordinator is not initialized yet. First run ?") 46 | 47 | self._user_inputs: dict = {} 48 | self._placeholders: dict = {} 49 | 50 | async def generic_step(self, step_id, data_schema, user_input, next_step_function): 51 | """A generic method step""" 52 | _LOGGER.debug( 53 | "Into ConfigFlow.async_step_%s user_input=%s", step_id, user_input 54 | ) 55 | 56 | defaults = self._infos.copy() 57 | errors = {} 58 | 59 | if user_input is not None: 60 | defaults.update(user_input or {}) 61 | try: 62 | await self.validate_input(user_input, step_id) 63 | except UnknownEntity as err: 64 | errors[str(err)] = "unknown_entity" 65 | except InvalidTime as err: 66 | errors[str(err)] = "format_time_invalid" 67 | except Exception: # pylint: disable=broad-except 68 | _LOGGER.exception("Unexpected exception") 69 | errors["base"] = "unknown" 70 | else: 71 | self.merge_user_input(data_schema, user_input) 72 | # Add default values for central config flags 73 | _LOGGER.debug("_info is now: %s", self._infos) 74 | return await next_step_function() 75 | 76 | # ds = schema_defaults(data_schema, **defaults) # pylint: disable=invalid-name 77 | ds = self.add_suggested_values_to_schema( 78 | data_schema=data_schema, suggested_values=defaults 79 | ) # pylint: disable=invalid-name 80 | 81 | return self.async_show_form( 82 | step_id=step_id, 83 | data_schema=ds, 84 | errors=errors, 85 | description_placeholders=self._placeholders, 86 | ) 87 | 88 | def merge_user_input(self, data_schema: vol.Schema, user_input: dict): 89 | """For each schema entry not in user_input, set or remove values in infos""" 90 | self._infos.update(user_input) 91 | for key, _ in data_schema.schema.items(): 92 | if key not in user_input and isinstance(key, vol.Marker): 93 | _LOGGER.debug( 94 | "add_empty_values_to_user_input: %s is not in user_input", key 95 | ) 96 | if key in self._infos: 97 | self._infos.pop(key) 98 | # else: This don't work but I don't know why. _infos seems broken after this (Not serializable exactly) 99 | # self._infos[key] = user_input[key] 100 | 101 | _LOGGER.debug("merge_user_input: infos is now %s", self._infos) 102 | 103 | async def validate_input( 104 | self, data: dict, step_id # pylint: disable=unused-argument 105 | ) -> None: 106 | """Validate the user input.""" 107 | 108 | # check the entity_ids 109 | for conf in [ 110 | CONF_POWER_CONSUMPTION_ENTITY_ID, 111 | CONF_POWER_PRODUCTION_ENTITY_ID, 112 | CONF_SELL_COST_ENTITY_ID, 113 | CONF_BUY_COST_ENTITY_ID, 114 | CONF_SELL_TAX_PERCENT_ENTITY_ID, 115 | ]: 116 | d = data.get(conf, None) # pylint: disable=invalid-name 117 | if not isinstance(d, list): 118 | d = [d] 119 | for e in d: 120 | if e is not None and self.hass.states.get(e) is None: 121 | _LOGGER.error( 122 | "Entity id %s doesn't have any state. We cannot use it in the Versatile Thermostat configuration", # pylint: disable=line-too-long 123 | e, 124 | ) 125 | raise UnknownEntity(conf) 126 | 127 | for conf in [CONF_RAZ_TIME, CONF_OFFPEAK_TIME]: 128 | try: 129 | d = data.get(conf, None) 130 | if d is not None: 131 | validate_time_format(d) 132 | except vol.Invalid as err: 133 | raise InvalidTime(conf) 134 | 135 | async def async_step_user(self, user_input: dict | None = None) -> FlowResult: 136 | """Handle the flow steps user""" 137 | _LOGGER.debug("Into ConfigFlow.async_step_user user_input=%s", user_input) 138 | 139 | if not self._coordinator or not self._coordinator.is_central_config_done: 140 | return await self.async_step_device_central(user_input) 141 | 142 | schema = types_schema_devices 143 | next_step = None 144 | if user_input is not None: 145 | if user_input.get(CONF_DEVICE_TYPE) == CONF_DEVICE: 146 | next_step = self.async_step_device 147 | elif user_input.get(CONF_DEVICE_TYPE) == CONF_POWERED_DEVICE: 148 | next_step = self.async_step_powered_device 149 | else: 150 | raise ConfigurationError("Unknown device type") 151 | 152 | return await self.generic_step("user", schema, user_input, next_step) 153 | 154 | async def async_step_device_central( 155 | self, user_input: dict | None = None 156 | ) -> FlowResult: 157 | """Handle the flow steps for central device""" 158 | _LOGGER.debug( 159 | "Into ConfigFlow.async_step_device_central user_input=%s", user_input 160 | ) 161 | 162 | if user_input is not None: 163 | # Check if the entity_ids are valid 164 | user_input[CONF_NAME] = "Configuration" 165 | user_input[CONF_DEVICE_TYPE] = CONF_DEVICE_CENTRAL 166 | # await self.validate_input(user_input, "device_central") 167 | 168 | return await self.generic_step( 169 | "device_central", 170 | central_config_schema, 171 | user_input, 172 | self.async_step_finalize, 173 | ) 174 | 175 | async def async_step_device(self, user_input: dict | None = None) -> FlowResult: 176 | """Handle the flow steps for device""" 177 | _LOGGER.debug("Into ConfigFlow.async_step_device user_input=%s", user_input) 178 | 179 | return await self.generic_step( 180 | "device", managed_device_schema, user_input, self.async_step_finalize 181 | ) 182 | 183 | async def async_step_powered_device( 184 | self, user_input: dict | None = None 185 | ) -> FlowResult: 186 | """Handle the flow steps for powered device""" 187 | _LOGGER.debug( 188 | "Into ConfigFlow.async_step_powered_device user_input=%s", user_input 189 | ) 190 | 191 | if user_input is not None: 192 | # Check if the entity_ids are valid 193 | await self.validate_input(user_input, "powered_device") 194 | 195 | return await self.generic_step( 196 | "powered_device", 197 | power_managed_device_schema, 198 | user_input, 199 | self.async_step_finalize, 200 | ) 201 | 202 | def is_matching(self, entry: ConfigEntry) -> bool: 203 | """Check if the entry matches the current flow.""" 204 | return entry.data.get("domain") == SOLAR_OPTIMIZER_DOMAIN 205 | 206 | async def async_step_finalize(self, user_input: dict | None = None) -> FlowResult: 207 | """Handle the flow steps for finalization""" 208 | _LOGGER.debug("Into ConfigFlow.async_step_finalize user_input=%s", user_input) 209 | 210 | if user_input is not None: 211 | # Check if the entity_ids are valid 212 | await self.validate_input(user_input, "finalize") 213 | 214 | @staticmethod 215 | @callback 216 | def async_get_options_flow(config_entry: ConfigEntry): 217 | """Get options flow for this handler""" 218 | return SolarOptimizerOptionsFlow(config_entry) 219 | 220 | class SolarOptimizerConfigFlow( 221 | SolarOptimizerBaseConfigFlow, ConfigFlow, domain=SOLAR_OPTIMIZER_DOMAIN 222 | ): 223 | """The real config flow for Solar Optimizer""" 224 | 225 | def __init__(self) -> None: 226 | # self._info = dict() 227 | super().__init__(dict()) 228 | _LOGGER.debug("CTOR ConfigFlow") 229 | 230 | async def async_step_finalize(self, user_input: dict | None = None) -> FlowResult: 231 | """Finalization of the ConfigEntry creation""" 232 | _LOGGER.debug("ConfigFlow.async_finalize") 233 | await super().async_step_finalize(user_input) 234 | 235 | return self.async_create_entry(title=self._infos[CONF_NAME], data=self._infos) 236 | 237 | 238 | class SolarOptimizerOptionsFlow(SolarOptimizerBaseConfigFlow, OptionsFlow): 239 | """The class which enable to modified the configuration""" 240 | 241 | def __init__(self, config_entry: ConfigEntry) -> None: 242 | """Initialisation de l'option flow. On a le ConfigEntry existant en entrée""" 243 | super().__init__(config_entry.data.copy()) 244 | _LOGGER.debug( 245 | "CTOR SolarOptimizerOptionsFlow info: %s, entry_id: %s", 246 | self._infos, 247 | config_entry.entry_id, 248 | ) 249 | 250 | async def async_step_init(self, user_input=None): 251 | """Manage options.""" 252 | _LOGGER.debug( 253 | "Into OptionsFlowHandler.async_step_init user_input =%s", 254 | user_input, 255 | ) 256 | 257 | if self._infos.get(CONF_DEVICE_TYPE) == CONF_DEVICE_CENTRAL: 258 | return await self.async_step_device_central(user_input) 259 | elif self._infos.get(CONF_DEVICE_TYPE) == CONF_DEVICE: 260 | return await self.async_step_device(user_input) 261 | elif self._infos.get(CONF_DEVICE_TYPE) == CONF_POWERED_DEVICE: 262 | return await self.async_step_powered_device(user_input) 263 | 264 | async def async_step_finalize(self, user_input: dict | None = None) -> FlowResult: 265 | _LOGGER.info( 266 | "Recreating entry %s due to configuration change. New config is now: %s", 267 | self.config_entry.entry_id, 268 | self._infos, 269 | ) 270 | await super().async_step_finalize(user_input) 271 | 272 | self.hass.config_entries.async_update_entry(self.config_entry, data=self._infos) 273 | return self.async_create_entry(title=None, data=None) 274 | 275 | # async def async_step_init(self, user_input: dict | None = None) -> FlowResult: 276 | # """Gestion de l'étape 'user'. Point d'entrée de notre 277 | # configFlow. Cette méthode est appelée 2 fois : 278 | # 1. une première fois sans user_input -> on affiche le formulaire de configuration 279 | # 2. une deuxième fois avec les données saisies par l'utilisateur dans user_input -> on sauvegarde les données saisies 280 | # """ 281 | # user_form = vol.Schema(central_config_schema) 282 | 283 | 284 | # 285 | # if user_input is None: 286 | # _LOGGER.debug( 287 | # "config_flow step user (1). 1er appel : pas de user_input -> on affiche le form user_form" 288 | # ) 289 | # return self.async_show_form( 290 | # step_id="init", 291 | # data_schema=add_suggested_values_to_schema( 292 | # data_schema=user_form, 293 | # suggested_values=self._user_inputs, 294 | # ), 295 | # ) 296 | # 297 | # # 2ème appel : il y a des user_input -> on stocke le résultat 298 | # self._user_inputs.update(user_input) 299 | # _LOGGER.debug( 300 | # "config_flow step_user (2). L'ensemble de la configuration est: %s", 301 | # self._user_inputs, 302 | # ) 303 | # 304 | # # On appelle le step de fin pour enregistrer les modifications 305 | # return await self.async_end() 306 | 307 | # async def async_end(self): 308 | # """Finalization of the ConfigEntry creation""" 309 | # _LOGGER.info( 310 | # "Recreation de l'entry %s. La nouvelle config est maintenant : %s", 311 | # self.config_entry.entry_id, 312 | # self._user_inputs, 313 | # ) 314 | # 315 | # # Modification des data de la configEntry 316 | # # (et non pas ajout d'un objet options dans la configEntry) 317 | # self.hass.config_entries.async_update_entry( 318 | # self.config_entry, data=self._user_inputs 319 | # ) 320 | # # Suppression de l'objet options dans la configEntry 321 | # return self.async_create_entry(title=None, data=None) 322 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/coordinator.py: -------------------------------------------------------------------------------- 1 | """ The data coordinator class """ 2 | 3 | import logging 4 | import math 5 | from datetime import datetime, timedelta, time 6 | from typing import Any 7 | 8 | from homeassistant.core import HomeAssistant, Event, EventStateChangedData 9 | from homeassistant.components.select import SelectEntity 10 | 11 | from homeassistant.helpers.event import ( 12 | async_track_state_change_event, 13 | ) 14 | 15 | from homeassistant.helpers.update_coordinator import ( 16 | DataUpdateCoordinator, 17 | ) 18 | 19 | from homeassistant.util.unit_conversion import ( 20 | BaseUnitConverter, 21 | PowerConverter 22 | ) 23 | 24 | from homeassistant.config_entries import ConfigEntry 25 | 26 | from .const import DEFAULT_REFRESH_PERIOD_SEC, name_to_unique_id, SOLAR_OPTIMIZER_DOMAIN, DEFAULT_RAZ_TIME 27 | from .managed_device import ManagedDevice 28 | from .simulated_annealing_algo import SimulatedAnnealingAlgorithm 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | def get_safe_float(hass, entity_id: str, unit: str = None): 34 | """Get a safe float state value for an entity. 35 | Return None if entity is not available""" 36 | if entity_id is None or not (state := hass.states.get(entity_id)) or state.state == "unknown" or state.state == "unavailable": 37 | return None 38 | 39 | float_val = float(state.state) 40 | 41 | if (unit is not None) and ('device_class' in state.attributes) and (state.attributes["device_class"] == "power"): 42 | float_val = PowerConverter.convert(float_val, 43 | state.attributes["unit_of_measurement"], 44 | unit 45 | ) 46 | 47 | return None if math.isinf(float_val) or not math.isfinite(float_val) else float_val 48 | 49 | 50 | class SolarOptimizerCoordinator(DataUpdateCoordinator): 51 | """The coordinator class which is used to coordinate all update""" 52 | 53 | hass: HomeAssistant 54 | 55 | def __init__(self, hass: HomeAssistant, config): 56 | """Initialize the coordinator""" 57 | SolarOptimizerCoordinator.hass = hass 58 | self._devices: list[ManagedDevice] = [] 59 | self._power_consumption_entity_id: str = None 60 | self._power_production_entity_id: str = None 61 | self._subscribe_to_events: bool = False 62 | self._unsub_events = None 63 | self._sell_cost_entity_id: str = None 64 | self._buy_cost_entity_id: str = None 65 | self._sell_tax_percent_entity_id: str = None 66 | self._smooth_production: bool = True 67 | self._last_production: float = 0.0 68 | self._battery_soc_entity_id: str = None 69 | self._battery_charge_power_entity_id: str = None 70 | self._raz_time: time = None 71 | 72 | self._central_config_done = False 73 | self._priority_weight_entity = None 74 | 75 | super().__init__(hass, _LOGGER, name="Solar Optimizer") 76 | 77 | init_temp = 1000 78 | min_temp = 0.05 79 | cooling_factor = 0.95 80 | max_iteration_number = 1000 81 | 82 | if config and (algo_config := config.get("algorithm")): 83 | init_temp = float(algo_config.get("initial_temp", 1000)) 84 | min_temp = float(algo_config.get("min_temp", 0.05)) 85 | cooling_factor = float(algo_config.get("cooling_factor", 0.95)) 86 | max_iteration_number = int(algo_config.get("max_iteration_number", 1000)) 87 | 88 | self._algo = SimulatedAnnealingAlgorithm( 89 | init_temp, min_temp, cooling_factor, max_iteration_number 90 | ) 91 | self.config = config 92 | 93 | async def configure(self, config: ConfigEntry) -> None: 94 | """Configure the coordinator from configEntry of the integration""" 95 | refresh_period_sec = ( 96 | config.data.get("refresh_period_sec") or DEFAULT_REFRESH_PERIOD_SEC 97 | ) 98 | self.update_interval = timedelta(seconds=refresh_period_sec) 99 | self._schedule_refresh() 100 | 101 | self._power_consumption_entity_id = config.data.get( 102 | "power_consumption_entity_id" 103 | ) 104 | self._power_production_entity_id = config.data.get("power_production_entity_id") 105 | self._subscribe_to_events = config.data.get("subscribe_to_events") 106 | 107 | if self._unsub_events is not None: 108 | self._unsub_events() 109 | self._unsub_events = None 110 | 111 | if self._subscribe_to_events: 112 | self._unsub_events = async_track_state_change_event( 113 | self.hass, 114 | [self._power_consumption_entity_id, self._power_production_entity_id], 115 | self._async_on_change) 116 | 117 | self._sell_cost_entity_id = config.data.get("sell_cost_entity_id") 118 | self._buy_cost_entity_id = config.data.get("buy_cost_entity_id") 119 | self._sell_tax_percent_entity_id = config.data.get("sell_tax_percent_entity_id") 120 | self._battery_soc_entity_id = config.data.get("battery_soc_entity_id") 121 | self._battery_charge_power_entity_id = config.data.get( 122 | "battery_charge_power_entity_id" 123 | ) 124 | self._smooth_production = config.data.get("smooth_production") is True 125 | self._last_production = 0.0 126 | 127 | self._raz_time = datetime.strptime( 128 | config.data.get("raz_time") or DEFAULT_RAZ_TIME, "%H:%M" 129 | ).time() 130 | self._central_config_done = True 131 | 132 | async def on_ha_started(self, _) -> None: 133 | """Listen the homeassistant_started event to initialize the first calculation""" 134 | _LOGGER.info("First initialization of Solar Optimizer") 135 | 136 | async def _async_on_change(self, event: Event[EventStateChangedData]) -> None: 137 | await self.async_refresh() 138 | self._schedule_refresh() 139 | 140 | async def _async_update_data(self): 141 | _LOGGER.info("Refreshing Solar Optimizer calculation") 142 | 143 | calculated_data = {} 144 | 145 | # Add a device state attributes 146 | for _, device in enumerate(self._devices): 147 | # Initialize current power depending or reality 148 | device.set_current_power_with_device_state() 149 | 150 | # Add a power_consumption and power_production 151 | power_production = get_safe_float(self.hass, self._power_production_entity_id, "W") 152 | if power_production is None: 153 | _LOGGER.warning( 154 | "Power production is not valued. Solar Optimizer will be disabled" 155 | ) 156 | return None 157 | 158 | if not self._smooth_production: 159 | calculated_data["power_production"] = power_production 160 | else: 161 | self._last_production = round( 162 | 0.5 * self._last_production + 0.5 * power_production 163 | ) 164 | calculated_data["power_production"] = self._last_production 165 | 166 | calculated_data["power_production_brut"] = power_production 167 | 168 | calculated_data["power_consumption"] = get_safe_float( 169 | self.hass, self._power_consumption_entity_id, "W" 170 | ) 171 | 172 | calculated_data["sell_cost"] = get_safe_float( 173 | self.hass, self._sell_cost_entity_id 174 | ) 175 | 176 | calculated_data["buy_cost"] = get_safe_float( 177 | self.hass, self._buy_cost_entity_id 178 | ) 179 | 180 | calculated_data["sell_tax_percent"] = get_safe_float( 181 | self.hass, self._sell_tax_percent_entity_id 182 | ) 183 | 184 | soc = get_safe_float(self.hass, self._battery_soc_entity_id) 185 | calculated_data["battery_soc"] = soc if soc is not None else 0 186 | 187 | charge_power = get_safe_float(self.hass, self._battery_charge_power_entity_id) 188 | calculated_data["battery_charge_power"] = ( 189 | charge_power if charge_power is not None else 0 190 | ) 191 | 192 | calculated_data["priority_weight"] = self.priority_weight 193 | 194 | # 195 | # Call Algorithm Recuit simulé 196 | # 197 | best_solution, best_objective, total_power = self._algo.recuit_simule( 198 | self._devices, 199 | calculated_data["power_consumption"] + calculated_data["battery_charge_power"], 200 | calculated_data["power_production"], 201 | calculated_data["sell_cost"], 202 | calculated_data["buy_cost"], 203 | calculated_data["sell_tax_percent"], 204 | calculated_data["battery_soc"], 205 | calculated_data["priority_weight"], 206 | ) 207 | 208 | calculated_data["best_solution"] = best_solution 209 | calculated_data["best_objective"] = best_objective 210 | calculated_data["total_power"] = total_power 211 | 212 | # Uses the result to turn on or off or change power 213 | should_log = False 214 | for _, equipement in enumerate(best_solution): 215 | name = equipement["name"] 216 | requested_power = equipement.get("requested_power") 217 | state = equipement["state"] 218 | _LOGGER.debug("Dealing with best_solution for %s - %s", name, equipement) 219 | device = self.get_device_by_name(name) 220 | if not device: 221 | continue 222 | 223 | old_requested_power = device.requested_power 224 | is_active = device.is_active 225 | should_force_offpeak = device.should_be_forced_offpeak 226 | if should_force_offpeak: 227 | _LOGGER.debug("%s - we should force %s name", self, name) 228 | if is_active and not state and not should_force_offpeak: 229 | _LOGGER.debug("Extinction de %s", name) 230 | should_log = True 231 | old_requested_power = 0 232 | await device.deactivate() 233 | elif not is_active and (state or should_force_offpeak): 234 | _LOGGER.debug("Allumage de %s", name) 235 | should_log = True 236 | old_requested_power = requested_power 237 | await device.activate(requested_power) 238 | 239 | # Send change power if state is now on and change power is accepted and (power have change or eqt is just activated) 240 | if ( 241 | state 242 | and device.can_change_power 243 | and (device.current_power != requested_power or not is_active) 244 | ): 245 | _LOGGER.debug( 246 | "Change power of %s to %s", 247 | equipement["name"], 248 | requested_power, 249 | ) 250 | should_log = True 251 | await device.change_requested_power(requested_power) 252 | 253 | device.set_requested_power(old_requested_power) 254 | 255 | # Add updated data to the result 256 | calculated_data[name_to_unique_id(name)] = device 257 | 258 | if should_log: 259 | _LOGGER.info("Calculated data are: %s", calculated_data) 260 | else: 261 | _LOGGER.debug("Calculated data are: %s", calculated_data) 262 | 263 | return calculated_data 264 | 265 | @classmethod 266 | def get_coordinator(cls) -> Any: 267 | """Get the coordinator from the hass.data""" 268 | if ( 269 | not hasattr(SolarOptimizerCoordinator, "hass") 270 | or SolarOptimizerCoordinator.hass is None 271 | or SolarOptimizerCoordinator.hass.data[SOLAR_OPTIMIZER_DOMAIN] is None 272 | ): 273 | return None 274 | 275 | return SolarOptimizerCoordinator.hass.data[SOLAR_OPTIMIZER_DOMAIN][ 276 | "coordinator" 277 | ] 278 | 279 | @classmethod 280 | def reset(cls) -> Any: 281 | """Reset the coordinator from the hass.data""" 282 | if ( 283 | not hasattr(SolarOptimizerCoordinator, "hass") 284 | or SolarOptimizerCoordinator.hass is None 285 | or SolarOptimizerCoordinator.hass.data[SOLAR_OPTIMIZER_DOMAIN] is None 286 | ): 287 | return 288 | 289 | SolarOptimizerCoordinator.hass.data[SOLAR_OPTIMIZER_DOMAIN][ 290 | "coordinator" 291 | ] = None 292 | 293 | @property 294 | def is_central_config_done(self) -> bool: 295 | """Return True if the central config is done""" 296 | return self._central_config_done 297 | 298 | @property 299 | def devices(self) -> list[ManagedDevice]: 300 | """Get all the managed device""" 301 | return self._devices 302 | 303 | def get_device_by_name(self, name: str) -> ManagedDevice | None: 304 | """Returns the device which name is given in argument""" 305 | for _, device in enumerate(self._devices): 306 | if device.name == name: 307 | return device 308 | return None 309 | 310 | def get_device_by_unique_id(self, uid: str) -> ManagedDevice | None: 311 | """Returns the device which name is given in argument""" 312 | for _, device in enumerate(self._devices): 313 | if device.unique_id == uid: 314 | return device 315 | return None 316 | 317 | def set_priority_weight_entity(self, entity: SelectEntity): 318 | """Set the priority weight entity""" 319 | self._priority_weight_entity = entity 320 | 321 | @property 322 | def priority_weight(self) -> int: 323 | """Get the priority weight""" 324 | if self._priority_weight_entity is None: 325 | return 0 326 | return self._priority_weight_entity.current_priority_weight 327 | 328 | @property 329 | def raz_time(self) -> time: 330 | """Get the raz time with default to DEFAULT_RAZ_TIME""" 331 | return self._raz_time 332 | 333 | def add_device(self, device: ManagedDevice): 334 | """Add a new device to the list of managed device""" 335 | # Append or replace the device 336 | for i, dev in enumerate(self._devices): 337 | if dev.unique_id == device.unique_id: 338 | self._devices[i] = device 339 | return 340 | self._devices.append(device) 341 | 342 | def remove_device(self, unique_id: str): 343 | """Remove a device from the list of managed device""" 344 | for i, dev in enumerate(self._devices): 345 | if dev.unique_id == unique_id: 346 | self._devices.pop(i) 347 | return 348 | -------------------------------------------------------------------------------- /tests/test_priority.py: -------------------------------------------------------------------------------- 1 | """ Priority Unit test module""" 2 | # from unittest.mock import patch 3 | from datetime import datetime, time 4 | 5 | from homeassistant.setup import async_setup_component 6 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 7 | from homeassistant.components.select import DOMAIN as SELECT_DOMAIN 8 | 9 | from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import 10 | 11 | 12 | async def test_priority_select_creation(hass: HomeAssistant): 13 | """A nominal start of Solar Optimizer should create the select entities""" 14 | 15 | entry_central = MockConfigEntry( 16 | domain=DOMAIN, 17 | title="Central", 18 | unique_id="centralUniqueId", 19 | data={ 20 | CONF_NAME: "Central", 21 | CONF_REFRESH_PERIOD_SEC: 60, 22 | CONF_DEVICE_TYPE: CONF_DEVICE_CENTRAL, 23 | CONF_POWER_CONSUMPTION_ENTITY_ID: "sensor.fake_power_consumption", 24 | CONF_POWER_PRODUCTION_ENTITY_ID: "sensor.fake_power_production", 25 | CONF_SELL_COST_ENTITY_ID: "input_number.fake_sell_cost", 26 | CONF_BUY_COST_ENTITY_ID: "input_number.fake_buy_cost", 27 | CONF_SELL_TAX_PERCENT_ENTITY_ID: "input_number.fake_sell_tax_percent", 28 | CONF_SMOOTH_PRODUCTION: True, 29 | CONF_BATTERY_SOC_ENTITY_ID: "sensor.fake_battery_soc", 30 | CONF_RAZ_TIME: "05:00", 31 | }, 32 | ) 33 | device_central = await create_managed_device( 34 | hass, 35 | entry_central, 36 | "centralUniqueId", 37 | ) 38 | 39 | assert device_central is None 40 | coordinator: SolarOptimizerCoordinator = SolarOptimizerCoordinator.get_coordinator() 41 | assert coordinator is not None 42 | assert coordinator.is_central_config_done is True 43 | 44 | priority_weight_entity = search_entity(hass, "select.solar_optimizer_priority_weight", SELECT_DOMAIN) 45 | assert priority_weight_entity is not None 46 | assert priority_weight_entity.name == "Priority weight" 47 | assert priority_weight_entity.current_option == PRIORITY_WEIGHT_NULL 48 | assert priority_weight_entity.options == PRIORITY_WEIGHTS 49 | 50 | assert coordinator.priority_weight == PRIORITY_WEIGHT_MAP.get(PRIORITY_WEIGHT_NULL) 51 | 52 | # Change the priority weight 53 | priority_weight_entity.select_option(PRIORITY_WEIGHT_HIGH) 54 | await hass.async_block_till_done() 55 | assert priority_weight_entity.current_option == PRIORITY_WEIGHT_HIGH 56 | assert coordinator.priority_weight == PRIORITY_WEIGHT_MAP.get(PRIORITY_WEIGHT_HIGH) 57 | 58 | entry_a = MockConfigEntry( 59 | domain=DOMAIN, 60 | title="Equipement A", 61 | unique_id="eqtAUniqueId", 62 | data={ 63 | CONF_NAME: "Equipement A", 64 | CONF_DEVICE_TYPE: CONF_DEVICE, 65 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 66 | CONF_POWER_MAX: 1000, 67 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 68 | CONF_DURATION_MIN: 0.3, 69 | CONF_DURATION_STOP_MIN: 0.1, 70 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 71 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 72 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 73 | CONF_BATTERY_SOC_THRESHOLD: 50, 74 | CONF_MAX_ON_TIME_PER_DAY_MIN: 10, 75 | CONF_MIN_ON_TIME_PER_DAY_MIN: 1, 76 | CONF_OFFPEAK_TIME: "22:00", 77 | }, 78 | ) 79 | 80 | device_a = await create_managed_device( 81 | hass, 82 | entry_a, 83 | "equipement_a", 84 | ) 85 | 86 | assert device_a is not None 87 | assert device_a.priority == PRIORITY_MAP.get(PRIORITY_MEDIUM) 88 | 89 | priority_entity_a = search_entity(hass, "select.solar_optimizer_priority_equipement_a", SELECT_DOMAIN) 90 | assert priority_entity_a is not None 91 | assert priority_entity_a.name == "Priority" 92 | assert priority_entity_a.current_option == PRIORITY_MEDIUM 93 | 94 | # Change the priority weight 95 | priority_entity_a.select_option(PRIORITY_HIGH) 96 | await hass.async_block_till_done() 97 | assert priority_entity_a.current_option == PRIORITY_HIGH 98 | assert device_a.priority == PRIORITY_MAP.get(PRIORITY_HIGH) 99 | 100 | entry_b = MockConfigEntry( 101 | domain=DOMAIN, 102 | title="Equipement B", 103 | unique_id="eqtBUniqueId", 104 | data={ 105 | CONF_NAME: "Equipement B", 106 | CONF_DEVICE_TYPE: CONF_POWERED_DEVICE, 107 | CONF_ENTITY_ID: "input_boolean.fake_device_b", 108 | CONF_POWER_MAX: 2000, 109 | CONF_POWER_MIN: 100, 110 | CONF_POWER_STEP: 150, 111 | CONF_CHECK_USABLE_TEMPLATE: "{{ False }}", 112 | CONF_DURATION_MIN: 1, 113 | CONF_DURATION_STOP_MIN: 2, 114 | CONF_DURATION_POWER_MIN: 3, 115 | CONF_ACTION_MODE: CONF_ACTION_MODE_EVENT, 116 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 117 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 118 | CONF_CONVERT_POWER_DIVIDE_FACTOR: 6, 119 | CONF_CHANGE_POWER_SERVICE: "input_number/set_value", 120 | CONF_POWER_ENTITY_ID: "input_number.tesla_amps", 121 | CONF_BATTERY_SOC_THRESHOLD: 0, 122 | CONF_MAX_ON_TIME_PER_DAY_MIN: 0, 123 | CONF_MIN_ON_TIME_PER_DAY_MIN: 0, 124 | CONF_OFFPEAK_TIME: "00:00", 125 | }, 126 | ) 127 | 128 | device_b = await create_managed_device( 129 | hass, 130 | entry_b, 131 | "equipement_b", 132 | ) 133 | 134 | assert device_b is not None 135 | assert device_b.priority == PRIORITY_MAP.get(PRIORITY_MEDIUM) 136 | priority_entity_b = search_entity(hass, "select.solar_optimizer_priority_equipement_b", SELECT_DOMAIN) 137 | assert priority_entity_b is not None 138 | assert priority_entity_b.name == "Priority" 139 | assert priority_entity_b.current_option == PRIORITY_MEDIUM 140 | 141 | # Change the priority weight 142 | priority_entity_b.select_option(PRIORITY_HIGH) 143 | await hass.async_block_till_done() 144 | assert priority_entity_b.current_option == PRIORITY_HIGH 145 | assert device_b.priority == PRIORITY_MAP.get(PRIORITY_HIGH) 146 | 147 | # In dev env, you should remove the skip decorator 148 | @pytest.mark.skip(reason="Do not work every time due to the random nature of the test") 149 | @pytest.mark.parametrize( 150 | "consumption_power, production_power, priority_weight, device_a_power_max, priority_a, device_b_power_min, device_b_power_max, priority_b, is_a_activated, is_b_activated, best_objective, device_a_power, device_b_power", 151 | [ 152 | # fmt: off 153 | # consumption_power, production_power, priority_weight, device_a_power_max, priority_a, device_b_power_min, device_b_power_max, priority_b, is_a_activated, is_b_activated, best_objective, device_a_power, device_b_power 154 | # not enough production 155 | ( 500, 100, PRIORITY_WEIGHT_NULL, 1000, PRIORITY_MEDIUM, 0, "2000", PRIORITY_MEDIUM, False, False, 250, 0, 0), 156 | # not enough production and very high priority 157 | ( 500, 100, PRIORITY_WEIGHT_VERY_HIGH, 1000, PRIORITY_MEDIUM, 0, "2000", PRIORITY_MEDIUM, False, False, 62.5, 0, 0), 158 | # enough production 159 | ( -1000, 1000, PRIORITY_WEIGHT_LOW, 1000, PRIORITY_MEDIUM, 200, "2000", PRIORITY_LOW, True, False, 0.4, 1000, 0), 160 | # enough production and very high priority for device B 161 | ( -1000, 1000, PRIORITY_WEIGHT_MEDIUM, 1000, PRIORITY_MEDIUM, 200, "2000", PRIORITY_VERY_HIGH, False, True, 0.25, 0, 1000), 162 | # large production and low priority for device B, high for device A but A stops at 1000 and B goes to 2000 163 | ( -2000, 2000, PRIORITY_WEIGHT_MEDIUM, 1000, PRIORITY_HIGH, 200, "2000", PRIORITY_LOW, True, True, 1.25, 1000, 1000), 164 | # fmt: on 165 | ], 166 | ) 167 | async def test_full_nominal_test_with_priority( 168 | hass: HomeAssistant, 169 | init_solar_optimizer_central_config, 170 | consumption_power, 171 | production_power, 172 | priority_weight, 173 | device_a_power_max, 174 | priority_a, 175 | device_b_power_min, 176 | device_b_power_max, 177 | priority_b, 178 | is_a_activated, 179 | is_b_activated, 180 | best_objective, 181 | device_a_power, 182 | device_b_power, 183 | ): 184 | """A full test with 2 equipements, one powered and one not powered""" 185 | 186 | # Set the priority weight before running the algorithm 187 | priority_weight_entity = search_entity(hass, "select.solar_optimizer_priority_weight", SELECT_DOMAIN) 188 | assert priority_weight_entity is not None 189 | priority_weight_entity.select_option(priority_weight) 190 | await hass.async_block_till_done() 191 | coordinator = SolarOptimizerCoordinator.get_coordinator() 192 | assert coordinator.priority_weight == PRIORITY_WEIGHT_MAP.get(priority_weight, 0) 193 | 194 | entry_a = MockConfigEntry( 195 | domain=DOMAIN, 196 | title="Equipement A", 197 | unique_id="eqtAUniqueId", 198 | data={ 199 | CONF_NAME: "Equipement A", 200 | CONF_DEVICE_TYPE: CONF_DEVICE, 201 | CONF_ENTITY_ID: "input_boolean.fake_device_a", 202 | CONF_POWER_MAX: device_a_power_max, 203 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 204 | CONF_DURATION_MIN: 0.3, 205 | CONF_DURATION_STOP_MIN: 0.1, 206 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 207 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 208 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 209 | CONF_BATTERY_SOC_THRESHOLD: 0, 210 | }, 211 | ) 212 | 213 | device_a = await create_managed_device( 214 | hass, 215 | entry_a, 216 | "equipement_a", 217 | ) 218 | 219 | assert device_a is not None 220 | assert device_a.name == "Equipement A" 221 | device_a_switch = search_entity( 222 | hass, "switch.solar_optimizer_equipement_a", SWITCH_DOMAIN 223 | ) 224 | 225 | assert device_a_switch is not None 226 | 227 | priority_entity_a = search_entity(hass, "select.solar_optimizer_priority_equipement_a", SELECT_DOMAIN) 228 | assert priority_entity_a is not None 229 | priority_entity_a.select_option(priority_a) 230 | await hass.async_block_till_done() 231 | 232 | entry_b = MockConfigEntry( 233 | domain=DOMAIN, 234 | title="Equipement B", 235 | unique_id="eqtBUniqueId", 236 | data={ 237 | CONF_NAME: "Equipement B", 238 | CONF_DEVICE_TYPE: CONF_POWERED_DEVICE, 239 | CONF_ENTITY_ID: "input_boolean.fake_device_b", 240 | CONF_POWER_MIN: device_b_power_min, 241 | CONF_POWER_MAX: device_b_power_max, 242 | CONF_POWER_STEP: 100, 243 | CONF_CHECK_USABLE_TEMPLATE: "{{ True }}", 244 | CONF_DURATION_MIN: 1, 245 | CONF_DURATION_STOP_MIN: 0.5, 246 | CONF_ACTION_MODE: CONF_ACTION_MODE_ACTION, 247 | CONF_ACTIVATION_SERVICE: "input_boolean/turn_on", 248 | CONF_DEACTIVATION_SERVICE: "input_boolean/turn_off", 249 | # CONF_BATTERY_SOC_THRESHOLD removed 250 | CONF_DURATION_POWER_MIN: 1, 251 | CONF_CONVERT_POWER_DIVIDE_FACTOR: 6, 252 | CONF_CHANGE_POWER_SERVICE: "input_number/set_value", 253 | CONF_POWER_ENTITY_ID: "input_number.tesla_amps", 254 | }, 255 | ) 256 | 257 | device_b = await create_managed_device( 258 | hass, 259 | entry_b, 260 | "equipement_b", 261 | ) 262 | 263 | assert device_b is not None 264 | assert device_b.name == "Equipement B" 265 | device_b_switch = search_entity( 266 | hass, "switch.solar_optimizer_equipement_b", SWITCH_DOMAIN 267 | ) 268 | 269 | assert device_b_switch is not None 270 | 271 | priority_entity_b = search_entity(hass, "select.solar_optimizer_priority_equipement_b", SELECT_DOMAIN) 272 | assert priority_entity_b is not None 273 | priority_entity_b.select_option(priority_b) 274 | await hass.async_block_till_done() 275 | 276 | side_effects = SideEffects( 277 | { 278 | "sensor.fake_power_consumption": State("sensor.fake_power_consumption", consumption_power), 279 | "sensor.fake_power_production": State("sensor.fake_power_production", production_power), 280 | "input_number.fake_sell_cost": State("input_number.fake_sell_cost", 1), 281 | "input_number.fake_buy_cost": State("input_number.fake_buy_cost", 1), 282 | "input_number.fake_sell_tax_percent": State("input_number.fake_sell_tax_percent", 0), 283 | "sensor.fake_battery_soc": State("sensor.fake_battery_soc", 0), 284 | }, 285 | State("unknown.entity_id", "unknown"), 286 | ) 287 | 288 | # fmt:off 289 | with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()): 290 | # fmt:on 291 | calculated_data = await coordinator._async_update_data() 292 | await hass.async_block_till_done() 293 | 294 | assert calculated_data["total_power"] == device_a_power + device_b_power 295 | assert calculated_data["best_objective"] == best_objective 296 | assert calculated_data["equipement_a"].is_waiting is is_a_activated 297 | assert calculated_data["equipement_a"].requested_power == device_a_power 298 | assert calculated_data["equipement_b"].is_waiting is is_b_activated 299 | assert calculated_data["equipement_b"].requested_power == device_b_power 300 | 301 | assert calculated_data["best_solution"][0]["name"] == "Equipement A" 302 | assert calculated_data["best_solution"][0]["state"] is is_a_activated 303 | assert calculated_data["best_solution"][0]["current_power"] == 0 304 | assert calculated_data["best_solution"][0]["requested_power"] == device_a_power 305 | assert calculated_data["best_solution"][1]["name"] == "Equipement B" 306 | assert calculated_data["best_solution"][1]["state"] is is_b_activated 307 | assert calculated_data["best_solution"][1]["current_power"] == 0 308 | assert calculated_data["best_solution"][1]["requested_power"] == device_b_power 309 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/switch.py: -------------------------------------------------------------------------------- 1 | """ A binary sensor entity that holds the state of each managed_device """ 2 | 3 | import logging 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_ON 8 | from homeassistant.core import callback, HomeAssistant, State, Event 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | from homeassistant.helpers.restore_state import RestoreEntity 12 | from homeassistant.components.switch import SwitchEntity, DOMAIN as SWITCH_DOMAIN 13 | 14 | from homeassistant.helpers.entity_platform import ( 15 | AddEntitiesCallback, 16 | ) 17 | 18 | from homeassistant.helpers.event import ( 19 | async_track_state_change_event, 20 | ) 21 | from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType 22 | 23 | from .const import * # pylint: disable=wildcard-import, unused-wildcard-import 24 | from .coordinator import SolarOptimizerCoordinator 25 | from .managed_device import ManagedDevice 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | async def async_setup_entry( 31 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 32 | ) -> None: 33 | """Setup the entries of type Binary sensor, one for each ManagedDevice""" 34 | _LOGGER.debug("Calling switch.async_setup_entry") 35 | 36 | coordinator: SolarOptimizerCoordinator = SolarOptimizerCoordinator.get_coordinator() 37 | 38 | entities = [] 39 | if entry.data[CONF_DEVICE_TYPE] == CONF_DEVICE_CENTRAL: 40 | return 41 | 42 | unique_id = name_to_unique_id(entry.data[CONF_NAME]) 43 | device = coordinator.get_device_by_unique_id(unique_id) 44 | if device is None: 45 | _LOGGER.error("Calling switch.async_setup_entry in error cause device with unique_id %s not found", unique_id) 46 | return 47 | # Not needed because the device is created in sensor.py which is the first to be called (see order in const.py) 48 | # device = ManagedDevice(hass, entry.data, coordinator) 49 | # coordinator.add_device(device) 50 | 51 | entity = ManagedDeviceSwitch(coordinator, hass, device) 52 | entities.append(entity) 53 | entity = ManagedDeviceEnable(hass, device) 54 | entities.append(entity) 55 | 56 | async_add_entities(entities) 57 | 58 | 59 | class ManagedDeviceSwitch(CoordinatorEntity, SwitchEntity): 60 | """The entity holding the algorithm calculation""" 61 | 62 | _entity_component_unrecorded_attributes = ( 63 | SwitchEntity._entity_component_unrecorded_attributes.union( 64 | frozenset( 65 | { 66 | "is_enabled", 67 | "is_active", 68 | "is_waiting", 69 | "is_usable", 70 | "can_change_power", 71 | "duration_sec", 72 | "duration_power_sec", 73 | "power_min", 74 | "power_max", 75 | "next_date_available", 76 | "next_date_available_power", 77 | "battery_soc_threshold", 78 | "battery_soc", 79 | } 80 | ) 81 | ) 82 | ) 83 | 84 | def __init__(self, coordinator, hass, device: ManagedDevice): 85 | _LOGGER.debug("Adding ManagedDeviceSwitch for %s", device.name) 86 | idx = name_to_unique_id(device.name) 87 | super().__init__(coordinator, context=idx) 88 | self._hass: HomeAssistant = hass 89 | self._device = device 90 | self.idx = idx 91 | self._attr_has_entity_name = True 92 | self.entity_id = f"{SWITCH_DOMAIN}.solar_optimizer_{idx}" 93 | self._attr_name = "Active" 94 | self._attr_unique_id = "solar_optimizer_active_" + idx 95 | self._entity_id = device.entity_id 96 | self._attr_is_on = device.is_active 97 | 98 | # Try to get the state if it exists 99 | # device: ManagedDevice = None 100 | # if (device := coordinator.get_device_by_unique_id(idx)) is not None: 101 | # self._device = device 102 | # else: 103 | # self._attr_is_on = None 104 | 105 | async def async_added_to_hass(self) -> None: 106 | """The entity have been added to hass, listen to state change of the underlying entity""" 107 | await super().async_added_to_hass() 108 | 109 | # Arme l'écoute de la première entité 110 | listener_cancel = async_track_state_change_event( 111 | self.hass, 112 | [self._entity_id], 113 | self._on_state_change, 114 | ) 115 | # desarme le timer lors de la destruction de l'entité 116 | self.async_on_remove(listener_cancel) 117 | 118 | # desarme le timer lors de la destruction de l'entité 119 | self.async_on_remove( 120 | self._hass.bus.async_listen( 121 | event_type=EVENT_TYPE_SOLAR_OPTIMIZER_ENABLE_STATE_CHANGE, 122 | listener=self._on_enable_state_change, 123 | ) 124 | ) 125 | 126 | self.update_custom_attributes(self._device) 127 | 128 | @callback 129 | async def _on_enable_state_change(self, event: Event) -> None: 130 | """Triggered when the ManagedDevice enable state have change""" 131 | 132 | # is it for me ? 133 | if ( 134 | not event.data 135 | or (device_id := event.data.get("device_unique_id")) != self.idx 136 | ): 137 | return 138 | 139 | # search for coordinator and device 140 | if not self.coordinator or not ( 141 | device := self.coordinator.get_device_by_unique_id(device_id) 142 | ): 143 | return 144 | 145 | _LOGGER.info( 146 | "Changing enabled state for %s to %s", device_id, device.is_enabled 147 | ) 148 | 149 | self.update_custom_attributes(device) 150 | self.async_write_ha_state() 151 | 152 | @callback 153 | async def _on_state_change(self, event: Event) -> None: 154 | """The entity have change its state""" 155 | _LOGGER.info( 156 | "Appel de on_state_change à %s avec l'event %s", datetime.now(), event 157 | ) 158 | 159 | if not event.data: 160 | return 161 | 162 | # search for coordinator and device 163 | if not self.coordinator or not ( 164 | device := self.coordinator.get_device_by_unique_id(self.idx) 165 | ): 166 | return 167 | 168 | new_state: State = event.data.get("new_state") 169 | # old_state: State = event.data.get("old_state") 170 | 171 | if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): 172 | _LOGGER.debug("Pas d'état disponible. Evenement ignoré") 173 | return 174 | 175 | # On recherche la date de l'event pour la stocker dans notre état 176 | new_state = self._device.is_active # new_state.state == STATE_ON 177 | if new_state == self._attr_is_on: 178 | return 179 | 180 | self._attr_is_on = new_state 181 | # On sauvegarde le nouvel état 182 | self.update_custom_attributes(device) 183 | self.async_write_ha_state() 184 | 185 | def update_custom_attributes(self, device): 186 | """Add some custom attributes to the entity""" 187 | current_tz = get_tz(self._hass) 188 | self._attr_extra_state_attributes: dict(str, str) = { 189 | "is_enabled": device.is_enabled, 190 | "is_active": device.is_active, 191 | "is_waiting": device.is_waiting, 192 | "is_usable": device.is_usable, 193 | "can_change_power": device.can_change_power, 194 | "current_power": device.current_power, 195 | "requested_power": device.requested_power, 196 | "duration_sec": device.duration_sec, 197 | "duration_power_sec": device.duration_power_sec, 198 | "power_min": device.power_min, 199 | "power_max": device.power_max, 200 | "next_date_available": device.next_date_available.astimezone( 201 | current_tz 202 | ).isoformat(), 203 | "next_date_available_power": device.next_date_available_power.astimezone( 204 | current_tz 205 | ).isoformat(), 206 | "battery_soc_threshold": device.battery_soc_threshold, 207 | "battery_soc": device.battery_soc, 208 | "device_name": device.name, 209 | } 210 | 211 | @callback 212 | def _handle_coordinator_update(self) -> None: 213 | """Handle updated data from the coordinator.""" 214 | _LOGGER.debug("Calling _handle_coordinator_update for %s", self._attr_name) 215 | 216 | if not self.coordinator or not self.coordinator.data: 217 | _LOGGER.debug("No coordinator found or no data...") 218 | return 219 | 220 | device: ManagedDevice = self.coordinator.data.get(self.idx) 221 | if not device: 222 | # it is possible to not have device in coordinator update (if device is not enabled) 223 | _LOGGER.debug("No device %s found ...", self.idx) 224 | return 225 | 226 | self._attr_is_on = device.is_active 227 | self.update_custom_attributes(device) 228 | self.async_write_ha_state() 229 | 230 | def turn_on(self, **kwargs: Any) -> None: 231 | """Turn the entity on.""" 232 | self.hass.async_create_task(self.async_turn_on(**kwargs)) 233 | 234 | async def async_turn_on(self, **kwargs: Any) -> None: 235 | """Turn the entity on.""" 236 | _LOGGER.info("Turn_on Solar Optimizer switch %s", self._attr_name) 237 | # search for coordinator and device 238 | if not self.coordinator or not ( 239 | device := self.coordinator.get_device_by_unique_id(self.idx) 240 | ): 241 | return 242 | 243 | if not self._attr_is_on: 244 | await device.activate() 245 | self._attr_is_on = True 246 | self.update_custom_attributes(device) 247 | self.async_write_ha_state() 248 | _LOGGER.debug("Turn_on Solar Optimizer switch %s ok", self._attr_name) 249 | 250 | def turn_off( # pylint: disable=useless-parent-delegation 251 | self, **kwargs: Any 252 | ) -> None: 253 | """Turn the entity off.""" 254 | # We cannot call async_turn_off from a sync context so we call the async_turn_off in a task 255 | self.hass.async_create_task(self.async_turn_off(**kwargs)) 256 | 257 | async def async_turn_off(self, **kwargs: Any) -> None: 258 | """Turn the entity on.""" 259 | _LOGGER.info("Turn_off Solar Optimizer switch %s", self._attr_name) 260 | # search for coordinator and device 261 | if not self.coordinator or not ( 262 | device := self.coordinator.get_device_by_unique_id(self.idx) 263 | ): 264 | return 265 | 266 | if self._attr_is_on: 267 | _LOGGER.debug("Will deactivate %s", self._attr_name) 268 | await device.deactivate() 269 | self._attr_is_on = False 270 | self.update_custom_attributes(device) 271 | self.async_write_ha_state() 272 | _LOGGER.debug("Turn_ff Solar Optimizer switch %s ok", self._attr_name) 273 | else: 274 | _LOGGER.debug("Not active %s", self._attr_name) 275 | 276 | @property 277 | def device_info(self) -> DeviceInfo | None: 278 | # Retournez des informations sur le périphérique associé à votre entité 279 | return DeviceInfo( 280 | entry_type=DeviceEntryType.SERVICE, 281 | identifiers={(DOMAIN, self._device.name)}, 282 | name="Solar Optimizer-" + self._device.name, 283 | manufacturer=DEVICE_MANUFACTURER, 284 | model=DEVICE_MODEL, 285 | ) 286 | 287 | @property 288 | def get_attr_extra_state_attributes(self): 289 | """Get the extra state attributes for the entity""" 290 | return self._attr_extra_state_attributes 291 | 292 | 293 | class ManagedDeviceEnable(SwitchEntity, RestoreEntity): 294 | """The that enables the ManagedDevice optimisation with""" 295 | 296 | _device: ManagedDevice 297 | 298 | def __init__(self, hass: HomeAssistant, device: ManagedDevice): 299 | name = name_to_unique_id(device.name) 300 | self._hass: HomeAssistant = hass 301 | self._device = device 302 | self._attr_has_entity_name = True 303 | self.entity_id = f"{SWITCH_DOMAIN}.enable_solar_optimizer_{name}" 304 | self._attr_name = "Enable" 305 | self._attr_unique_id = "solar_optimizer_enable_" + name 306 | self._attr_is_on = True 307 | 308 | @property 309 | def device_info(self) -> DeviceInfo | None: 310 | # Retournez des informations sur le périphérique associé à votre entité 311 | return DeviceInfo( 312 | entry_type=DeviceEntryType.SERVICE, 313 | identifiers={(DOMAIN, self._device.name)}, 314 | name="Solar Optimizer-" + self._device.name, 315 | manufacturer=DEVICE_MANUFACTURER, 316 | model=DEVICE_MODEL, 317 | ) 318 | 319 | @property 320 | def icon(self) -> str | None: 321 | return "mdi:check" 322 | 323 | async def async_added_to_hass(self): 324 | await super().async_added_to_hass() 325 | 326 | # Récupérer le dernier état sauvegardé de l'entité 327 | last_state = await self.async_get_last_state() 328 | 329 | # Si l'état précédent existe, vous pouvez l'utiliser 330 | if last_state is not None: 331 | self._attr_is_on = last_state.state == STATE_ON 332 | else: 333 | # Si l'état précédent n'existe pas, initialisez l'état comme vous le souhaitez 334 | self._attr_is_on = True 335 | 336 | # this breaks the start of integration 337 | self.update_device_enabled() 338 | 339 | @callback 340 | async def async_turn_on(self, **kwargs: Any) -> None: 341 | """Turn the entity on.""" 342 | self.turn_on(**kwargs) 343 | 344 | @callback 345 | async def async_turn_off( # pylint: disable=useless-parent-delegation 346 | self, **kwargs: Any 347 | ) -> None: 348 | """Turn the entity off.""" 349 | # We cannot call async_turn_off from a sync context so we call the async_turn_off in a task 350 | self.turn_off(**kwargs) 351 | 352 | def update_device_enabled(self) -> None: 353 | """Update the device is enabled flag""" 354 | if not self._device: 355 | return 356 | 357 | self._device.set_enable(self._attr_is_on) 358 | 359 | @overrides 360 | def turn_off(self, **kwargs: Any): 361 | self._attr_is_on = False 362 | self.async_write_ha_state() 363 | self.update_device_enabled() 364 | 365 | @overrides 366 | def turn_on(self, **kwargs: Any): 367 | self._attr_is_on = True 368 | self.async_write_ha_state() 369 | self.update_device_enabled() 370 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/sensor.py: -------------------------------------------------------------------------------- 1 | """ A sensor entity that holds the result of the recuit simule algorithm """ 2 | 3 | import logging 4 | from datetime import datetime, timedelta, time 5 | from homeassistant.const import ( 6 | UnitOfPower, 7 | UnitOfTime, 8 | STATE_UNAVAILABLE, 9 | STATE_UNKNOWN, 10 | STATE_ON, 11 | ) 12 | from homeassistant.core import callback, HomeAssistant, Event, State 13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 14 | from homeassistant.helpers import entity_platform 15 | from homeassistant.components.sensor import ( 16 | SensorEntity, 17 | SensorDeviceClass, 18 | SensorStateClass, 19 | DOMAIN as SENSOR_DOMAIN, 20 | ) 21 | from homeassistant.config_entries import ConfigEntry 22 | 23 | from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType 24 | 25 | from homeassistant.helpers.entity_platform import ( 26 | AddEntitiesCallback, 27 | ) 28 | from homeassistant.helpers.restore_state import ( 29 | RestoreEntity, 30 | async_get as restore_async_get, 31 | ) 32 | from homeassistant.helpers.event import ( 33 | async_track_state_change_event, 34 | async_track_time_change, 35 | async_track_time_interval, 36 | ) 37 | from homeassistant.helpers.device_registry import DeviceInfo 38 | 39 | 40 | from .const import * # pylint: disable=wildcard-import, unused-wildcard-import 41 | from .coordinator import SolarOptimizerCoordinator 42 | from .managed_device import ManagedDevice 43 | 44 | _LOGGER = logging.getLogger(__name__) 45 | 46 | 47 | async def async_setup_entry( 48 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 49 | ) -> None: 50 | """Setup the entries of type Sensor""" 51 | 52 | # check that there is some data to configure 53 | if (device_type := entry.data.get(CONF_DEVICE_TYPE, None)) is None: 54 | return 55 | 56 | # Sets the config entries values to SolarOptimizer coordinator 57 | coordinator: SolarOptimizerCoordinator = SolarOptimizerCoordinator.get_coordinator() 58 | 59 | # inititalize the coordinator if entry if the central config 60 | if device_type == CONF_DEVICE_CENTRAL: 61 | # This is device entry 62 | entity1 = SolarOptimizerSensorEntity(coordinator, hass, "best_objective") 63 | entity2 = SolarOptimizerSensorEntity(coordinator, hass, "total_power") 64 | entity3 = SolarOptimizerSensorEntity(coordinator, hass, "power_production") 65 | entity4 = SolarOptimizerSensorEntity(coordinator, hass, "power_production_brut") 66 | 67 | async_add_entities([entity1, entity2, entity3, entity4], False) 68 | 69 | await coordinator.configure(entry) 70 | return 71 | 72 | entities = [] 73 | device = coordinator.get_device_by_unique_id( 74 | name_to_unique_id(entry.data[CONF_NAME]) 75 | ) 76 | if device is None: 77 | device = ManagedDevice(hass, entry.data, coordinator) 78 | coordinator.add_device(device) 79 | 80 | entity1 = TodayOnTimeSensor( 81 | hass, 82 | coordinator, 83 | device, 84 | ) 85 | 86 | async_add_entities([entity1], False) 87 | 88 | # Add services 89 | platform = entity_platform.async_get_current_platform() 90 | platform.async_register_entity_service( 91 | SERVICE_RESET_ON_TIME, 92 | {}, 93 | "service_reset_on_time", 94 | ) 95 | 96 | 97 | class SolarOptimizerSensorEntity(CoordinatorEntity, SensorEntity): 98 | """The entity holding the algorithm calculation""" 99 | 100 | def __init__(self, coordinator, hass, idx): 101 | super().__init__(coordinator, context=idx) 102 | self._hass = hass 103 | self.idx = idx 104 | self._attr_name = idx 105 | self._attr_unique_id = "solar_optimizer_" + idx 106 | 107 | self._attr_native_value = None 108 | 109 | @callback 110 | def _handle_coordinator_update(self) -> None: 111 | """Handle updated data from the coordinator.""" 112 | if ( 113 | not self.coordinator 114 | or not self.coordinator.data 115 | or (value := self.coordinator.data.get(self.idx)) is None 116 | ): 117 | _LOGGER.debug("No coordinator found or no data...") 118 | return 119 | 120 | self._attr_native_value = value 121 | self.async_write_ha_state() 122 | 123 | @property 124 | def device_info(self): 125 | # Retournez des informations sur le périphérique associé à votre entité 126 | return DeviceInfo( 127 | entry_type=DeviceEntryType.SERVICE, 128 | identifiers={(DOMAIN, CONF_DEVICE_CENTRAL)}, 129 | name="Solar Optimizer", 130 | manufacturer=DEVICE_MANUFACTURER, 131 | model=INTEGRATION_MODEL, 132 | ) 133 | 134 | @property 135 | def icon(self) -> str | None: 136 | if self.idx == "best_objective": 137 | return "mdi:bullseye-arrow" 138 | elif self.idx == "total_power": 139 | return "mdi:flash" 140 | elif self.idx == "battery_soc": 141 | return "mdi:battery" 142 | else: 143 | return "mdi:solar-power-variant" 144 | 145 | @property 146 | def device_class(self) -> SensorDeviceClass | None: 147 | if self.idx == "best_objective": 148 | return SensorDeviceClass.MONETARY 149 | elif self.idx == "battery_soc": 150 | return SensorDeviceClass.BATTERY 151 | else: 152 | return SensorDeviceClass.POWER 153 | 154 | @property 155 | def state_class(self) -> SensorStateClass | None: 156 | if self.device_class == SensorDeviceClass.POWER: 157 | return SensorStateClass.MEASUREMENT 158 | else: 159 | return SensorStateClass.TOTAL 160 | 161 | @property 162 | def native_unit_of_measurement(self) -> str | None: 163 | if self.idx == "best_objective": 164 | return "€" 165 | elif self.idx == "battery_soc": 166 | return "%" 167 | else: 168 | return UnitOfPower.WATT 169 | 170 | 171 | class TodayOnTimeSensor(SensorEntity, RestoreEntity): 172 | """Gives the time in minute in which the device was on for a day""" 173 | 174 | _entity_component_unrecorded_attributes = ( 175 | SensorEntity._entity_component_unrecorded_attributes.union( 176 | frozenset( 177 | { 178 | "max_on_time_per_day_sec", 179 | "max_on_time_per_day_min", 180 | "max_on_time_hms", 181 | "on_time_hms", 182 | "raz_time", 183 | "should_be_forced_offpeak", 184 | } 185 | ) 186 | ) 187 | ) 188 | 189 | def __init__( 190 | self, 191 | hass: HomeAssistant, 192 | coordinator: SolarOptimizerCoordinator, 193 | device: ManagedDevice, 194 | ) -> None: 195 | """Initialize the sensor""" 196 | self.hass = hass 197 | idx = name_to_unique_id(device.name) 198 | self._attr_name = "On time today" 199 | self._attr_has_entity_name = True 200 | self.entity_id = f"{SENSOR_DOMAIN}.on_time_today_solar_optimizer_{idx}" 201 | self._attr_unique_id = "solar_optimizer_on_time_today_" + idx 202 | self._attr_native_value = None 203 | self._entity_id = device.entity_id 204 | self._device = device 205 | self._coordinator = coordinator 206 | self._last_datetime_on = None 207 | self._old_state = None 208 | 209 | async def async_added_to_hass(self) -> None: 210 | """The entity have been added to hass, listen to state change of the underlying entity""" 211 | await super().async_added_to_hass() 212 | 213 | # Arme l'écoute de la première entité 214 | listener_cancel = async_track_state_change_event( 215 | self.hass, 216 | [self._entity_id], 217 | self._on_state_change, 218 | ) 219 | # desarme le timer lors de la destruction de l'entité 220 | self.async_on_remove(listener_cancel) 221 | 222 | # Add listener to midnight to reset the counter 223 | raz_time: time = self._coordinator.raz_time 224 | self.async_on_remove( 225 | async_track_time_change( 226 | hass=self.hass, 227 | action=self._on_midnight, 228 | hour=raz_time.hour, 229 | minute=raz_time.minute, 230 | second=0, 231 | ) 232 | ) 233 | 234 | # Add a listener to calculate OnTine at each minute 235 | self.async_on_remove( 236 | async_track_time_interval( 237 | self.hass, 238 | self._on_update_on_time, 239 | interval=timedelta(minutes=1), 240 | ) 241 | ) 242 | 243 | # restore the last value or set to 0 244 | self._attr_native_value = 0 245 | old_state = await self.async_get_last_state() 246 | if old_state is not None: 247 | if old_state.state is not None and old_state.state != "unknown": 248 | self._attr_native_value = round(float(old_state.state)) 249 | _LOGGER.info( 250 | "%s - read on_time from storage is %s", 251 | self, 252 | self._attr_native_value, 253 | ) 254 | 255 | old_value = old_state.attributes.get("last_datetime_on") 256 | if old_value is not None: 257 | self._last_datetime_on = datetime.fromisoformat(old_value) 258 | 259 | self.update_custom_attributes() 260 | self.async_write_ha_state() 261 | 262 | async def async_will_remove_from_hass(self): 263 | """Try to force backup of entity""" 264 | _LOGGER.info( 265 | "%s - force write before remove. on_time is %s", 266 | self, 267 | self._attr_native_value, 268 | ) 269 | # Force dump in background 270 | await restore_async_get(self.hass).async_dump_states() 271 | 272 | @callback 273 | async def _on_state_change(self, event: Event) -> None: 274 | """The entity have change its state""" 275 | now = self._device.now 276 | _LOGGER.info("Call of on_state_change at %s with event %s", now, event) 277 | 278 | if not event.data: 279 | return 280 | 281 | new_state: State = event.data.get("new_state") 282 | # old_state: State = event.data.get("old_state") 283 | 284 | if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): 285 | _LOGGER.debug("No available state. Event is ignored") 286 | return 287 | 288 | need_save = False 289 | # We search for the date of the event 290 | new_state = self._device.is_active # new_state.state == STATE_ON 291 | # old_state = old_state is not None and old_state.state == STATE_ON 292 | if new_state and not self._old_state: 293 | _LOGGER.debug("The managed device becomes on - store the last_datetime_on") 294 | self._last_datetime_on = now 295 | need_save = True 296 | 297 | if not new_state: 298 | if self._old_state and self._last_datetime_on is not None: 299 | _LOGGER.debug("The managed device becomes off - increment the delta time") 300 | self._attr_native_value += round((now - self._last_datetime_on).total_seconds()) 301 | self._last_datetime_on = None 302 | need_save = True 303 | 304 | # On sauvegarde le nouvel état 305 | if need_save: 306 | self._old_state = new_state 307 | self.update_custom_attributes() 308 | self.async_write_ha_state() 309 | self._device.set_on_time(self._attr_native_value) 310 | 311 | @callback 312 | async def _on_midnight(self, _=None) -> None: 313 | """Called each day at midnight to reset the counter""" 314 | self._attr_native_value = 0 315 | 316 | _LOGGER.info("Call of _on_midnight to reset onTime") 317 | 318 | # reset _last_datetime_on to now if it was active. Here we lose the time on of yesterday but it is too late I can't do better. 319 | # Else you will have two point with the same date and not the same value (one with value + duration and one with 0) 320 | if self._last_datetime_on is not None: 321 | self._last_datetime_on = self._device.now 322 | 323 | self.update_custom_attributes() 324 | self.async_write_ha_state() 325 | self._device.set_on_time(self._attr_native_value) 326 | 327 | @callback 328 | async def _on_update_on_time(self, _=None) -> None: 329 | """Called priodically to update the on_time sensor""" 330 | now = self._device.now 331 | _LOGGER.debug("Call of _on_update_on_time at %s", now) 332 | 333 | if self._last_datetime_on is not None and self._device.is_active: 334 | self._attr_native_value += round( 335 | (now - self._last_datetime_on).total_seconds() 336 | ) 337 | self._last_datetime_on = now 338 | self.update_custom_attributes() 339 | self.async_write_ha_state() 340 | 341 | self._device.set_on_time(self._attr_native_value) 342 | 343 | def update_custom_attributes(self): 344 | """Add some custom attributes to the entity""" 345 | self._attr_extra_state_attributes: dict(str, str) = { 346 | "last_datetime_on": self._last_datetime_on, 347 | "max_on_time_per_day_min": round(self._device.max_on_time_per_day_sec / 60), 348 | "max_on_time_per_day_sec": self._device.max_on_time_per_day_sec, 349 | "on_time_hms": seconds_to_hms(self._attr_native_value), 350 | "max_on_time_hms": seconds_to_hms(self._device.max_on_time_per_day_sec), 351 | "raz_time": self._coordinator.raz_time, 352 | "should_be_forced_offpeak": self._device.should_be_forced_offpeak, 353 | "offpeak_time": self._device.offpeak_time, 354 | } 355 | 356 | @property 357 | def icon(self) -> str | None: 358 | return "mdi:timer-play" 359 | 360 | @property 361 | def device_info(self) -> DeviceInfo | None: 362 | # Retournez des informations sur le périphérique associé à votre entité 363 | return DeviceInfo( 364 | entry_type=DeviceEntryType.SERVICE, 365 | identifiers={(DOMAIN, self._device.name)}, 366 | name="Solar Optimizer-" + self._device.name, 367 | manufacturer=DEVICE_MANUFACTURER, 368 | model=DEVICE_MODEL, 369 | ) 370 | 371 | @property 372 | def device_class(self) -> SensorDeviceClass | None: 373 | return SensorDeviceClass.DURATION 374 | 375 | @property 376 | def state_class(self) -> SensorStateClass | None: 377 | return SensorStateClass.MEASUREMENT 378 | 379 | @property 380 | def native_unit_of_measurement(self) -> str | None: 381 | return UnitOfTime.SECONDS 382 | 383 | @property 384 | def suggested_display_precision(self) -> int | None: 385 | """Return the suggested number of decimal digits for display.""" 386 | return 0 387 | 388 | @property 389 | def last_datetime_on(self) -> datetime | None: 390 | """Returns the last_datetime_on""" 391 | return self._last_datetime_on 392 | 393 | @property 394 | def get_attr_extra_state_attributes(self): 395 | """Get the extra state attributes for the entity""" 396 | return self._attr_extra_state_attributes 397 | 398 | async def service_reset_on_time(self): 399 | """Called by a service call: 400 | service: sensor.reset_on_time 401 | data: 402 | target: 403 | entity_id: solar_optimizer.on_time_today_solar_optimizer_ 404 | """ 405 | _LOGGER.info("%s - Calling service_reset_on_time", self) 406 | await self._on_midnight() 407 | -------------------------------------------------------------------------------- /custom_components/solar_optimizer/simulated_annealing_algo.py: -------------------------------------------------------------------------------- 1 | """ The Simulated Annealing (recuit simulé) algorithm""" 2 | import logging 3 | import random 4 | import math 5 | import copy 6 | 7 | from .managed_device import ManagedDevice 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | DEBUG = False 12 | 13 | 14 | class SimulatedAnnealingAlgorithm: 15 | """The class which implemenets the Simulated Annealing algorithm""" 16 | 17 | # Paramètres de l'algorithme de recuit simulé 18 | _temperature_initiale: float = 1000 19 | _temperature_minimale: float = 0.1 20 | _facteur_refroidissement: float = 0.95 21 | _nombre_iterations: float = 1000 22 | _equipements: list[ManagedDevice] 23 | _puissance_totale_eqt_initiale: float 24 | _cout_achat: float = 15 # centimes 25 | _cout_revente: float = 10 # centimes 26 | _taxe_revente: float = 10 # pourcentage 27 | _consommation_net: float 28 | _production_solaire: float 29 | 30 | def __init__( 31 | self, 32 | initial_temp: float, 33 | min_temp: float, 34 | cooling_factor: float, 35 | max_iteration_number: int, 36 | ): 37 | """Initialize the algorithm with values""" 38 | self._temperature_initiale = initial_temp 39 | self._temperature_minimale = min_temp 40 | self._facteur_refroidissement = cooling_factor 41 | self._nombre_iterations = max_iteration_number 42 | _LOGGER.info( 43 | "Initializing the SimulatedAnnealingAlgorithm with initial_temp=%.2f min_temp=%.2f cooling_factor=%.2f max_iterations_number=%d", 44 | self._temperature_initiale, 45 | self._temperature_minimale, 46 | self._facteur_refroidissement, 47 | self._nombre_iterations, 48 | ) 49 | 50 | def recuit_simule( 51 | self, 52 | devices: list[ManagedDevice], 53 | power_consumption: float, 54 | solar_power_production: float, 55 | sell_cost: float, 56 | buy_cost: float, 57 | sell_tax_percent: float, 58 | battery_soc: float, 59 | priority_weight: int, 60 | ): 61 | """The entrypoint of the algorithm: 62 | You should give: 63 | - devices: a list of ManagedDevices. devices that are is_usable false are not taken into account 64 | - power_consumption: the current power consumption. Can be negeative if power is given back to grid. 65 | - solar_power_production: the solar production power 66 | - sell_cost: the sell cost of energy 67 | - buy_cost: the buy cost of energy 68 | - sell_tax_percent: a sell taxe applied to sell energy (a percentage) 69 | - battery_soc: the state of charge of the battery (0-100) 70 | - priority_weight: the weight of the priority in the cost calculation. 10 means 10% 71 | 72 | In return you will have: 73 | - best_solution: a list of object in whitch name, power_max and state are set, 74 | - best_objectif: the measure of the objective for that solution, 75 | - total_power_consumption: the total of power consumption for all equipments which should be activated (state=True) 76 | """ 77 | if ( 78 | len(devices) <= 0 # pylint: disable=too-many-boolean-expressions 79 | or power_consumption is None 80 | or solar_power_production is None 81 | or sell_cost is None 82 | or buy_cost is None 83 | or sell_tax_percent is None 84 | ): 85 | _LOGGER.info( 86 | "Not all informations are available for Simulated Annealign algorithm to work. Calculation is abandoned" 87 | ) 88 | return [], -1, -1 89 | 90 | _LOGGER.debug( 91 | "Calling recuit_simule with power_consumption=%.2f, solar_power_production=%.2f sell_cost=%.2f, buy_cost=%.2f, tax=%.2f%% devices=%s", 92 | power_consumption, 93 | solar_power_production, 94 | sell_cost, 95 | buy_cost, 96 | sell_tax_percent, 97 | devices, 98 | ) 99 | self._cout_achat = buy_cost 100 | self._cout_revente = sell_cost 101 | self._taxe_revente = sell_tax_percent 102 | self._consommation_net = power_consumption 103 | self._production_solaire = solar_power_production 104 | self._priority_weight = priority_weight / 100.0 # to get percentage 105 | 106 | # fix #131 - costs cannot be negative or 0 107 | if self._cout_achat <= 0 or self._cout_revente <= 0: 108 | _LOGGER.warning( 109 | "The cost of energy cannot be negative or 0. Buy cost=%.2f, Sell cost=%.2f. Setting them to 1", 110 | self._cout_achat, 111 | self._cout_revente, 112 | ) 113 | self._cout_achat = self._cout_revente = 1 114 | 115 | self._equipements = [] 116 | for _, device in enumerate(devices): 117 | if not device.is_enabled: 118 | _LOGGER.debug("%s is disabled. Forget it", device.name) 119 | continue 120 | 121 | device.set_battery_soc(battery_soc) 122 | usable = device.is_usable 123 | waiting = device.is_waiting 124 | # Force deactivation if active, not usable and not waiting 125 | force_state = ( 126 | False 127 | if device.is_active 128 | and ((not usable and not waiting) or device.current_power <= 0) 129 | else device.is_active 130 | ) 131 | self._equipements.append( 132 | { 133 | "power_max": device.power_max, 134 | "power_min": device.power_min, 135 | "power_step": device.power_step, 136 | "current_power": device.current_power, # if force_state else 0, 137 | # Initial Requested power is the current power if usable 138 | "requested_power": device.current_power, # if force_state else 0, 139 | "name": device.name, 140 | "state": force_state, 141 | "is_usable": device.is_usable, 142 | "is_waiting": waiting, 143 | "can_change_power": device.can_change_power, 144 | "priority": device.priority, 145 | } 146 | ) 147 | if DEBUG: 148 | _LOGGER.debug("enabled _equipements are: %s", self._equipements) 149 | 150 | # Générer une solution initiale 151 | solution_actuelle = self.generer_solution_initiale(self._equipements) 152 | meilleure_solution = solution_actuelle 153 | meilleure_objectif = self.calculer_objectif(solution_actuelle) 154 | temperature = self._temperature_initiale 155 | 156 | for _ in range(self._nombre_iterations): 157 | # Générer un voisin 158 | objectif_actuel = self.calculer_objectif(solution_actuelle) 159 | if DEBUG: 160 | _LOGGER.debug("Objectif actuel : %.2f", objectif_actuel) 161 | 162 | voisin = self.permuter_equipement(solution_actuelle) 163 | 164 | # Calculer les objectifs pour la solution actuelle et le voisin 165 | objectif_voisin = self.calculer_objectif(voisin) 166 | if DEBUG: 167 | _LOGGER.debug("Objectif voisin : %2.f", objectif_voisin) 168 | 169 | # Accepter le voisin si son objectif est meilleur ou si la consommation totale n'excède pas la production solaire 170 | if objectif_voisin < objectif_actuel: 171 | _LOGGER.debug("---> On garde l'objectif voisin") 172 | solution_actuelle = voisin 173 | if objectif_voisin < meilleure_objectif: 174 | _LOGGER.debug("---> C'est la meilleure jusque là") 175 | meilleure_solution = voisin 176 | meilleure_objectif = objectif_voisin 177 | else: 178 | # Accepter le voisin avec une certaine probabilité 179 | probabilite = math.exp( 180 | (objectif_actuel - objectif_voisin) / temperature 181 | ) 182 | if (seuil := random.random()) < probabilite: 183 | solution_actuelle = voisin 184 | if DEBUG: 185 | _LOGGER.debug( 186 | "---> On garde l'objectif voisin car seuil (%.2f) inférieur à proba (%.2f)", 187 | seuil, 188 | probabilite, 189 | ) 190 | else: 191 | if DEBUG: 192 | _LOGGER.debug("--> On ne prend pas") 193 | 194 | # Réduire la température 195 | temperature *= self._facteur_refroidissement 196 | if DEBUG: 197 | _LOGGER.debug(" !! Temperature %.2f", temperature) 198 | if temperature < self._temperature_minimale or meilleure_objectif <= 0: 199 | break 200 | 201 | return ( 202 | meilleure_solution, 203 | meilleure_objectif, 204 | self.consommation_equipements(meilleure_solution), 205 | ) 206 | 207 | def calculer_objectif(self, solution) -> float: 208 | """Calcul de l'objectif : minimiser le surplus de production solaire 209 | rejets = 0 if consommation_net >=0 else -consommation_net 210 | consommation_solaire = min(production_solaire, production_solaire - rejets) 211 | consommation_totale = consommation_net + consommation_solaire 212 | """ 213 | 214 | puissance_totale_eqt = self.consommation_equipements(solution) 215 | diff_puissance_totale_eqt = ( 216 | puissance_totale_eqt - self._puissance_totale_eqt_initiale 217 | ) 218 | 219 | new_consommation_net = self._consommation_net + diff_puissance_totale_eqt 220 | new_rejets = 0 if new_consommation_net >= 0 else -new_consommation_net 221 | new_import = 0 if new_consommation_net < 0 else new_consommation_net 222 | new_consommation_solaire = min( 223 | self._production_solaire, self._production_solaire - new_rejets 224 | ) 225 | new_consommation_totale = ( 226 | new_consommation_net + new_rejets 227 | ) + new_consommation_solaire 228 | if DEBUG: 229 | _LOGGER.debug( 230 | "Objectif : cette solution ajoute %.3fW a la consommation initial. Nouvelle consommation nette=%.3fW. Nouveaux rejets=%.3fW. Nouvelle conso totale=%.3fW", 231 | diff_puissance_totale_eqt, 232 | new_consommation_net, 233 | new_rejets, 234 | new_consommation_totale, 235 | ) 236 | 237 | cout_revente_impose = self._cout_revente * (1.0 - self._taxe_revente / 100.0) 238 | coef_import = (self._cout_achat) / (self._cout_achat + cout_revente_impose) 239 | coef_rejets = (cout_revente_impose) / (self._cout_achat + cout_revente_impose) 240 | 241 | consumption_coef = coef_import * new_import + coef_rejets * new_rejets 242 | # calculate the priority coef as the sum of the priority of all devices 243 | # in the solution 244 | if puissance_totale_eqt > 0: 245 | priority_coef = sum((equip["priority"] * equip["requested_power"] / puissance_totale_eqt) for i, equip in enumerate(solution) if equip["state"]) 246 | else: 247 | priority_coef = 0 248 | priority_weight = self._priority_weight 249 | 250 | ret = consumption_coef * (1.0 - priority_weight) + priority_coef * priority_weight 251 | return ret 252 | 253 | def generer_solution_initiale(self, solution): 254 | """Generate the initial solution (which is the solution given in argument) and calculate the total initial power""" 255 | self._puissance_totale_eqt_initiale = self.consommation_equipements(solution) 256 | return copy.deepcopy(solution) 257 | 258 | def consommation_equipements(self, solution): 259 | """The total power consumption for all active equipement""" 260 | return sum( 261 | equipement["requested_power"] 262 | for _, equipement in enumerate(solution) 263 | if equipement["state"] 264 | ) 265 | 266 | def calculer_new_power( 267 | self, current_power, power_step, power_min, power_max, can_switch_off 268 | ): 269 | """Calcul une nouvelle puissance""" 270 | choices = [] 271 | power_min_to_use = max(0, power_min - power_step) if can_switch_off else power_min 272 | 273 | # add all choices from current_power to power_min_to_use descending 274 | cp = current_power 275 | choice = -1 276 | while cp > power_min_to_use: 277 | cp -= power_step 278 | choices.append(choice) 279 | choice -= 1 280 | 281 | # if current_power > power_min_to_use: 282 | # choices.append(-1) 283 | 284 | # add all choices from current_power to power_max ascending 285 | cp = current_power 286 | choice = 1 287 | while cp < power_max: 288 | cp += power_step 289 | choices.append(choice) 290 | choice += 1 291 | # if current_power < power_max: 292 | # choices.append(1) 293 | 294 | if len(choices) <= 0: 295 | # No changes 296 | return current_power 297 | 298 | power_add = random.choice(choices) * power_step 299 | _LOGGER.debug("Adding %d power to current_power (%d)", power_add, current_power) 300 | requested_power = current_power + power_add 301 | _LOGGER.debug("New requested_power is %s", requested_power) 302 | return requested_power 303 | 304 | def permuter_equipement(self, solution): 305 | """Permuter le state d'un equipement eau hasard""" 306 | voisin = copy.deepcopy(solution) 307 | 308 | usable = [eqt for eqt in voisin if eqt["is_usable"]] 309 | 310 | if len(usable) <= 0: 311 | return voisin 312 | 313 | eqt = random.choice(usable) 314 | 315 | # name = eqt["name"] 316 | state = eqt["state"] 317 | can_change_power = eqt["can_change_power"] 318 | is_waiting = eqt["is_waiting"] 319 | 320 | # Current power is the last requested_power 321 | current_power = eqt.get("requested_power") 322 | power_max = eqt.get("power_max") 323 | power_step = eqt.get("power_step") 324 | if can_change_power: 325 | power_min = eqt.get("power_min") 326 | else: 327 | # If power is not manageable, min = max 328 | power_min = power_max 329 | 330 | # On veut gérer le is_waiting qui interdit d'allumer ou éteindre un eqt usable. 331 | # On veut pouvoir changer la puissance si l'eqt est déjà allumé malgré qu'il soit waiting. 332 | # Usable veut dire qu'on peut l'allumer/éteindre OU qu'on peut changer la puissance 333 | 334 | # if not can_change_power and is_waiting: 335 | # -> on ne fait rien (mais ne devrait pas arriver car il ne serait pas usable dans ce cas) 336 | # 337 | # if state and can_change_power and is_waiting: 338 | # -> change power mais sans l'éteindre (requested_power >= power_min) 339 | # 340 | # if state and can_change_power and not is_waiting: 341 | # -> change power avec extinction possible 342 | # 343 | # if not state and not is_waiting 344 | # -> allumage 345 | # 346 | # if state and not is_waiting 347 | # -> extinction 348 | # 349 | if (not can_change_power and is_waiting) or ( 350 | not state and can_change_power and is_waiting 351 | ): 352 | _LOGGER.debug("not can_change_power and is_waiting -> do nothing") 353 | return voisin 354 | 355 | if state and can_change_power and is_waiting: 356 | # calculated a new power but do not switch off (because waiting) 357 | requested_power = self.calculer_new_power( 358 | current_power, power_step, power_min, power_max, can_switch_off=False 359 | ) 360 | assert ( 361 | requested_power > 0 362 | ), "Requested_power should be > 0 because is_waiting is True" 363 | 364 | elif state and can_change_power and not is_waiting: 365 | # change power and accept switching off 366 | requested_power = self.calculer_new_power( 367 | current_power, power_step, power_min, power_max, can_switch_off=True 368 | ) 369 | if requested_power < power_min: 370 | # deactivate the equipment 371 | eqt["state"] = False 372 | requested_power = 0 373 | 374 | elif not state and not is_waiting: 375 | # Allumage 376 | eqt["state"] = not state 377 | requested_power = power_min 378 | 379 | elif state and not is_waiting: 380 | # Extinction 381 | eqt["state"] = not state 382 | requested_power = 0 383 | 384 | elif "requested_power" not in locals(): 385 | _LOGGER.error("We should not be there. eqt=%s", eqt) 386 | assert False, "Requested power n'a pas été calculé. Ce n'est pas normal" 387 | 388 | eqt["requested_power"] = requested_power 389 | 390 | if DEBUG: 391 | _LOGGER.debug( 392 | " -- On permute %s puissance max de %.2f. Il passe à %s", 393 | eqt["name"], 394 | eqt["requested_power"], 395 | eqt["state"], 396 | ) 397 | return voisin 398 | --------------------------------------------------------------------------------