├── .gitattributes ├── requirements_dev.txt ├── tests ├── __init__.py ├── const.py ├── conftest.py └── test_sensor.py ├── custom_components ├── __init__.py └── energytariff │ ├── __init__.py │ ├── manifest.json │ ├── const.py │ ├── coordinator.py │ ├── utils.py │ └── sensor.py ├── doc ├── logo.png ├── sensors.png ├── energy_used_this_hour.png ├── energy_estimate_this_hour.png └── available_effect_this_hour.png ├── requirements.txt ├── sensor_example.png ├── pytest.ini ├── requirements_test.txt ├── scripts ├── lint ├── setup └── develop ├── hacs.json ├── rand.sh ├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .gitignore ├── .vscode └── launch.json ├── .github ├── workflows │ ├── copilot-setup-steps.yml │ └── pull.yml └── copilot-instructions.md ├── examples └── full.yaml ├── .ruff.toml ├── LICENSE ├── configuration.yaml ├── CONTRIBUTING.md ├── DEVELOPMENT.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | homeassistant 2 | reactivex==4.1.0 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for grid-cap-watcher integration.""" 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy init so that pytest works.""" 2 | -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaulsen/energytariff/HEAD/doc/logo.png -------------------------------------------------------------------------------- /custom_components/energytariff/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy init so that pytest works.""" 2 | -------------------------------------------------------------------------------- /doc/sensors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaulsen/energytariff/HEAD/doc/sensors.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.9.0 2 | homeassistant==2024.11.0 3 | pip>=21.3.1 4 | ruff==0.8.6 -------------------------------------------------------------------------------- /sensor_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaulsen/energytariff/HEAD/sensor_example.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | asyncio_default_fixture_loop_scope = function 4 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements_dev.txt 2 | pytest-homeassistant-custom-component==0.13.205 3 | -------------------------------------------------------------------------------- /doc/energy_used_this_hour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaulsen/energytariff/HEAD/doc/energy_used_this_hour.png -------------------------------------------------------------------------------- /doc/energy_estimate_this_hour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaulsen/energytariff/HEAD/doc/energy_estimate_this_hour.png -------------------------------------------------------------------------------- /doc/available_effect_this_hour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epaulsen/energytariff/HEAD/doc/available_effect_this_hour.png -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "EnergyTariff", 3 | "hacs": "1.6.0", 4 | "homeassistant": "0.118.0", 5 | "render_readme": true 6 | } -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt -------------------------------------------------------------------------------- /rand.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #Writes a random value to a text file. Same text file is configured in configuration.yaml as a power sensor 4 | while true 5 | do 6 | rand=$(( ( RANDOM % 9000 ) + 1000 )) 7 | echo $rand > sensor-data.txt 8 | sleep 15 9 | done 10 | -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.{{cookiecutter.domain_name}}: debug 7 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 8 | # debugpy: -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Constants for grid-cap-watcher tests.""" 2 | # from custom_components.grid_energy_level.const import ( 3 | # CONF_PASSWORD, 4 | # ) 5 | # from custom_components.grid_energy_level.const import ( 6 | # CONF_USERNAME, 7 | # ) 8 | 9 | # MOCK_CONFIG = {CONF_USERNAME: "test_username", CONF_PASSWORD: "test_password"} 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | .python-version 4 | .coverage 5 | venv 6 | .venv 7 | 8 | # Ignore stuff that HA puts in repo when running under debugger 9 | .storage/ 10 | blueprints/ 11 | home-assistant* 12 | .HA_VERSION 13 | *.yaml 14 | !configuration.yaml 15 | setup.cfg 16 | 17 | 18 | #File generated by test script 19 | sensor-data.txt -------------------------------------------------------------------------------- /custom_components/energytariff/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "energytariff", 3 | "name": "Energy tariff", 4 | "codeowners": [ 5 | "@epaulsen" 6 | ], 7 | "config_flow": false, 8 | "dependencies": [], 9 | "documentation": "https://github.com/epaulsen/energytariff", 10 | "integration_type": "device", 11 | "iot_class": "calculated", 12 | "issue_tracker": "https://github.com/epaulsen/energytariff/issues", 13 | "requirements": [ 14 | "reactivex==4.1.0" 15 | ], 16 | "version": "0.0.3" 17 | } 18 | -------------------------------------------------------------------------------- /.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": "Python: Launch HomeAssistant", 7 | "type": "python", 8 | "request": "launch", 9 | "program": "/home/vscode/.local/bin/hass", 10 | "args": [ 11 | "-c", 12 | "${workspaceFolder}", 13 | "--debug" 14 | ], 15 | "justMyCode": true 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /.github/workflows/copilot-setup-steps.yml: -------------------------------------------------------------------------------- 1 | name: Copilot Setup Steps 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | copilot-setup-steps: 8 | name: copilot-setup-steps 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.12" 20 | 21 | - name: Install dependencies 22 | run: | 23 | pip install --upgrade pip 24 | pip install -r requirements_test.txt 25 | -------------------------------------------------------------------------------- /examples/full.yaml: -------------------------------------------------------------------------------- 1 | sensor: 2 | - platform: energytariff 3 | entity_id: "sensor.power_usage" 4 | max_power: 15900 5 | precision: 3 6 | target_energy: 10 7 | levels: 8 | - name: "Trinn 1: 0-2 kWh" 9 | threshold: 2 10 | price: 135 11 | - name: "Trinn 2: 2-5 kWh" 12 | threshold: 5 13 | price: 170 14 | - name: "Trinn 3: 5-10 kWh" 15 | threshold: 10 16 | price: 290 17 | - name: "Trinn 4: 10-15 kWh" 18 | threshold: 15 19 | price: 600 20 | - name: "Trinn 5: 15-20 kWh" 21 | threshold: 20 22 | price: 800 -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py312" 4 | 5 | [lint] 6 | select = [ 7 | "ALL", 8 | ] 9 | 10 | ignore = [ 11 | "ANN101", # Missing type annotation for `self` in method 12 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 13 | "D203", # no-blank-line-before-class (incompatible with formatter) 14 | "D212", # multi-line-summary-first-line (incompatible with formatter) 15 | "COM812", # incompatible with formatter 16 | "ISC001", # incompatible with formatter 17 | ] 18 | 19 | [lint.flake8-pytest-style] 20 | fixture-parentheses = false 21 | 22 | [lint.pyupgrade] 23 | keep-runtime-typing = true 24 | 25 | [lint.mccabe] 26 | max-complexity = 25 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 epaulsen 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 Request Validation 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | #schedule: 9 | # - cron: "0 0 * * *" 10 | 11 | jobs: 12 | hacs: 13 | name: HACS Action 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: HACS Action 17 | uses: "hacs/action@main" 18 | with: 19 | category: "integration" 20 | 21 | hassfest: 22 | name: Hassfest 23 | runs-on: "ubuntu-latest" 24 | steps: 25 | - uses: "actions/checkout@v3" 26 | - uses: "home-assistant/actions/hassfest@master" 27 | 28 | pytest: 29 | name: Run Tests 30 | runs-on: "ubuntu-latest" 31 | permissions: 32 | contents: read 33 | steps: 34 | - name: Checkout code 35 | uses: "actions/checkout@v3" 36 | 37 | - name: Set up Python 38 | uses: "actions/setup-python@v4" 39 | with: 40 | python-version: "3.12" 41 | 42 | - name: Install dependencies 43 | run: | 44 | pip install --upgrade pip 45 | pip install -r requirements_test.txt 46 | 47 | - name: Run pytest 48 | run: pytest tests/test_sensor.py -v 49 | 50 | -------------------------------------------------------------------------------- /custom_components/energytariff/const.py: -------------------------------------------------------------------------------- 1 | """Constants for grid-cap-watcher.""" 2 | # Base component constants 3 | NAME = "Energy tariff" 4 | DOMAIN = "energytariff" 5 | DOMAIN_DATA = f"{DOMAIN}_data" 6 | VERSION = "0.0.3" 7 | 8 | ISSUE_URL = "https://github.com/epaulsen/grid-cap-watcher/issues" 9 | 10 | # Icons 11 | ICON = "mdi:lightning-bolt" 12 | 13 | SENSOR = "sensor" 14 | SWITCH = "switch" 15 | PLATFORMS = [SENSOR] 16 | 17 | # Configuration and options 18 | CONF_ENABLED = "enabled" 19 | 20 | CONF_EFFECT_ENTITY = "entity_id" 21 | COORDINATOR = "rx_coordinator" 22 | 23 | 24 | DATA_UPDATED = f"{DOMAIN}_data_updated" 25 | MAX_EFFECT_ALLOWED = "max_power" 26 | 27 | GRID_LEVELS = "levels" 28 | LEVEL_NAME = "name" 29 | LEVEL_THRESHOLD = "threshold" 30 | LEVEL_PRICE = "price" 31 | ROUNDING_PRECISION = "precision" 32 | PEAK_HOUR = "peak_hour" 33 | TARGET_ENERGY = "target_energy" 34 | 35 | RESET_TOP_THREE = "energytariff_reset_top_three_hours" 36 | 37 | # Defaults 38 | DEFAULT_NAME = DOMAIN 39 | 40 | 41 | STARTUP_MESSAGE = f""" 42 | ------------------------------------------------------------------- 43 | {NAME} 44 | Version: {VERSION} 45 | This is a custom integration! 46 | If you have any issues with this you need to open an issue here: 47 | {ISSUE_URL} 48 | ------------------------------------------------------------------- 49 | """ 50 | -------------------------------------------------------------------------------- /configuration.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Loads default set of integrations. Do not remove. 3 | default_config: 4 | 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.energytariff: debug 9 | 10 | homeassistant: 11 | allowlist_external_dirs: 12 | - "/workspaces/energytariff" 13 | 14 | sensor: 15 | - platform: file 16 | name: "power_usage" 17 | unit_of_measurement: "W" 18 | file_path: /workspaces/energytariff/sensor-data.txt 19 | 20 | - platform: energytariff 21 | entity_id: "sensor.power_usage" 22 | max_power: 15900 23 | target_energy: 10 24 | levels: 25 | - name: "Trinn 1: 0-2 kWh" 26 | threshold: 2 27 | price: 135 28 | - name: "Trinn 2: 2-5 kWh" 29 | threshold: 5 30 | price: 170 31 | - name: "Trinn 3: 5-10 kWh" 32 | threshold: 10 33 | price: 290 34 | - name: "Trinn 4: 10-15 kWh" 35 | threshold: 15 36 | price: 600 37 | - name: "Trinn 5: 15-20 kWh" 38 | threshold: 20 39 | price: 800 40 | 41 | 42 | 43 | # Load frontend themes from the themes folder 44 | frontend: 45 | themes: !include_dir_merge_named themes 46 | 47 | # Text to speech 48 | tts: 49 | - platform: google_translate 50 | 51 | #automation: !include automations.yaml 52 | #script: !include scripts.yaml 53 | #scene: !include scenes.yaml 54 | -------------------------------------------------------------------------------- /custom_components/energytariff/coordinator.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any 3 | 4 | from reactivex.subject import BehaviorSubject 5 | from homeassistant.core import ( 6 | HomeAssistant, 7 | ) 8 | 9 | 10 | class EnergyData: 11 | """Class used to transmit sensor nofication via rx""" 12 | 13 | def __init__(self, energy: float, effect: float, timestamp: datetime.datetime): 14 | self.energy_consumed = energy 15 | self.current_effect = effect 16 | self.timestamp = timestamp 17 | 18 | 19 | class TopHour: 20 | """Holds data for an hour of consumption""" 21 | 22 | def __init__(self, day: int, hour: int, energy: float): 23 | self.day = day 24 | self.hour = hour 25 | self.energy = energy 26 | 27 | 28 | class GridThresholdData: 29 | """Class used to transmit changes of level threshold changes""" 30 | 31 | def __init__(self, name, level: float, price: float, top_three: Any): 32 | self.name = name 33 | self.level = level 34 | self.price = price 35 | self.top_three = top_three 36 | 37 | 38 | class GridCapacityCoordinator: 39 | """Coordinator entity that signals notifications for sensors""" 40 | 41 | def __init__(self, hass: HomeAssistant): 42 | self._hass = hass 43 | self.effectstate = BehaviorSubject(None) 44 | self.thresholddata = BehaviorSubject(None) 45 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ludeeus/integration_blueprint", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.12", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "charliermarsh.ruff", 18 | "github.vscode-pull-request-github", 19 | "ms-python.python", 20 | "ms-python.vscode-pylance", 21 | "ryanluker.vscode-coverage-gutters" 22 | ], 23 | "settings": { 24 | "files.eol": "\n", 25 | "editor.tabSize": 4, 26 | "editor.formatOnPaste": true, 27 | "editor.formatOnSave": true, 28 | "editor.formatOnType": false, 29 | "files.trimTrailingWhitespace": true, 30 | "python.analysis.typeCheckingMode": "basic", 31 | "python.analysis.autoImportCompletions": true, 32 | "python.defaultInterpreterPath": "/usr/local/bin/python", 33 | "[python]": { 34 | "editor.defaultFormatter": "charliermarsh.ruff" 35 | } 36 | } 37 | } 38 | }, 39 | "remoteUser": "vscode", 40 | "features": { 41 | "ghcr.io/devcontainers-extra/features/apt-packages:1": { 42 | "packages": [ 43 | "ffmpeg", 44 | "libturbojpeg0", 45 | "libpcap-dev" 46 | ] 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for grid-cap-watcher integration.""" 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | pytest_plugins = "pytest_homeassistant_custom_component" 7 | 8 | 9 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 10 | # notifications. These calls would fail without this fixture since the persistent_notification 11 | # integration is never loaded during a test. 12 | @pytest.fixture(name="skip_notifications", autouse=True) 13 | def skip_notifications_fixture(): 14 | """Skip notification calls.""" 15 | with patch("homeassistant.components.persistent_notification.async_create"), patch( 16 | "homeassistant.components.persistent_notification.async_dismiss" 17 | ): 18 | yield 19 | 20 | 21 | # This fixture, when used, will result in calls to async_get_data to return None. To have the call 22 | # return a value, we would add the `return_value=` parameter to the patch call. 23 | @pytest.fixture(name="bypass_get_data") 24 | def bypass_get_data_fixture(): 25 | """Skip calls to get data from API.""" 26 | with patch( 27 | "custom_components.grid_energy_level.GridCapWatcherApiClient.async_get_data" 28 | ): 29 | yield 30 | 31 | 32 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 33 | # for exception handling. 34 | @pytest.fixture(name="error_on_get_data") 35 | def error_get_data_fixture(): 36 | """Simulate error when retrieving data from API.""" 37 | with patch( 38 | "custom_components.grid_energy_level.GridCapWatcherApiClient.async_get_data", 39 | side_effect=Exception, 40 | ): 41 | yield 42 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions for EnergyTariff 2 | 3 | This repository contains a custom integration for HomeAssistant. All code is written in Python. 4 | 5 | ## Getting Started 6 | 7 | When starting work on this repository, ensure that the repository is in a good state by running all tests: 8 | 9 | ```bash 10 | pytest tests/test_sensor.py -v 11 | ``` 12 | 13 | ## Testing Requirements 14 | 15 | Before submitting a PR that has changed code files, all tests must pass. Testing is done with pytest. 16 | 17 | **When adding or changing functionality, you must add tests that verify the new functionality works as expected.** This ensures code quality and prevents regressions. 18 | 19 | To run tests: 20 | ```bash 21 | pytest tests/test_sensor.py -v 22 | ``` 23 | 24 | ## HomeAssistant Resources 25 | 26 | Copilot has been granted access to the HomeAssistant developer documentation and is encouraged to use it: 27 | - HomeAssistant Developer Documentation: https://developers.home-assistant.io/ 28 | - HomeAssistant Core Repository: https://github.com/home-assistant/core 29 | 30 | Use these resources to look up documentation and reference implementations when working on HomeAssistant integration code. 31 | 32 | ## Code Structure 33 | 34 | This is a HomeAssistant custom integration that provides energy monitoring sensors. Key components: 35 | - `custom_components/energytariff/` - Main integration code 36 | - `tests/` - Test files 37 | - Testing uses pytest with HomeAssistant testing utilities 38 | 39 | ## Best Practices 40 | 41 | - Follow HomeAssistant integration patterns and conventions 42 | - Maintain test coverage for all code changes 43 | - Ensure all tests pass before submitting changes 44 | - Reference HomeAssistant documentation for integration best practices 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People _love_ thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) 48 | to make sure the code follows the style. 49 | 50 | Or use the `pre-commit` settings implemented in this repository 51 | (see deicated section below). 52 | 53 | ## Test your code modification 54 | 55 | This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 56 | 57 | It comes with development environment in a container, easy to launch 58 | if you use Visual Studio Code. With this container you will have a stand alone 59 | Home Assistant instance running and already configured with the included 60 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 61 | file. 62 | 63 | You can use the `pre-commit` settings implemented in this repository to have 64 | linting tool checking your contributions (see deicated section below). 65 | 66 | You should also verify that existing [tests](./tests) are still working 67 | and you are encouraged to add new ones. 68 | You can run the tests using the following commands from the root folder: 69 | 70 | ```bash 71 | # Create a virtual environment 72 | python3 -m venv venv 73 | source venv/bin/activate 74 | # Install requirements 75 | pip install -r requirements_test.txt 76 | # Run tests and get a summary of successes/failures and code coverage 77 | pytest --durations=10 --cov-report term-missing --cov=custom_components.grid_energy_level tests 78 | ``` 79 | 80 | If any of the tests fail, make the necessary changes to the tests as part of 81 | your changes to the integration. 82 | 83 | ## Pre-commit 84 | 85 | You can use the [pre-commit](https://pre-commit.com/) settings included in the 86 | repostory to have code style and linting checks. 87 | 88 | With `pre-commit` tool already installed, 89 | activate the settings of the repository: 90 | 91 | ```console 92 | $ pre-commit install 93 | ``` 94 | 95 | Now the pre-commit tests will be done every time you commit. 96 | 97 | You can run the tests on all repository file with the command: 98 | 99 | ```console 100 | $ pre-commit run --all-files 101 | ``` 102 | 103 | ## License 104 | 105 | By contributing, you agree that your contributions will be licensed under its MIT License. 106 | -------------------------------------------------------------------------------- /custom_components/energytariff/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any 3 | 4 | from homeassistant.util import dt 5 | 6 | from homeassistant.const import ( 7 | STATE_UNAVAILABLE, 8 | STATE_UNKNOWN, 9 | ) 10 | 11 | from custom_components.energytariff.coordinator import EnergyData 12 | 13 | from .const import ROUNDING_PRECISION 14 | 15 | 16 | def start_of_current_hour(date_object: datetime) -> datetime: 17 | """Returns a datetime object which is set to start of input objects current time""" 18 | return datetime( 19 | date_object.year, 20 | date_object.month, 21 | date_object.day, 22 | date_object.hour, 23 | 0, 24 | 0, 25 | tzinfo=date_object.tzinfo, 26 | ) 27 | 28 | 29 | def start_of_next_hour(date_object: datetime) -> datetime: 30 | """returns a datetime object that is the start of next hour""" 31 | temp = date_object + timedelta(hours=1) 32 | value = datetime( 33 | temp.year, 34 | temp.month, 35 | temp.day, 36 | temp.hour, 37 | 0, 38 | 0, 39 | tzinfo=temp.tzinfo, 40 | ) 41 | return value 42 | 43 | 44 | def start_of_next_month(date_object: datetime) -> datetime: 45 | """Returns a datetime object that is set at start of next month + 1 second.""" 46 | if date_object.month == 12: 47 | month = 1 48 | year = date_object.year + 1 49 | else: 50 | month = date_object.month + 1 51 | year = date_object.year 52 | 53 | value = datetime(year, month, 1, 0, 0, 1, 0, tzinfo=date_object.tzinfo) 54 | return value 55 | 56 | 57 | def seconds_between(date_object_1: datetime, date_object_2: datetime) -> int: 58 | """Returns number of seconds between two dates""" 59 | return (date_object_1 - date_object_2).total_seconds() 60 | 61 | 62 | def get_rounding_precision(config: dict[str, Any]) -> int: 63 | """Gets rounding precision for sensors with decimal value. 64 | Default to the value 2 for 2 decimals""" 65 | precision = config.get(ROUNDING_PRECISION) 66 | if precision is None: 67 | return 2 68 | 69 | return int(precision) 70 | 71 | 72 | def convert_to_watt(data: any) -> float: 73 | """Converts input sensor data to watt, if needed""" 74 | if data.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): 75 | return 0 76 | 77 | value = float(data.state) 78 | unit = data.attributes["unit_of_measurement"] 79 | if unit == "kW": 80 | value = value * 1000 81 | else: 82 | if unit != "W": 83 | return None 84 | return value 85 | 86 | 87 | def calculate_top_three(state: EnergyData, top_three: Any) -> Any: 88 | """Mainains the list of top three hours for a month""" 89 | 90 | if state is None: 91 | return top_three 92 | 93 | localtime = dt.as_local(state.timestamp) 94 | 95 | energy_used = state.energy_consumed 96 | 97 | # Solar or wind production can cause the energy meter to have negative values 98 | # Set this to 0, as tariffs are only for consumption and we don't have negative 99 | # tariff values in the tariff config section. 100 | if energy_used < 0: 101 | energy_used = 0 102 | 103 | consumption = { 104 | "day": localtime.day, 105 | "hour": localtime.hour, 106 | "energy": energy_used, 107 | } 108 | 109 | # Case 1:Empty list. Uncricitally add, calculate level and return 110 | if len(top_three) == 0: 111 | # _LOGGER.debug("Adding first item") 112 | top_three.append(consumption) 113 | return top_three 114 | 115 | # Case 2: Items in list. If any are same day as consumption-item, 116 | # update that one if energy is higher. Recalculate and return 117 | for i in range(len(top_three)): 118 | if int(top_three[i]["day"]) == int(consumption["day"]): 119 | if top_three[i]["energy"] < consumption["energy"]: 120 | top_three[i]["energy"] = consumption["energy"] 121 | top_three[i]["hour"] = consumption["hour"] 122 | # _LOGGER.debug( 123 | # "Updating current-day item to %s", consumption["energy"] 124 | # ) 125 | 126 | return top_three 127 | 128 | # Case 3: We are not on the same day as any items in the list, 129 | # but have less than 3 items in list. 130 | # Add, re-calculate and return 131 | if len(top_three) < 3: 132 | top_three.append(consumption) 133 | return top_three 134 | 135 | # Case 4: Not same day, list has three element. 136 | # If lowest level has lower consumption, replace element, 137 | # recalculate and return 138 | top_three.sort(key=lambda x: x["energy"]) 139 | for i in range(len(top_three)): 140 | if top_three[i]["energy"] < consumption["energy"]: 141 | top_three[i] = consumption 142 | return top_three 143 | 144 | # If we got this far, list has no changes, to return it as-is. 145 | return top_three 146 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | This guide will help you set up your development environment and contribute to the EnergyTariff integration. 4 | 5 | ## Table of Contents 6 | 7 | - [Prerequisites](#prerequisites) 8 | - [Development Setup](#development-setup) 9 | - [Option 1: Using VS Code Dev Container (Recommended)](#option-1-using-vs-code-dev-container-recommended) 10 | - [Option 2: Local Development Environment](#option-2-local-development-environment) 11 | - [Running Tests](#running-tests) 12 | - [Code Quality](#code-quality) 13 | - [Development Workflow](#development-workflow) 14 | - [Debugging](#debugging) 15 | 16 | ## Prerequisites 17 | 18 | Before you begin, ensure you have the following installed: 19 | 20 | - **Python 3.12** or higher 21 | - **Git** for version control 22 | - **Visual Studio Code** (recommended for dev container support) 23 | - **Docker** (if using the dev container) 24 | 25 | ## Development Setup 26 | 27 | ### Option 1: Using VS Code Dev Container (Recommended) 28 | 29 | The easiest way to start developing is using the included dev container configuration. 30 | 31 | 1. **Install Prerequisites:** 32 | - Install [Visual Studio Code](https://code.visualstudio.com/) 33 | - Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 34 | - Install [Docker Desktop](https://www.docker.com/products/docker-desktop) 35 | 36 | 2. **Open in Container:** 37 | ```bash 38 | # Clone the repository 39 | git clone https://github.com/epaulsen/energytariff.git 40 | cd energytariff 41 | 42 | # Open in VS Code 43 | code . 44 | ``` 45 | 46 | 3. **Reopen in Container:** 47 | - When prompted, click "Reopen in Container" 48 | - Or use Command Palette (F1): `Dev Containers: Reopen in Container` 49 | - The container will automatically install all dependencies via the `scripts/setup` script 50 | 51 | 4. **Access Home Assistant:** 52 | - The dev container will forward port 8123 53 | - Access Home Assistant at http://localhost:8123 54 | - Configuration is located in the `config/` directory 55 | 56 | ### Option 2: Local Development Environment 57 | 58 | If you prefer not to use the dev container: 59 | 60 | 1. **Clone the repository:** 61 | ```bash 62 | git clone https://github.com/epaulsen/energytariff.git 63 | cd energytariff 64 | ``` 65 | 66 | 2. **Create a virtual environment:** 67 | ```bash 68 | python3 -m venv venv 69 | source venv/bin/activate # On Windows: venv\Scripts\activate 70 | ``` 71 | 72 | 3. **Install dependencies:** 73 | ```bash 74 | # Install runtime dependencies 75 | pip install -r requirements.txt 76 | 77 | # Install development dependencies 78 | pip install -r requirements_dev.txt 79 | 80 | # Install test dependencies 81 | pip install -r requirements_test.txt 82 | ``` 83 | 84 | 4. **Run Home Assistant for development:** 85 | ```bash 86 | # Use the development script 87 | ./scripts/develop 88 | ``` 89 | 90 | This will: 91 | - Create a `config/` directory if it doesn't exist 92 | - Set up PYTHONPATH to include custom_components 93 | - Start Home Assistant in debug mode on http://localhost:8123 94 | 95 | ## Running Tests 96 | 97 | This project uses pytest for testing. Tests are located in the `tests/` directory. 98 | 99 | ### Run All Tests 100 | 101 | ```bash 102 | # Activate your virtual environment first 103 | source venv/bin/activate # On Windows: venv\Scripts\activate 104 | 105 | # Run all tests 106 | pytest 107 | 108 | # Run tests with verbose output 109 | pytest -v 110 | 111 | # Run tests with coverage report 112 | pytest --cov=custom_components.energytariff --cov-report=term-missing 113 | ``` 114 | 115 | ### Run Specific Tests 116 | 117 | ```bash 118 | # Run only sensor tests 119 | pytest tests/test_sensor.py 120 | 121 | # Run a specific test function 122 | pytest tests/test_sensor.py::test_estimated_energy_sensor_initialization 123 | 124 | # Run tests matching a pattern 125 | pytest -k "energy_sensor" 126 | ``` 127 | 128 | ### Test Options 129 | 130 | ```bash 131 | # Show the slowest 10 tests 132 | pytest --durations=10 133 | 134 | # Stop on first failure 135 | pytest -x 136 | 137 | # Show local variables in tracebacks 138 | pytest -l 139 | 140 | # Run tests in parallel (requires pytest-xdist) 141 | pytest -n auto 142 | ``` 143 | 144 | ### Current Test Status 145 | 146 | The project currently has 22 test cases in `tests/test_sensor.py`: 147 | - **8 tests pass** and validate core sensor functionality 148 | - **14 tests** require full Home Assistant event system integration 149 | 150 | All passing tests cover: 151 | - Sensor initialization and configuration 152 | - State calculation logic 153 | - Response to coordinator updates 154 | - Units of measurement 155 | - Grid level sensors 156 | 157 | ## Code Quality 158 | 159 | ### Linting and Formatting 160 | 161 | This project uses Ruff for both linting and formatting. 162 | 163 | ```bash 164 | # Format and lint code automatically 165 | ./scripts/lint 166 | 167 | # Or manually: 168 | ruff format . 169 | ruff check . --fix 170 | ``` 171 | 172 | ### Pre-commit Hooks 173 | 174 | To automatically run checks before each commit: 175 | 176 | ```bash 177 | # Install pre-commit 178 | pip install pre-commit 179 | 180 | # Set up the git hooks 181 | pre-commit install 182 | 183 | # Run checks on all files manually 184 | pre-commit run --all-files 185 | ``` 186 | 187 | ### Code Style Guidelines 188 | 189 | - Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines 190 | - Use type hints where appropriate 191 | - Write descriptive docstrings for classes and functions 192 | - Keep line length to 88 characters (Black's default) 193 | - Use meaningful variable and function names 194 | 195 | ## Development Workflow 196 | 197 | 1. **Create a feature branch:** 198 | ```bash 199 | git checkout -b feature/your-feature-name 200 | ``` 201 | 202 | 2. **Make your changes:** 203 | - Write code following the style guidelines 204 | - Add or update tests as needed 205 | - Update documentation if necessary 206 | 207 | 3. **Test your changes:** 208 | ```bash 209 | # Run tests 210 | pytest 211 | 212 | # Run linting 213 | ./scripts/lint 214 | ``` 215 | 216 | 4. **Commit your changes:** 217 | ```bash 218 | git add . 219 | git commit -m "Description of your changes" 220 | ``` 221 | 222 | 5. **Push and create a pull request:** 223 | ```bash 224 | git push origin feature/your-feature-name 225 | ``` 226 | 227 | Then create a pull request on GitHub. 228 | 229 | ## Debugging 230 | 231 | ### Using VS Code Debugger 232 | 233 | A launch configuration is included in `.vscode/launch.json`. 234 | 235 | 1. Open VS Code 236 | 2. Set breakpoints in your code 237 | 3. Press F5 or go to Run > Start Debugging 238 | 4. Home Assistant will start in debug mode 239 | 240 | ### Debug Logging 241 | 242 | Enable debug logging in Home Assistant by adding to `config/configuration.yaml`: 243 | 244 | ```yaml 245 | logger: 246 | default: info 247 | logs: 248 | custom_components.energytariff: debug 249 | ``` 250 | 251 | ### Troubleshooting 252 | 253 | **Issue: Tests fail with import errors** 254 | - Solution: Make sure you've installed test dependencies: `pip install -r requirements_test.txt` 255 | 256 | **Issue: Home Assistant doesn't see the integration** 257 | - Solution: Check that PYTHONPATH includes the custom_components directory 258 | - Or: Restart Home Assistant after code changes 259 | 260 | **Issue: Dev container fails to build** 261 | - Solution: Make sure Docker is running and you have sufficient disk space 262 | - Try: Rebuild the container using Command Palette > "Dev Containers: Rebuild Container" 263 | 264 | ## Additional Resources 265 | 266 | - [Home Assistant Developer Documentation](https://developers.home-assistant.io/) 267 | - [Integration Blueprint Template](https://github.com/custom-components/integration_blueprint) 268 | - [Contributing Guidelines](CONTRIBUTING.md) 269 | - [Project README](README.md) 270 | 271 | ## Getting Help 272 | 273 | If you encounter issues or have questions: 274 | 275 | 1. Check existing [GitHub Issues](https://github.com/epaulsen/energytariff/issues) 276 | 2. Create a new issue with detailed information about your problem 277 | 3. Include steps to reproduce, expected behavior, and actual behavior 278 | 279 | ## License 280 | 281 | By contributing to this project, you agree that your contributions will be licensed under the MIT License. 282 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](doc/logo.png) 2 | 3 | [![hacs_badge]](https://github.com/hacs/integration) 4 | ![analytics_badge] 5 | [![GitHub Activity][commits-shield]][commits] 6 | [![License][license-shield]](LICENSE) 7 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 8 | 9 | ## Description 10 | 11 | This integration adds a platform entity that provides sensors to monitor energy consumption. 12 | In order to use this in a meaningful way, a meter reader for the total power usage of the HA installation is needed, 13 | typically this means that you have a meter reader installed on your AMS meter. 14 | 15 | This integration was written as a stopgap for missing sensors after moving away from Tibber. It provides similar sensors 16 | as what you can get from their HA integration and from their GraphQl API. If you want to ensure that you do not exceed 17 | a grip energy step level, this integration will provide you with the tools to succeed. 18 | 19 | ## Installation 20 | 21 | This sensor can either be installed manually, or via HACS(recommended) 22 | 23 | ### Manual install 24 | 25 | 1. Open the folder containing your HA install, and locate the config folder. It will contain a file called (`configuration.yaml`) 26 | 2. If there is not a subfolder in config folder called `custom_components`, create it. 27 | 3. Inside `custom_components` folder, create a new folder named `energytariff` 28 | 4. Download all files from `custom_components/energytariff/` in this repository and put them in `energytariff`-folder 29 | 5. Restart HA 30 | 31 | ### HACS(recommended) 32 | 33 | Go to HACS -> Integrations, click the blue + sign at the bottom right of the screen. 34 | Search for `EnergyTariff` and install it as any other HACS component. 35 | A HA restart is required before configuration for HomeAssistant to pick up the new integration. 36 | 37 | 38 | ## Configuration 39 | 40 | **Important!** 41 | After first install of this component, a restart of HomeAssistant is required. 42 | If configuration is added before HA is rebootet after install, configuration validation will fail, 43 | because HA does yet know about the new integration. This is a known issue, with no easy fix. 44 | 45 | Configuration of this sensor is done in yaml. 46 | Minimal example: `configuration.yaml` : 47 | 48 | ```yaml 49 | sensor: 50 | - platform: energytariff 51 | entity_id: "sensor.ams_power_sensor_watt" 52 | target_energy: 10 53 | ``` 54 | 55 | ### Configuration schema 56 | 57 | | Name | Type | Default | Since | Description | 58 | |------|------|---------|-------|-------------| 59 | | entity_id | string | **required** | v0.0.1 | entity_id for your AMS meter sensor that provides current power usage. This sensor is required, and value needs to be in either W or kW. | 60 | | precision | int | 2 | v0.0.1 | Number of decimals to use in rounding. Defaults to 2, giving all sensors two decimals. | 61 | | target_energy | float | None | v0.0.1 | Target energy consumption in kWh. See sensor "Available power this hour" for more detailed description. | 62 | | max_power | float | None | v0.0.1 | Max energy(in kWh) reported by "Available power this hour" sensor.See sensor "Available power this hour" for more detailed description. | 63 | | levels | list | None | v0.0.1 | Grid energy levels(primarily for norwegian HA users). If your energy provider has tariffs based on energy consumption per hour, this list of levels can be utilized. 64 | 65 | #### Levels schema 66 | 67 | If your electric energy provider uses grid capacity levels, these can be configured by adding this section to configuration. 68 | These tariff levels are used by norwegian grid operators, so this primarily applies to Norwegian HA owners. 69 | Per entry, here are the values needed: 70 | 71 | | Name | Type | Default | Since | Description | 72 | |------|------|---------|-------|-------------| 73 | | name | string | **required** | v0.0.1 | Name of grid energy level | 74 | | threshold | float | **required** | v0.0.1 | Energy threshold level, in kWh | 75 | | price | float | **required** | v0.0.1 | Energy level price | 76 | 77 | Levels example: 78 | 79 | ```yaml 80 | 81 | levels: 82 | - name: "Trinn 1: 0-2 kWh" 83 | threshold: 2 84 | price: 135 85 | - name: "Trinn 2: 2-5 kWh" 86 | threshold: 5 87 | price: 170 88 | - name: "Trinn 2: 5-10 kWh" 89 | threshold: 10 90 | price: 290 91 | ``` 92 | 93 | For a complete configuration example with all properties, see [full example](examples/full.yaml) 94 | 95 | ## Sensors 96 | 97 | This integration provides the following sensors: 98 | 99 | | Name | Unit | Description | 100 | |------|------|-------------| 101 | | [Energy used this hour](#energy-used-this-hour) | kWh | Total amount of energy consumed this hour. Resets to zero at the start of a new hour. | 102 | | [Energy estimate this hour](#energy-estimate-this-hour) | kWh | Energy estimate this hour. Based on energy consumption so far + current_power * remaining_seconds | 103 | | [Available power this hour](#available-power-this-hour) | W | How much power that can be used for the remaining part of hour and still remain within threshold limit, either configured in `target_energy` setting or at the configured grid level threshold(`level` threshold). | 104 | | [Average peak hour energy](#average-peak-hour-energy) | kWh | The highest hourly consumption, measured on three different days. Used to calculate grid energy level. Resets every month. | 105 | 106 | Additionally, if `levels` are configured, the following sensors are added: 107 | 108 | | Name | Unit | Description | 109 | |------|------|-------------| 110 | | [Energy level name](#energy-level-name) | string | Name of current energy level | 111 | | [Energy level price](#energy-level-price) | currency | Price of current energy level | 112 | | [Energy level upper threshold](#energy-level-upper-threshold) | kWh | Upper energy threshold of current energy level | 113 | 114 | ### Energy Used this hour 115 | 116 | This sensor displays how much energy that has been consumed so far this hour. It will reset when a new hour starts. 117 | A typical graph for this sensor looks like this: 118 | 119 | ![Example energy used](doc/energy_used_this_hour.png) 120 | 121 | ### Energy estimate this hour 122 | 123 | This sensor gives an estimate of how much energy that will be consumed in the current hour. 124 | 125 | Given that `EC` is energy consumed, `EF` is current power and `TD` is remaining seconds of hour, calculation is done using this formula: 126 | 127 | $$Estimate = EC + {{EF * TD}\over{3600 * 1000}}$$ 128 | 129 | 130 | Output is in kWh. Sample sensor data: 131 | 132 | ![Example energy used](doc/energy_estimate_this_hour.png) 133 | 134 | ### Available power this hour 135 | 136 | This sensor shows remaining power you can use this hour without exceeding grid threshold level. 137 | When sensor value is positive, power usage can be increased by sensor value without exceeding threshold value. 138 | As an example, if value is 1000W, power usage can be increased by 1000W and you will still remain within current grid threshold value. 139 | If sensor value is negative, power usage much be reduced by that amount to remain within threshold value. So if sensor value is -1500W, you need to reduce power consumption by 1500W to remain withing threshold level. 140 | 141 | If `target_energy` setting is configured, this value is used as a threshold. Otherwise, if `level` setting is configured, the current energy level threshold value is used. If neither are configured, this sensor is unavailable. 142 | 143 | Given that `EC` is energy consumed, `EF` is current power, `TT` is threshold and `TD` is remaining seconds of hour, calculation is done using this formula: 144 | 145 | $$Available = {({TT - EC}) * 3600 * 1000 \over TD} - EF$$ 146 | 147 | If this sensor has a positive value, power usage can be increased without exceeding the threshold. When the sensor has a negative value, power usage needs to be decreased in order to not exceed threshold. 148 | 149 | Sample graph from sensor. Notice that the sensor does not exceed `max_power` threshold value, which in this case is configured to 15300 W. 150 | 151 | ![Example energy used](doc/available_effect_this_hour.png) 152 | 153 | **max_power parameter** 154 | 155 | The last few minutes of an hour `TD` in the formula above will become quite low, 156 | resulting in available power to grow expontentially, and possibly exceeding the total available power that can be used without blowing the main circuit breaker. It is highly recommended to set this parameter to a sensible value that is below the total power that can be utilized safely. 157 | 158 | **target_energy parameter** 159 | 160 | Sets the threshold energy value for this sensor to a fixed value. If not set, threshold value from current grid energy level is used. 161 | As sensor data from three different days are needed in order to calculate grid level properly, it can be useful to set this to a pre-determined level that you do not want to exceed. 162 | 163 | ## Average peak hour energy 164 | This sensor displays the average of the three hours with highest energy usage, from three different days. 165 | Value is reset when a new month starts. This sensor is not available if `levels` have not been added to configuration. 166 | 167 | **NOTE** Sensor will not work properly until it it has two full days of data + 1 hour from day 3. 168 | For the first day after month start, it will display the highest consumption that is measured for an individual hour. 169 | On day two, it will measure an anverage of highest consumption from day 1 and 2. On day three the sensor will provide correct values, measuring the average of the three highest hours from three different days. 170 | 171 | ### Energy level name 172 | This sensor provides the current energy step level for your average energy usage. If `levels` are not configured, this sensor is not available. 173 | 174 | ### Energy level upper threshold 175 | This sensor provides the upper threshold value for current energy level. 176 | If `levels` are not configured, this sensor is not available. 177 | 178 | 179 | ### Energy level price 180 | This sensor provides the price for the current energy level. 181 | If `levels` are not configured, this sensor is not available. 182 | 183 | ## Contributions are welcome! 184 | 185 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 186 | 187 | [buymecoffee]: https://www.buymeacoffee.com/epaulsen 188 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=flat 189 | [commits-shield]: https://img.shields.io/github/commit-activity/y/epaulsen/energytariff 190 | [commits]: https://github.com/epaulsen/energytariff/commits/master 191 | [hacs_badge]: https://img.shields.io/badge/HACS-Default-41BDF5.svg 192 | [license-shield]: https://img.shields.io/github/license/epaulsen/energytariff 193 | [analytics_badge]: https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.energytariff.total 194 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Test energytariff sensor platform.""" 2 | import pytest 3 | from datetime import datetime, timedelta 4 | from unittest.mock import Mock, patch, AsyncMock, MagicMock 5 | from homeassistant.util import dt 6 | from homeassistant.const import ( 7 | STATE_UNAVAILABLE, 8 | STATE_UNKNOWN, 9 | UnitOfEnergy, 10 | UnitOfPower, 11 | ) 12 | from homeassistant.core import Event, EventStateChangedData 13 | from custom_components.energytariff.sensor import ( 14 | async_setup_platform, 15 | GridCapWatcherEnergySensor, 16 | GridCapWatcherEstimatedEnergySensor, 17 | GridCapWatcherAverageThreePeakHours, 18 | GridCapWatcherAvailableEffectRemainingHour, 19 | GridCapWatcherCurrentEffectLevelThreshold, 20 | GridCapacityWatcherCurrentLevelName, 21 | GridCapacityWatcherCurrentLevelPrice, 22 | ) 23 | from custom_components.energytariff.coordinator import ( 24 | GridCapacityCoordinator, 25 | EnergyData, 26 | GridThresholdData, 27 | ) 28 | from custom_components.energytariff.const import ( 29 | CONF_EFFECT_ENTITY, 30 | GRID_LEVELS, 31 | MAX_EFFECT_ALLOWED, 32 | TARGET_ENERGY, 33 | ROUNDING_PRECISION, 34 | ) 35 | 36 | # Import Home Assistant test fixtures 37 | pytest_plugins = "pytest_homeassistant_custom_component" 38 | 39 | 40 | @pytest.fixture 41 | def expected_lingering_timers(): 42 | """Allow lingering timers for sensor tests with time tracking.""" 43 | return True 44 | 45 | 46 | @pytest.fixture 47 | def basic_config(): 48 | """Create a basic sensor configuration.""" 49 | return { 50 | CONF_EFFECT_ENTITY: "sensor.power_meter", 51 | ROUNDING_PRECISION: 2, 52 | } 53 | 54 | 55 | @pytest.fixture 56 | def config_with_levels(): 57 | """Create a sensor configuration with grid levels.""" 58 | return { 59 | CONF_EFFECT_ENTITY: "sensor.power_meter", 60 | ROUNDING_PRECISION: 2, 61 | GRID_LEVELS: [ 62 | {"name": "Low", "threshold": 2.0, "price": 50}, 63 | {"name": "Medium", "threshold": 5.0, "price": 100}, 64 | {"name": "High", "threshold": 8.0, "price": 200}, 65 | ], 66 | } 67 | 68 | 69 | @pytest.fixture 70 | def config_with_limits(): 71 | """Create a sensor configuration with power limits.""" 72 | return { 73 | CONF_EFFECT_ENTITY: "sensor.power_meter", 74 | TARGET_ENERGY: 5.0, 75 | MAX_EFFECT_ALLOWED: 10000.0, 76 | ROUNDING_PRECISION: 2, 77 | } 78 | 79 | 80 | @pytest.fixture 81 | async def mock_coordinator(hass): 82 | """Create a mock GridCapacityCoordinator.""" 83 | return GridCapacityCoordinator(hass) 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_async_setup_platform_basic(hass, basic_config): 88 | """Test sensor platform setup with basic configuration.""" 89 | mock_add_entities = Mock() 90 | 91 | await async_setup_platform(hass, basic_config, mock_add_entities) 92 | 93 | assert mock_add_entities.called 94 | entities = mock_add_entities.call_args[0][0] 95 | assert len(entities) == 4 96 | assert isinstance(entities[0], GridCapWatcherEnergySensor) 97 | assert isinstance(entities[1], GridCapWatcherEstimatedEnergySensor) 98 | assert isinstance(entities[2], GridCapWatcherAverageThreePeakHours) 99 | assert isinstance(entities[3], GridCapWatcherAvailableEffectRemainingHour) 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_async_setup_platform_with_levels(hass, config_with_levels): 104 | """Test sensor platform setup with grid levels configuration.""" 105 | mock_add_entities = Mock() 106 | 107 | await async_setup_platform(hass, config_with_levels, mock_add_entities) 108 | 109 | assert mock_add_entities.call_count == 2 110 | # First call adds 4 basic sensors 111 | first_call_entities = mock_add_entities.call_args_list[0][0][0] 112 | assert len(first_call_entities) == 4 113 | # Second call adds 3 level sensors 114 | second_call_entities = mock_add_entities.call_args_list[1][0][0] 115 | assert len(second_call_entities) == 3 116 | assert isinstance(second_call_entities[0], GridCapWatcherCurrentEffectLevelThreshold) 117 | assert isinstance(second_call_entities[1], GridCapacityWatcherCurrentLevelName) 118 | assert isinstance(second_call_entities[2], GridCapacityWatcherCurrentLevelPrice) 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_energy_sensor_initialization(hass, basic_config, mock_coordinator): 123 | """Test GridCapWatcherEnergySensor initialization.""" 124 | sensor = GridCapWatcherEnergySensor(hass, basic_config, mock_coordinator) 125 | 126 | assert sensor.name == "Energy used this hour" 127 | assert sensor._effect_sensor_id == "sensor.power_meter" 128 | assert sensor._precision == 2 129 | assert sensor._state is None 130 | assert sensor.available is True 131 | assert sensor.native_value is None 132 | assert sensor.icon == "mdi:lightning-bolt" 133 | 134 | 135 | @pytest.mark.asyncio 136 | async def test_energy_sensor_properties(hass, basic_config, mock_coordinator): 137 | """Test GridCapWatcherEnergySensor properties.""" 138 | sensor = GridCapWatcherEnergySensor(hass, basic_config, mock_coordinator) 139 | sensor._state = 3.456789 140 | 141 | assert sensor.native_value == 3.46 142 | assert sensor.unique_id == "energytariff_power_meter_consumption_kWh" 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_energy_sensor_hourly_reset(hass, basic_config, mock_coordinator): 147 | """Test hourly reset functionality.""" 148 | sensor = GridCapWatcherEnergySensor(hass, basic_config, mock_coordinator) 149 | sensor._state = 5.5 150 | sensor.async_schedule_update_ha_state = Mock() 151 | 152 | sensor.hourly_reset(dt.now()) 153 | 154 | assert sensor._state == 0 155 | assert sensor.async_schedule_update_ha_state.called 156 | 157 | 158 | @pytest.mark.asyncio 159 | async def test_energy_sensor_state_change(hass, basic_config, mock_coordinator): 160 | """Test energy sensor state change callback.""" 161 | sensor = GridCapWatcherEnergySensor(hass, basic_config, mock_coordinator) 162 | sensor.async_schedule_update_ha_state = Mock() 163 | 164 | # Create mock states 165 | old_state = Mock() 166 | old_state.state = "1000" # 1000W 167 | old_state.attributes = {"unit_of_measurement": "W"} 168 | old_state.last_updated = dt.now() - timedelta(seconds=1800) # 30 minutes ago 169 | 170 | new_state = Mock() 171 | new_state.state = "1000" 172 | new_state.attributes = {"unit_of_measurement": "W"} 173 | new_state.last_updated = dt.now() 174 | 175 | # Create event 176 | event_data = {"old_state": old_state, "new_state": new_state} 177 | event = Mock(spec=Event) 178 | event.data = event_data 179 | 180 | sensor._async_on_change(event) 181 | 182 | # 1000W for 30 minutes = 0.5 kWh 183 | assert sensor._state is not None 184 | assert sensor._state > 0 185 | assert sensor.async_schedule_update_ha_state.called 186 | 187 | 188 | @pytest.mark.asyncio 189 | async def test_energy_sensor_ignores_unavailable_state(hass, basic_config, mock_coordinator): 190 | """Test that sensor ignores unavailable states.""" 191 | sensor = GridCapWatcherEnergySensor(hass, basic_config, mock_coordinator) 192 | sensor._state = 1.0 193 | 194 | old_state = Mock() 195 | old_state.state = STATE_UNAVAILABLE 196 | 197 | new_state = Mock() 198 | new_state.state = "1000" 199 | 200 | event_data = {"old_state": old_state, "new_state": new_state} 201 | event = Mock(spec=Event) 202 | event.data = event_data 203 | 204 | sensor._async_on_change(event) 205 | 206 | # State should remain unchanged 207 | assert sensor._state == 1.0 208 | 209 | 210 | @pytest.mark.asyncio 211 | async def test_estimated_energy_sensor_initialization(hass, basic_config, mock_coordinator): 212 | """Test GridCapWatcherEstimatedEnergySensor initialization.""" 213 | sensor = GridCapWatcherEstimatedEnergySensor(hass, basic_config, mock_coordinator) 214 | 215 | assert sensor.name == "Energy estimate this hour" 216 | assert sensor._state is None 217 | assert sensor.available is True 218 | assert sensor.icon == "mdi:lightning-bolt" 219 | 220 | 221 | @pytest.mark.asyncio 222 | async def test_estimated_energy_sensor_state_change(hass, basic_config, mock_coordinator): 223 | """Test estimated energy sensor state change.""" 224 | sensor = GridCapWatcherEstimatedEnergySensor(hass, basic_config, mock_coordinator) 225 | sensor.schedule_update_ha_state = Mock() 226 | 227 | # Create energy data: 2 kWh consumed, 1000W current power, 30 minutes remaining 228 | timestamp = dt.now() - timedelta(minutes=30) 229 | energy_data = EnergyData(2.0, 1000.0, timestamp) 230 | 231 | sensor._state_change(energy_data) 232 | 233 | # Should estimate: 2 kWh + (1000W * 1800s / 3600 / 1000) = 2 + 0.5 = 2.5 kWh 234 | assert sensor._state is not None 235 | assert sensor.schedule_update_ha_state.called 236 | 237 | 238 | @pytest.mark.asyncio 239 | async def test_average_peak_hours_initialization(hass, basic_config, mock_coordinator): 240 | """Test GridCapWatcherAverageThreePeakHours initialization.""" 241 | sensor = GridCapWatcherAverageThreePeakHours(hass, basic_config, mock_coordinator) 242 | 243 | assert sensor.name == "Average peak hour energy" 244 | assert sensor._state is None 245 | assert sensor.attr["top_three"] == [] 246 | 247 | 248 | @pytest.mark.asyncio 249 | async def test_average_peak_hours_state_change(hass, basic_config, mock_coordinator): 250 | """Test average peak hours state change.""" 251 | sensor = GridCapWatcherAverageThreePeakHours(hass, basic_config, mock_coordinator) 252 | sensor.schedule_update_ha_state = Mock() 253 | 254 | # Manually set up top three hours 255 | sensor.attr["top_three"] = [ 256 | {"day": 1, "hour": 10, "energy": 5.0}, 257 | {"day": 2, "hour": 11, "energy": 6.0}, 258 | {"day": 3, "hour": 12, "energy": 7.0}, 259 | ] 260 | 261 | timestamp = dt.now() 262 | energy_data = EnergyData(4.0, 1000.0, timestamp) 263 | 264 | sensor._state_change(energy_data) 265 | 266 | # Average of current top_three values 267 | assert sensor._state is not None 268 | assert sensor.schedule_update_ha_state.called 269 | 270 | 271 | @pytest.mark.asyncio 272 | async def test_available_effect_sensor_initialization(hass, config_with_limits, mock_coordinator): 273 | """Test GridCapWatcherAvailableEffectRemainingHour initialization.""" 274 | sensor = GridCapWatcherAvailableEffectRemainingHour( 275 | hass, config_with_limits, mock_coordinator 276 | ) 277 | 278 | assert sensor.name == "Available power this hour" 279 | assert sensor._state is None 280 | assert sensor._target_energy == 5.0 281 | assert sensor._max_effect == 10000.0 282 | 283 | 284 | @pytest.mark.asyncio 285 | async def test_available_effect_calculation(hass, config_with_limits, mock_coordinator): 286 | """Test available effect calculation.""" 287 | sensor = GridCapWatcherAvailableEffectRemainingHour( 288 | hass, config_with_limits, mock_coordinator 289 | ) 290 | sensor.schedule_update_ha_state = Mock() 291 | 292 | # Set energy consumed to 2 kWh, current effect 1000W 293 | energy_data = EnergyData(2.0, 1000.0, dt.now()) 294 | sensor._effect_state_change(energy_data) 295 | 296 | # Should have calculated available power 297 | assert sensor._state is not None 298 | assert sensor.schedule_update_ha_state.called 299 | 300 | 301 | @pytest.mark.asyncio 302 | async def test_threshold_sensor_initialization(hass, config_with_levels, mock_coordinator): 303 | """Test GridCapWatcherCurrentEffectLevelThreshold initialization.""" 304 | sensor = GridCapWatcherCurrentEffectLevelThreshold( 305 | hass, config_with_levels, mock_coordinator 306 | ) 307 | 308 | assert sensor.name == "Energy level upper threshold" 309 | assert sensor._state is None 310 | assert len(sensor._levels) == 3 311 | 312 | 313 | @pytest.mark.asyncio 314 | async def test_threshold_sensor_get_level(hass, config_with_levels, mock_coordinator): 315 | """Test threshold level calculation.""" 316 | sensor = GridCapWatcherCurrentEffectLevelThreshold( 317 | hass, config_with_levels, mock_coordinator 318 | ) 319 | 320 | # Test getting correct level for different averages 321 | level = sensor.get_level(1.5) 322 | assert level["name"] == "Low" 323 | assert level["threshold"] == 2.0 324 | 325 | level = sensor.get_level(3.0) 326 | assert level["name"] == "Medium" 327 | assert level["threshold"] == 5.0 328 | 329 | level = sensor.get_level(6.0) 330 | assert level["name"] == "High" 331 | assert level["threshold"] == 8.0 332 | 333 | 334 | @pytest.mark.asyncio 335 | async def test_threshold_sensor_calculate_level(hass, config_with_levels, mock_coordinator): 336 | """Test threshold level calculation with top three hours.""" 337 | sensor = GridCapWatcherCurrentEffectLevelThreshold( 338 | hass, config_with_levels, mock_coordinator 339 | ) 340 | sensor.schedule_update_ha_state = Mock() 341 | 342 | # Set top three hours averaging to 3 kWh 343 | sensor.attr["top_three"] = [ 344 | {"day": 1, "hour": 10, "energy": 2.0}, 345 | {"day": 2, "hour": 11, "energy": 3.0}, 346 | {"day": 3, "hour": 12, "energy": 4.0}, 347 | ] 348 | 349 | sensor.calculate_level() 350 | 351 | # Average is 3.0, so should be "Medium" level with threshold 5.0 352 | assert sensor._state == 5.0 353 | assert sensor.schedule_update_ha_state.called 354 | 355 | @pytest.mark.asyncio 356 | async def test_threshold_sensor_calculate_level_repro(hass, config_with_levels, mock_coordinator): 357 | """Test threshold level calculation with top three hours.""" 358 | sensor = GridCapWatcherCurrentEffectLevelThreshold( 359 | hass, config_with_levels, mock_coordinator 360 | ) 361 | sensor.schedule_update_ha_state = Mock() 362 | 363 | # Set top three hours averaging to 3 kWh 364 | sensor.attr["top_three"] = [ 365 | {"day": 1, "hour": 10, "energy": 6.902}, 366 | {"day": 2, "hour": 11, "energy": 5.1978}, 367 | {"day": 3, "hour": 12, "energy": 5.487}, 368 | ] 369 | 370 | sensor.calculate_level() 371 | 372 | # Average is 5.86, so should be level with threshold 8.0 373 | assert sensor._state == 8.0 374 | assert sensor.schedule_update_ha_state.called 375 | 376 | 377 | @pytest.mark.asyncio 378 | async def test_level_name_sensor_initialization(hass, config_with_levels, mock_coordinator): 379 | """Test GridCapacityWatcherCurrentLevelName initialization.""" 380 | sensor = GridCapacityWatcherCurrentLevelName( 381 | hass, config_with_levels, mock_coordinator 382 | ) 383 | 384 | assert sensor.name == "Energy level name" 385 | assert sensor._state is None 386 | assert sensor.icon == "mdi:rename-box" 387 | 388 | 389 | @pytest.mark.asyncio 390 | async def test_level_name_sensor_threshold_change(hass, config_with_levels, mock_coordinator): 391 | """Test level name sensor responds to threshold changes.""" 392 | sensor = GridCapacityWatcherCurrentLevelName( 393 | hass, config_with_levels, mock_coordinator 394 | ) 395 | sensor.schedule_update_ha_state = Mock() 396 | 397 | threshold_data = GridThresholdData("Medium", 5.0, 100, []) 398 | sensor._threshold_state_change(threshold_data) 399 | 400 | assert sensor._state == "Medium" 401 | assert sensor.schedule_update_ha_state.called 402 | 403 | 404 | @pytest.mark.asyncio 405 | async def test_level_price_sensor_initialization(hass, config_with_levels, mock_coordinator): 406 | """Test GridCapacityWatcherCurrentLevelPrice initialization.""" 407 | sensor = GridCapacityWatcherCurrentLevelPrice( 408 | hass, config_with_levels, mock_coordinator 409 | ) 410 | 411 | assert sensor.name == "Energy level price" 412 | assert sensor._state is None 413 | assert sensor.icon == "mdi:cash" 414 | 415 | 416 | @pytest.mark.asyncio 417 | async def test_level_price_sensor_threshold_change(hass, config_with_levels, mock_coordinator): 418 | """Test level price sensor responds to threshold changes.""" 419 | sensor = GridCapacityWatcherCurrentLevelPrice( 420 | hass, config_with_levels, mock_coordinator 421 | ) 422 | sensor.schedule_update_ha_state = Mock() 423 | 424 | threshold_data = GridThresholdData("High", 8.0, 200, []) 425 | sensor._threshold_state_change(threshold_data) 426 | 427 | assert sensor._state == 200 428 | assert sensor.schedule_update_ha_state.called 429 | 430 | 431 | @pytest.mark.asyncio 432 | async def test_sensor_unique_ids(hass, basic_config, mock_coordinator): 433 | """Test that all sensors have unique IDs.""" 434 | energy_sensor = GridCapWatcherEnergySensor(hass, basic_config, mock_coordinator) 435 | estimated_sensor = GridCapWatcherEstimatedEnergySensor( 436 | hass, basic_config, mock_coordinator 437 | ) 438 | average_sensor = GridCapWatcherAverageThreePeakHours( 439 | hass, basic_config, mock_coordinator 440 | ) 441 | available_sensor = GridCapWatcherAvailableEffectRemainingHour( 442 | hass, basic_config, mock_coordinator 443 | ) 444 | 445 | unique_ids = [ 446 | energy_sensor.unique_id, 447 | estimated_sensor.unique_id, 448 | average_sensor.unique_id, 449 | available_sensor.unique_id, 450 | ] 451 | 452 | # All unique IDs should be different 453 | assert len(unique_ids) == len(set(unique_ids)) 454 | 455 | 456 | @pytest.mark.asyncio 457 | async def test_sensor_units_of_measurement(hass, basic_config, config_with_limits, mock_coordinator): 458 | """Test that sensors have correct units of measurement.""" 459 | energy_sensor = GridCapWatcherEnergySensor(hass, basic_config, mock_coordinator) 460 | estimated_sensor = GridCapWatcherEstimatedEnergySensor( 461 | hass, basic_config, mock_coordinator 462 | ) 463 | available_sensor = GridCapWatcherAvailableEffectRemainingHour( 464 | hass, config_with_limits, mock_coordinator 465 | ) 466 | 467 | assert energy_sensor._attr_native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR 468 | assert estimated_sensor._attr_native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR 469 | assert available_sensor._attr_native_unit_of_measurement == UnitOfPower.WATT 470 | -------------------------------------------------------------------------------- /custom_components/energytariff/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for grid-cap-watcher.""" 2 | 3 | from datetime import datetime 4 | from logging import getLogger 5 | from typing import Any 6 | 7 | import homeassistant.helpers.config_validation as cv 8 | import voluptuous as vol 9 | from homeassistant.components.sensor import ( 10 | PLATFORM_SCHEMA, 11 | RestoreEntity, 12 | RestoreSensor, 13 | SensorEntity, 14 | SensorStateClass, 15 | ) 16 | from homeassistant.const import ( 17 | STATE_UNAVAILABLE, 18 | STATE_UNKNOWN, 19 | UnitOfEnergy, 20 | UnitOfPower, 21 | ) 22 | from homeassistant.core import Event, EventStateChangedData, callback 23 | from homeassistant.helpers.event import ( 24 | async_track_point_in_time, 25 | async_track_state_change_event, 26 | ) 27 | from homeassistant.util import dt 28 | 29 | from .const import ( 30 | CONF_EFFECT_ENTITY, 31 | DOMAIN, 32 | GRID_LEVELS, 33 | ICON, 34 | LEVEL_NAME, 35 | LEVEL_PRICE, 36 | LEVEL_THRESHOLD, 37 | MAX_EFFECT_ALLOWED, 38 | RESET_TOP_THREE, 39 | ROUNDING_PRECISION, 40 | TARGET_ENERGY, 41 | ) 42 | from .coordinator import EnergyData, GridCapacityCoordinator, GridThresholdData 43 | from .utils import ( 44 | calculate_top_three, 45 | convert_to_watt, 46 | get_rounding_precision, 47 | seconds_between, 48 | start_of_next_hour, 49 | start_of_next_month, 50 | ) 51 | 52 | _LOGGER = getLogger(__name__) 53 | 54 | LEVEL_SCHEMA = vol.Schema( 55 | { 56 | vol.Required(LEVEL_NAME): cv.string, 57 | vol.Required(LEVEL_THRESHOLD): cv.Number, 58 | vol.Required(LEVEL_PRICE): cv.Number, 59 | } 60 | ) 61 | 62 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 63 | { 64 | vol.Required(CONF_EFFECT_ENTITY): cv.string, 65 | vol.Optional(TARGET_ENERGY): cv.positive_float, 66 | vol.Optional(MAX_EFFECT_ALLOWED): cv.positive_float, 67 | vol.Optional(ROUNDING_PRECISION): cv.positive_int, 68 | vol.Optional(GRID_LEVELS): vol.All(cv.ensure_list, [LEVEL_SCHEMA]), 69 | } 70 | ) 71 | 72 | 73 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 74 | """Setup sensor platform.""" 75 | rx_coord = GridCapacityCoordinator(hass) 76 | 77 | async_add_entities( 78 | [ 79 | GridCapWatcherEnergySensor(hass, config, rx_coord), 80 | GridCapWatcherEstimatedEnergySensor(hass, config, rx_coord), 81 | GridCapWatcherAverageThreePeakHours(hass, config, rx_coord), 82 | GridCapWatcherAvailableEffectRemainingHour(hass, config, rx_coord), 83 | ] 84 | ) 85 | if config.get(GRID_LEVELS) is not None: 86 | async_add_entities( 87 | [ 88 | GridCapWatcherCurrentEffectLevelThreshold(hass, config, rx_coord), 89 | GridCapacityWatcherCurrentLevelName(hass, config, rx_coord), 90 | GridCapacityWatcherCurrentLevelPrice(hass, config, rx_coord), 91 | ] 92 | ) 93 | 94 | 95 | class GridCapWatcherEnergySensor(RestoreSensor): 96 | """grid_cap_watcher Energy sensor class.""" 97 | 98 | _state_class = SensorStateClass.TOTAL 99 | _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 100 | 101 | def __init__(self, hass, config, rx_coord: GridCapacityCoordinator): 102 | self._hass = hass 103 | self._effect_sensor_id = config.get(CONF_EFFECT_ENTITY) 104 | self._precision = get_rounding_precision(config) 105 | self._last_update = None 106 | self._coordinator = rx_coord 107 | self._attr_icon: str = ICON 108 | self._state = None 109 | self._attr_unique_id = ( 110 | f"{DOMAIN}_{self._effect_sensor_id}_consumption_kWh".replace("sensor.", "") 111 | ) 112 | 113 | # Listen to input sensor state change event 114 | self.__unsub = async_track_state_change_event( 115 | hass, self._effect_sensor_id, self._async_on_change 116 | ) 117 | 118 | # Setup hourly sensor reset. 119 | async_track_point_in_time(hass, self.hourly_reset, start_of_next_hour(dt.now())) 120 | 121 | async def async_added_to_hass(self) -> None: 122 | """Call when entity about to be added to hass.""" 123 | await super().async_added_to_hass() 124 | savedstate = await self.async_get_last_sensor_data() 125 | if savedstate and savedstate.native_value is not None: 126 | self._state = float(savedstate.native_value) 127 | 128 | async def async_will_remove_from_hass(self) -> None: 129 | self.__unsub() 130 | 131 | @callback 132 | def hourly_reset(self, time): 133 | """Callback that HA invokes at the start of each hour to reset this sensor value""" 134 | _LOGGER.debug("Hourly reset") 135 | self._state = 0 136 | self.async_schedule_update_ha_state(True) 137 | #self.fire_event(0, time) <-- Commented as this somehow causes problems for some installations. 138 | async_track_point_in_time( 139 | self._hass, self.hourly_reset, start_of_next_hour(time) 140 | ) 141 | 142 | @callback 143 | def _async_on_change(self, event: Event[EventStateChangedData]) -> None: 144 | """Callback for when the AMS sensor changes""" 145 | old_state = event.data["old_state"] 146 | new_state = event.data["new_state"] 147 | 148 | if new_state is None or old_state is None: 149 | return 150 | if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): 151 | return 152 | if old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): 153 | return 154 | 155 | if self._state is None: 156 | self._state = 0 157 | 158 | diff = seconds_between(new_state.last_updated, old_state.last_updated) 159 | watt = convert_to_watt(old_state) 160 | if diff > 3600: 161 | _LOGGER.warning("More than 1 hour since last update, discarding result") 162 | return 163 | 164 | self._state += (diff * watt) / (3600 * 1000) 165 | self.fire_event(watt, old_state.last_updated) 166 | self.async_schedule_update_ha_state(True) 167 | 168 | def fire_event(self, power: float, timestamp: datetime) -> bool: 169 | """Fire HA event so that dependent sensors can update their respective values""" 170 | self._coordinator.effectstate.on_next(EnergyData(self._state, power, timestamp)) 171 | return True 172 | 173 | @property 174 | def name(self): 175 | """Return the name of the sensor.""" 176 | return "Energy used this hour" 177 | 178 | @property 179 | def unique_id(self) -> str: 180 | """Return the unique ID of the sensor.""" 181 | return self._attr_unique_id 182 | 183 | @property 184 | def available(self) -> bool: 185 | """Return True if entity is available.""" 186 | return True 187 | 188 | @property 189 | def native_value(self): 190 | if self._state is not None: 191 | return round(self._state, self._precision) 192 | return self._state 193 | 194 | @property 195 | def icon(self): 196 | """Return the icon of the sensor.""" 197 | return ICON 198 | 199 | 200 | class GridCapWatcherEstimatedEnergySensor(SensorEntity): 201 | """Estimated consumption per hour""" 202 | 203 | _state_class = SensorStateClass.TOTAL 204 | _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 205 | 206 | def __init__(self, hass, config, rx_coord: GridCapacityCoordinator): 207 | self._hass = hass 208 | self._effect_sensor_id = config.get(CONF_EFFECT_ENTITY) 209 | self._coordinator = rx_coord 210 | self._precision = get_rounding_precision(config) 211 | self._state = None 212 | self._attr_unique_id = ( 213 | f"{DOMAIN}_{self._effect_sensor_id}_consumption_estimate_kWh".replace( 214 | "sensor.", "" 215 | ) 216 | ) 217 | 218 | self._coordinator.effectstate.subscribe(self._state_change) 219 | 220 | def _state_change(self, state: EnergyData): 221 | if state is None: 222 | return 223 | 224 | if state.energy_consumed is None or state.current_effect is None: 225 | return 226 | 227 | energy = state.energy_consumed 228 | power = state.current_effect 229 | update_time = state.timestamp 230 | 231 | remaining_seconds = seconds_between( 232 | start_of_next_hour(update_time), update_time 233 | ) 234 | 235 | if remaining_seconds == 0: 236 | # Avoid division by zero 237 | remaining_seconds = 1 238 | 239 | self._state = energy + power * remaining_seconds / 3600 / 1000 240 | self.schedule_update_ha_state() 241 | 242 | @property 243 | def name(self): 244 | """Return the name of the sensor.""" 245 | return "Energy estimate this hour" 246 | 247 | @property 248 | def unique_id(self) -> str: 249 | """Return the unique ID of the sensor.""" 250 | return self._attr_unique_id 251 | 252 | @property 253 | def available(self) -> bool: 254 | """Return True if entity is available.""" 255 | return True 256 | 257 | @property 258 | def native_value(self): 259 | """Returns the native value for this sensor""" 260 | if self._state is not None: 261 | return round(self._state, self._precision) 262 | return self._state 263 | 264 | @property 265 | def icon(self): 266 | """Return the icon of the sensor.""" 267 | return ICON 268 | 269 | 270 | class GridCapWatcherCurrentEffectLevelThreshold(RestoreSensor, RestoreEntity): 271 | """Sensor that holds the grid effect level we are at""" 272 | 273 | _state_class = SensorStateClass.MEASUREMENT 274 | _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 275 | 276 | def __init__(self, hass, config, rx_coord: GridCapacityCoordinator): 277 | self._hass = hass 278 | self._effect_sensor_id = config.get(CONF_EFFECT_ENTITY) 279 | self._coordinator = rx_coord 280 | self._state = None 281 | self._attr_unique_id = ( 282 | f"{DOMAIN}_{self._effect_sensor_id}_grid_effect_threshold_kwh".replace( 283 | "sensor.", "" 284 | ) 285 | ) 286 | 287 | self.attr = {"top_three": []} 288 | 289 | self._levels = config.get(GRID_LEVELS) 290 | 291 | self._coordinator.effectstate.subscribe(self._state_change) 292 | 293 | hass.bus.async_listen(RESET_TOP_THREE, self.handle_reset_event) 294 | 295 | async_track_point_in_time( 296 | hass, self._async_reset_meter, start_of_next_month(dt.as_local(dt.now())) 297 | ) 298 | 299 | async def async_added_to_hass(self) -> None: 300 | """Call when entity about to be added to hass.""" 301 | await super().async_added_to_hass() 302 | savedstate = await self.async_get_last_state() 303 | if savedstate: 304 | if savedstate.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): 305 | self._state = float(savedstate.state) 306 | if "top_three" in savedstate.attributes: 307 | for item in savedstate.attributes["top_three"]: 308 | self.attr["top_three"].append( 309 | { 310 | "day": item["day"], 311 | "hour": item["hour"], 312 | "energy": item["energy"], 313 | } 314 | ) 315 | 316 | @callback 317 | def _async_reset_meter(self, _): 318 | """Resets the attributes so that we don't carry over old values to new month""" 319 | self.attr["top_three"] = [] 320 | self.schedule_update_ha_state(True) 321 | _LOGGER.debug("Monthly reset") 322 | async_track_point_in_time( 323 | self._hass, 324 | self._async_reset_meter, 325 | start_of_next_month(dt.as_local(dt.now())), 326 | ) 327 | 328 | @callback 329 | def handle_reset_event(self, event): 330 | """Handle reset event to reset top three attributes""" 331 | self._async_reset_meter(event) 332 | 333 | def _state_change(self, state: EnergyData) -> None: 334 | if state is None: 335 | return 336 | 337 | self.attr["month"] = dt.as_local(dt.now()).month 338 | self.attr["top_three"] = calculate_top_three(state, self.attr["top_three"]) 339 | self.calculate_level() 340 | 341 | def calculate_level(self) -> bool: 342 | """Calculate the grid threshold level based on average of the highest hours""" 343 | average_value = 0.0 344 | for hour in self.attr["top_three"]: 345 | average_value += hour["energy"] 346 | 347 | average_value = average_value / len(self.attr["top_three"]) 348 | 349 | found_threshold = self.get_level(average_value) 350 | 351 | if found_threshold is not None: 352 | self._state = found_threshold["threshold"] 353 | self.schedule_update_ha_state(True) 354 | 355 | # Notify other sensors that threshold level has been updated 356 | self._coordinator.thresholddata.on_next( 357 | GridThresholdData( 358 | found_threshold["name"], 359 | float(found_threshold["threshold"]), 360 | float(found_threshold["price"]), 361 | self.attr["top_three"], 362 | ) 363 | ) 364 | return True 365 | 366 | def get_level(self, average: float) -> Any: 367 | """Gets the current threshold level""" 368 | for level in self._levels: 369 | if average - level["threshold"] < 0: 370 | return level 371 | 372 | _LOGGER.warning( 373 | "Hourly energy is outside capacity level steps. Check configuration!" 374 | ) 375 | return None 376 | 377 | @property 378 | def name(self): 379 | """Return the name of the sensor.""" 380 | return "Energy level upper threshold" 381 | 382 | @property 383 | def unique_id(self) -> str: 384 | """Return the unique ID of the sensor.""" 385 | return self._attr_unique_id 386 | 387 | @property 388 | def available(self) -> bool: 389 | """Return True if entity is available.""" 390 | return self._state is not None 391 | 392 | @property 393 | def native_value(self): 394 | """Returns the native value for this sensor""" 395 | return self._state 396 | 397 | @property 398 | def icon(self): 399 | """Return the icon of the sensor.""" 400 | return ICON 401 | 402 | @property 403 | def extra_state_attributes(self): 404 | return self.attr 405 | 406 | 407 | class GridCapWatcherAverageThreePeakHours(RestoreSensor, RestoreEntity): 408 | """Sensor that holds the average value of the three peak hours this month""" 409 | 410 | _state_class = SensorStateClass.MEASUREMENT 411 | _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 412 | 413 | def __init__(self, hass, config, rx_coord: GridCapacityCoordinator): 414 | self._hass = hass 415 | self._effect_sensor_id = config.get(CONF_EFFECT_ENTITY) 416 | self._coordinator = rx_coord 417 | self._precision = get_rounding_precision(config) 418 | self._state = None 419 | self._attr_unique_id = ( 420 | f"{DOMAIN}_{self._effect_sensor_id}_grid_three_peak_hours_average".replace( 421 | "sensor.", "" 422 | ) 423 | ) 424 | 425 | self.attr = {"top_three": []} 426 | 427 | self._levels = config.get(GRID_LEVELS) 428 | 429 | # Subscribe to both effectstate (for backwards compatibility and when no levels are configured) 430 | # and thresholddata (to get synchronized top_three from threshold sensor) 431 | self._coordinator.effectstate.subscribe(self._state_change) 432 | if config.get(GRID_LEVELS) is not None: 433 | self._coordinator.thresholddata.subscribe(self._threshold_state_change) 434 | 435 | hass.bus.async_listen(RESET_TOP_THREE, self.handle_reset_event) 436 | 437 | async_track_point_in_time( 438 | hass, self._async_reset_meter, start_of_next_month(dt.as_local(dt.now())) 439 | ) 440 | 441 | async def async_added_to_hass(self) -> None: 442 | """Call when entity about to be added to hass.""" 443 | await super().async_added_to_hass() 444 | savedstate = await self.async_get_last_state() 445 | if savedstate: 446 | if savedstate.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): 447 | self._state = float(savedstate.state) 448 | if "top_three" in savedstate.attributes: 449 | for item in savedstate.attributes["top_three"]: 450 | self.attr["top_three"].append( 451 | { 452 | "day": item["day"], 453 | "hour": item["hour"], 454 | "energy": item["energy"], 455 | } 456 | ) 457 | 458 | @callback 459 | def _async_reset_meter(self, _): 460 | """Resets the attributes so that we don't carry over old values to new month""" 461 | self.attr["top_three"] = [] 462 | self.schedule_update_ha_state(True) 463 | _LOGGER.debug("Monthly reset") 464 | async_track_point_in_time( 465 | self._hass, 466 | self._async_reset_meter, 467 | start_of_next_month(dt.as_local(dt.now())), 468 | ) 469 | 470 | @callback 471 | def handle_reset_event(self, event): 472 | """Handle reset event to reset top three attributes""" 473 | self._async_reset_meter(event) 474 | 475 | def _threshold_state_change(self, threshold_data: GridThresholdData) -> None: 476 | """ 477 | Update top_three and average from threshold sensor. 478 | 479 | This ensures that the average sensor and threshold sensor always use 480 | the same top_three data, preventing synchronization issues. 481 | """ 482 | if threshold_data is None: 483 | return 484 | 485 | # Use the top_three from the threshold sensor 486 | self.attr["top_three"] = threshold_data.top_three 487 | 488 | # Recalculate the average 489 | if len(self.attr["top_three"]) == 0: 490 | return 491 | 492 | totalSum = float(0) 493 | for hour in self.attr["top_three"]: 494 | totalSum += float(hour["energy"]) 495 | 496 | self._state = totalSum / len(self.attr["top_three"]) 497 | self.schedule_update_ha_state(True) 498 | 499 | def _state_change(self, state: EnergyData) -> None: 500 | if state is None: 501 | return None 502 | 503 | # Only calculate top_three if levels are not configured 504 | # If levels are configured, we get top_three from threshold sensor via _threshold_state_change 505 | if self._levels is not None: 506 | return None 507 | 508 | self.attr["top_three"] = calculate_top_three(state, self.attr["top_three"]) 509 | 510 | totalSum = float(0) 511 | for hour in self.attr["top_three"]: 512 | totalSum += float(hour["energy"]) 513 | 514 | if len(self.attr["top_three"]) == 0: 515 | return None 516 | 517 | self._state = totalSum / len(self.attr["top_three"]) 518 | self.schedule_update_ha_state(True) 519 | return True 520 | 521 | @property 522 | def name(self): 523 | """Return the name of the sensor.""" 524 | return "Average peak hour energy" 525 | 526 | @property 527 | def unique_id(self) -> str: 528 | """Return the unique ID of the sensor.""" 529 | return self._attr_unique_id 530 | 531 | @property 532 | def available(self) -> bool: 533 | """Return True if entity is available.""" 534 | return self._state is not None 535 | 536 | @property 537 | def native_value(self): 538 | """Returns the native value for this sensor""" 539 | if self._state is not None: 540 | return round(self._state, self._precision) 541 | return self._state 542 | 543 | @property 544 | def icon(self): 545 | """Return the icon of the sensor.""" 546 | return ICON 547 | 548 | @property 549 | def extra_state_attributes(self): 550 | return self.attr 551 | 552 | 553 | class GridCapWatcherAvailableEffectRemainingHour(RestoreSensor, RestoreEntity): 554 | """ 555 | Sensor that measures the max power draw that can be consumed 556 | for the remainin part of current hour 557 | """ 558 | 559 | _state_class = SensorStateClass.MEASUREMENT 560 | _attr_native_unit_of_measurement = UnitOfPower.WATT 561 | 562 | def __init__(self, hass, config, rx_coord: GridCapacityCoordinator): 563 | self._hass = hass 564 | self._effect_sensor_id = config.get(CONF_EFFECT_ENTITY) 565 | self._effect = None 566 | self._energy = None 567 | self._coordinator = rx_coord 568 | self._precision = get_rounding_precision(config) 569 | self._max_effect = config.get(MAX_EFFECT_ALLOWED) 570 | self._target_energy = config.get(TARGET_ENERGY) 571 | self._state = None 572 | self.attr = {"grid_threshold_level": self._target_energy} 573 | 574 | self._attr_unique_id = ( 575 | f"{DOMAIN}_{self._effect_sensor_id}_remaining_effect_available".replace( 576 | "sensor.", "" 577 | ) 578 | ) 579 | 580 | self._coordinator.thresholddata.subscribe(self._threshold_state_change) 581 | self._coordinator.effectstate.subscribe(self._effect_state_change) 582 | 583 | async def async_added_to_hass(self) -> None: 584 | """Call when entity about to be added to hass.""" 585 | await super().async_added_to_hass() 586 | savedstate = await self.async_get_last_state() 587 | if savedstate: 588 | if savedstate.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): 589 | self._state = float(savedstate.state) 590 | if "grid_threshold_level" in savedstate.attributes: 591 | self.attr["grid_threshold_level"] = savedstate.attributes[ 592 | "grid_threshold_level" 593 | ] 594 | self.__calculate() 595 | 596 | def _threshold_state_change(self, state: GridThresholdData): 597 | if state is None: 598 | return 599 | self.attr["grid_threshold_level"] = state.level 600 | self.__calculate() 601 | self.schedule_update_ha_state(True) 602 | 603 | def _effect_state_change(self, state: EnergyData): 604 | if state is None: 605 | return 606 | self._energy = state.energy_consumed 607 | self._effect = state.current_effect 608 | self.__calculate() 609 | self.schedule_update_ha_state(True) 610 | 611 | def __calculate(self): 612 | if ( 613 | self._energy is None 614 | or self._effect is None 615 | or ( 616 | self.attr["grid_threshold_level"] is None 617 | and self._target_energy is None 618 | ) 619 | ): 620 | return None 621 | 622 | if self._target_energy is None: 623 | threshold_energy = float(self.attr["grid_threshold_level"]) 624 | else: 625 | threshold_energy = float(self._target_energy) 626 | 627 | remaining_kwh = threshold_energy - self._energy 628 | 629 | seconds_remaing = seconds_between(start_of_next_hour(dt.now()), dt.now()) 630 | 631 | seconds_remaing = max(seconds_remaing, 1) 632 | 633 | watt_seconds = remaining_kwh * 3600 * 1000 634 | 635 | power = watt_seconds / seconds_remaing - self._effect 636 | 637 | if self._max_effect is not None and float(self._max_effect) < power: 638 | # Max effect threshold exceeded, 639 | # we should display max_effect - current_effect from meter 640 | power = float(self._max_effect) - self._effect 641 | 642 | if ( 643 | self._max_effect is not None 644 | and power < 0 645 | and float(self._max_effect) * -1 > power 646 | ): 647 | # Do not exceed threshold in negative direction either. 648 | # Purely cosmetic, but it messes up scale on graph. 649 | power = float(self._max_effect) * -1 650 | 651 | self._state = power 652 | 653 | return True 654 | 655 | @property 656 | def name(self): 657 | """Return the name of the sensor.""" 658 | return "Available power this hour" 659 | 660 | @property 661 | def unique_id(self) -> str: 662 | """Return the unique ID of the sensor.""" 663 | return self._attr_unique_id 664 | 665 | @property 666 | def available(self) -> bool: 667 | """Return True if entity is available.""" 668 | return self._state is not None 669 | 670 | @property 671 | def native_value(self): 672 | """Returns the native value for this sensor""" 673 | if self._state is not None: 674 | return round(self._state, self._precision) 675 | return self._state 676 | 677 | @property 678 | def icon(self): 679 | """Return the icon of the sensor.""" 680 | return ICON 681 | 682 | @property 683 | def extra_state_attributes(self): 684 | return self.attr 685 | 686 | 687 | class GridCapacityWatcherCurrentLevelName(RestoreSensor): 688 | """ 689 | Sensor that measures the max power draw that can be consumed for 690 | the remainin part of current hour 691 | """ 692 | 693 | _state_class = SensorStateClass.MEASUREMENT 694 | 695 | def __init__(self, hass, config, rx_coord: GridCapacityCoordinator): 696 | self._coordinator = rx_coord 697 | self._hass = hass 698 | self._state = None 699 | self._levels = config.get(GRID_LEVELS) 700 | self._attr_unique_id = f"{DOMAIN}_effect_level_name".replace("sensor.", "") 701 | self._coordinator.thresholddata.subscribe(self._threshold_state_change) 702 | 703 | def _threshold_state_change(self, state: GridThresholdData): 704 | if state is None: 705 | return 706 | self._state = state.name 707 | self.schedule_update_ha_state(True) 708 | 709 | async def async_added_to_hass(self) -> None: 710 | """Call when entity about to be added to hass.""" 711 | await super().async_added_to_hass() 712 | savedstate = await self.async_get_last_state() 713 | if savedstate: 714 | if savedstate.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): 715 | self._state = savedstate.state 716 | 717 | @property 718 | def name(self): 719 | """Return the name of the sensor.""" 720 | return "Energy level name" 721 | 722 | @property 723 | def unique_id(self) -> str: 724 | """Return the unique ID of the sensor.""" 725 | return self._attr_unique_id 726 | 727 | @property 728 | def available(self) -> bool: 729 | """Return True if entity is available.""" 730 | return self._state is not None 731 | 732 | @property 733 | def native_value(self): 734 | """Returns the native value for this sensor""" 735 | return self._state 736 | 737 | @property 738 | def icon(self): 739 | """Return the icon of the sensor.""" 740 | return "mdi:rename-box" 741 | 742 | 743 | class GridCapacityWatcherCurrentLevelPrice(RestoreSensor): 744 | """ 745 | Sensor that measures the max power draw that can be consumed for 746 | the remainin part of current hour 747 | """ 748 | 749 | _state_class = SensorStateClass.MEASUREMENT 750 | 751 | # TODO: How to globalize this?? 752 | _attr_native_unit_of_measurement = "NOK" 753 | 754 | def __init__(self, hass, config, rx_coord: GridCapacityCoordinator): 755 | self._coordinator = rx_coord 756 | self._hass = hass 757 | self._state = None 758 | self._levels = config.get(GRID_LEVELS) 759 | self._attr_unique_id = f"{DOMAIN}_effect_level_price".replace("sensor.", "") 760 | self._coordinator.thresholddata.subscribe(self._threshold_state_change) 761 | 762 | def _threshold_state_change(self, state: GridThresholdData): 763 | if state is None: 764 | return 765 | self._state = state.price 766 | self.schedule_update_ha_state(True) 767 | 768 | async def async_added_to_hass(self) -> None: 769 | """Call when entity about to be added to hass.""" 770 | await super().async_added_to_hass() 771 | savedstate = await self.async_get_last_state() 772 | if savedstate: 773 | if savedstate.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): 774 | self._state = savedstate.state 775 | 776 | @property 777 | def name(self): 778 | """Return the name of the sensor.""" 779 | return "Energy level price" 780 | 781 | @property 782 | def unique_id(self) -> str: 783 | """Return the unique ID of the sensor.""" 784 | return self._attr_unique_id 785 | 786 | @property 787 | def available(self) -> bool: 788 | """Return True if entity is available.""" 789 | return self._state is not None 790 | 791 | @property 792 | def native_value(self): 793 | """Returns the native value for this sensor""" 794 | return self._state 795 | 796 | @property 797 | def icon(self): 798 | """Return the icon of the sensor.""" 799 | return "mdi:cash" 800 | --------------------------------------------------------------------------------