├── .devcontainer ├── devcontainer.json └── scripts │ ├── clear-config │ ├── dev-branch │ ├── develop │ ├── lint │ ├── setup │ ├── specific-version │ ├── type-check │ └── upgrade ├── .github ├── CODEOWNERS ├── release-drafter.yml └── workflows │ ├── release-drafter.yaml │ ├── release.yaml │ ├── update_manifest.py │ └── validate.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json ├── launch.json ├── settings.default.json └── tasks.json ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── config └── configuration.yaml ├── custom_components └── pun_sensor │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── interfaces.py │ ├── manifest.json │ ├── sensor.py │ ├── strings.json │ ├── translations │ ├── en.json │ └── it.json │ └── utils.py ├── hacs.json ├── pyproject.toml ├── requirements.txt ├── requirements_ha.txt ├── screenshot_debug_1.png ├── screenshot_debug_2.png ├── screenshots_main.png ├── screenshots_settings.png └── set_manifest.sh /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Home Assistant Integration Dev", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": ".devcontainer/scripts/setup", 5 | "containerEnv": { 6 | "PYTHONASYNCIODEBUG": "1" 7 | }, 8 | "runArgs": ["-e", "GIT_EDITOR=code --wait"], 9 | "forwardPorts": [8123], 10 | "portsAttributes": { 11 | "8123": { 12 | "label": "Home Assistant", 13 | "onAutoForward": "notify" 14 | } 15 | }, 16 | "customizations": { 17 | "vscode": { 18 | "extensions": [ 19 | "charliermarsh.ruff", 20 | "github.vscode-pull-request-github", 21 | "ms-python.pylint", 22 | "ms-python.python", 23 | "ms-python.vscode-pylance", 24 | "redhat.vscode-yaml", 25 | "esbenp.prettier-vscode", 26 | "thibault-vanderseypen.i18n-json-editor", 27 | "eamodio.gitlens", 28 | "ms-python.mypy-type-checker" 29 | ], 30 | "settings": { 31 | "files.eol": "\n", 32 | "editor.tabSize": 4, 33 | "pylint.importStrategy": "fromEnvironment", 34 | //"python.pythonPath": "/usr/local/bin/python3", 35 | "python.defaultInterpreterPath": "/usr/local/bin/python", 36 | "python.analysis.autoSearchPaths": true, 37 | "[python]": { 38 | "editor.defaultFormatter": "charliermarsh.ruff", 39 | "editor.formatOnSave": true 40 | }, 41 | "python.linting.mypyArgs": ["--cache-dir=.mypy_cache"], 42 | "editor.formatOnPaste": false, 43 | "editor.formatOnSave": true, 44 | "editor.formatOnType": true, 45 | "files.trimTrailingWhitespace": true, 46 | "[markdown]": { 47 | "files.trimTrailingWhitespace": false 48 | }, 49 | "i18nJsonEditor.forceKeyUPPERCASE": false, 50 | "i18nJsonEditor.supportedFolders": ["translations", "i18n"] 51 | } 52 | } 53 | }, 54 | "remoteUser": "vscode", 55 | "features": { 56 | "ghcr.io/devcontainers/features/rust:1": {} 57 | //"ghcr.io/devcontainers-contrib/features/ffmpeg-apt-get:1": {} 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.devcontainer/scripts/clear-config: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../.." 6 | 7 | # Termina Home Assistant se in esecuzione 8 | if pgrep hass; then pkill hass; fi 9 | 10 | # Elimina i file nella cartella 'config' (escluso il file 'configuration.yaml') 11 | find config -mindepth 1 ! -name 'configuration.yaml' -exec rm -rf {} + 12 | echo "Configuration cleared." 13 | -------------------------------------------------------------------------------- /.devcontainer/scripts/dev-branch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../.." 6 | 7 | uv pip install --system --prefix "/home/vscode/.local/" --upgrade git+https://github.com/home-assistant/home-assistant.git@dev 8 | -------------------------------------------------------------------------------- /.devcontainer/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 | if ! grep -R "^logger:" config/configuration.yaml >> /dev/null;then 13 | echo -n " 14 | logger: 15 | default: info 16 | logs: 17 | homeassistant.components.viva: debug 18 | " >> config/configuration.yaml 19 | fi 20 | if ! grep -R "debugpy:" config/configuration.yaml >> /dev/null;then 21 | echo " 22 | # Uncomment the line below if you want to use debugger 23 | # debugpy: 24 | " >> config/configuration.yaml 25 | fi 26 | 27 | 28 | # Set the path to custom_components 29 | ## This let's us have the structure we want /custom_components/ake_dev 30 | ## while at the same time have Home Assistant configuration inside /config 31 | ## without resulting to symlinks. 32 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 33 | 34 | # Start Home Assistant 35 | hass --config "${PWD}/config" --debug 36 | -------------------------------------------------------------------------------- /.devcontainer/scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../.." 6 | 7 | #ruff format . 8 | ruff check . --fix 9 | -------------------------------------------------------------------------------- /.devcontainer/scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../.." 6 | 7 | # Install libpcap and libturbojpeg to avoid errors in Home Assistant 8 | sudo apt-get update 9 | sudo apt-get install -y libpcap-dev libturbojpeg0 10 | 11 | # Install uv 12 | python3 -m pip install uv --user --disable-pip-version-check 13 | 14 | # Install Home Assistant dependencies 15 | uv pip install --system --prefix "/home/vscode/.local/" --requirement requirements_ha.txt 16 | 17 | # Install custom_component dependencies 18 | uv pip install --system --prefix "/home/vscode/.local/" --requirement requirements.txt 19 | 20 | # Set workspace directory as safe in git 21 | git config --global --add safe.directory ${PWD} 22 | #pre-commit install 23 | 24 | # Restart Python Language Server in VSCode to see the correct linting 25 | echo "*********************************************" 26 | echo "* To fix linting in VSCode, press F1 and *" 27 | echo "* choose 'Python: Restart Language Server' *" 28 | echo "*********************************************" 29 | -------------------------------------------------------------------------------- /.devcontainer/scripts/specific-version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../.." 6 | 7 | read -p 'Set Home Assistant version: ' -r version 8 | if [ ! -z "$version" ]; then 9 | uv pip install --system --prefix "/home/vscode/.local/" --upgrade homeassistant=="$version" 10 | else 11 | echo "No version specified, aborting." 12 | fi -------------------------------------------------------------------------------- /.devcontainer/scripts/type-check: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../.." 6 | 7 | echo "Be patient, may take several minutes..." 8 | mypy --cache-dir=.mypy_cache custom_components/pun_sensor -------------------------------------------------------------------------------- /.devcontainer/scripts/upgrade: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/../.." 6 | 7 | if [[ "$1" =~ prerelease|beta ]]; then 8 | # Install the latest beta version of Home Assistant 9 | uv pip install --system --prefix "/home/vscode/.local/" --upgrade --prerelease allow homeassistant 10 | else 11 | # Install the latest stable version of Home Assistant 12 | uv pip install --system --prefix "/home/vscode/.local/" --upgrade allow homeassistant 13 | fi 14 | 15 | # Show the installed version of Home Assistant 16 | echo -e "The installed Home Assistant version is $(hass --version).\n" -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/* @virtualdj 2 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | change-template: '- #$NUMBER $TITLE @$AUTHOR' 4 | sort-direction: ascending 5 | categories: 6 | - title: '🚀 Nuove funzioni' 7 | labels: 8 | - 'feature' 9 | - 'enhancement' 10 | 11 | - title: '🐛 Bug Fixes' 12 | labels: 13 | - 'fix' 14 | - 'bugfix' 15 | - 'bug' 16 | 17 | - title: '🧰 Manutenzione' 18 | labels: 19 | - 'dependencies' 20 | - 'documentation' 21 | - 'maintenance' 22 | 23 | version-resolver: 24 | major: 25 | labels: 26 | - 'major' 27 | minor: 28 | labels: 29 | - 'minor' 30 | patch: 31 | labels: 32 | - 'patch' 33 | default: patch 34 | template: | 35 | [![Downloads di questa release](https://img.shields.io/github/downloads/virtualdj/pun_sensor/v$RESOLVED_VERSION/total.svg)](https://github.com/virtualdj/pun_sensor/releases/v$RESOLVED_VERSION) 36 | ## Cambiamenti 37 | 38 | $CHANGES 39 | 40 | ## ⭐️ Grazie per il contributo a: 41 | $CONTRIBUTORS 42 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | 9 | jobs: 10 | update_release_draft: 11 | name: Update release draft 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Create Release 15 | uses: release-drafter/release-drafter@v6 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | # types: [prereleased,published] 6 | types: [published] 7 | 8 | jobs: 9 | release_zip_file: 10 | name: Prepare release asset 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Get version 17 | id: version 18 | uses: home-assistant/actions/helpers/version@master 19 | 20 | - name: "Update manifest.json" 21 | run: | 22 | python3 ${{ github.workspace }}/.github/workflows/update_manifest.py --version ${{ steps.version.outputs.version }} 23 | 24 | - name: Create zip 25 | run: | 26 | cd custom_components/pun_sensor 27 | zip pun_sensor.zip -r ./ 28 | 29 | - name: Upload zip to release 30 | uses: svenstaro/upload-release-action@2.9.0 31 | with: 32 | repo_token: ${{ secrets.GITHUB_TOKEN }} 33 | file: ./custom_components/pun_sensor/pun_sensor.zip 34 | asset_name: pun_sensor.zip 35 | tag: ${{ github.ref }} 36 | overwrite: true 37 | -------------------------------------------------------------------------------- /.github/workflows/update_manifest.py: -------------------------------------------------------------------------------- 1 | """Update the manifest file.""" 2 | 3 | import sys 4 | import json 5 | import os 6 | 7 | 8 | def update_manifest(): 9 | """Update the manifest file.""" 10 | version = "v0.0.0" 11 | for index, value in enumerate(sys.argv): 12 | if value in ["--version", "-V"]: 13 | version = sys.argv[index + 1] 14 | 15 | # read manifest 16 | with open( 17 | f"{os.getcwd()}/custom_components/pun_sensor/manifest.json" 18 | ) as manifestfile: 19 | manifest = json.load(manifestfile) 20 | 21 | # set version in manifest 22 | manifest["version"] = version 23 | 24 | # read requirements.txt 25 | with open(f"{os.getcwd()}/requirements.txt") as requirementsfile: 26 | requirements = [line.rstrip() for line in requirementsfile] 27 | 28 | # set requirements in manifest 29 | manifest["requirements"] = requirements 30 | 31 | # save manifest 32 | with open( 33 | f"{os.getcwd()}/custom_components/pun_sensor/manifest.json", "w" 34 | ) as manifestfile: 35 | manifestfile.write(json.dumps(manifest, indent=4, sort_keys=False)) 36 | 37 | # print output 38 | print("# generated manifest.json") 39 | for key, value in manifest.items(): 40 | print(f"{key}: {value}") 41 | 42 | 43 | update_manifest() 44 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request_target: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | jobs: 13 | validate-ruff: 14 | name: Validate with ruff 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Check ruff 21 | uses: astral-sh/ruff-action@v3 22 | with: 23 | args: 'format --diff' 24 | 25 | validate-hassfest: 26 | name: Validate with hassfest 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Check out repository 30 | uses: actions/checkout@v4 31 | 32 | - name: "Update manifest.json" 33 | run: | 34 | python3 ${{ github.workspace }}/.github/workflows/update_manifest.py 35 | 36 | - name: Hassfest validation 37 | uses: home-assistant/actions/hassfest@master 38 | 39 | validate-hacs: 40 | name: Validate with HACS 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Check out repository 44 | uses: actions/checkout@v4 45 | 46 | - name: "Update manifest.json" 47 | run: | 48 | python3 ${{ github.workspace }}/.github/workflows/update_manifest.py 49 | 50 | - name: HACS Validation 51 | uses: hacs/action@main 52 | with: 53 | category: integration 54 | 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Home Assistant Config 132 | /config/* 133 | !/config/configuration.yaml 134 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.4.1 4 | hooks: 5 | - id: ruff 6 | args: 7 | - --fix 8 | - id: ruff-format 9 | files: ^((homeassistant|custom_components|pylint|script|tests)/.+)?[^/]+\.py$ 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v4.5.0 12 | hooks: 13 | - id: trailing-whitespace 14 | - id: end-of-file-fixer 15 | - id: check-json 16 | exclude: (.vscode|.devcontainer) 17 | - id: pretty-format-json 18 | args: ['--autofix', '--no-ensure-ascii', '--top-keys=domain,name'] 19 | files: manifest.json 20 | - id: pretty-format-json 21 | args: ['--autofix', '--no-ensure-ascii', '--top-keys=name'] 22 | files: hacs.json 23 | - id: pretty-format-json 24 | args: ['--autofix', '--no-ensure-ascii', '--no-sort-keys'] 25 | files: (/strings\.json$|translations/.+\.json$) 26 | - id: check-yaml 27 | args: ['--unsafe'] 28 | - id: check-added-large-files 29 | - id: check-shebang-scripts-are-executable 30 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "esbenp.prettier-vscode", 5 | "ms-python.python" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "Home Assistant", 7 | "type": "debugpy", 8 | "request": "launch", 9 | "module": "homeassistant", 10 | "justMyCode": false, 11 | "args": [ 12 | "--debug", 13 | "-c", 14 | "config" 15 | ], 16 | }, 17 | /* 18 | { 19 | // Example of attaching to local debug server 20 | "name": "Python: Attach Local", 21 | "type": "python", 22 | "request": "attach", 23 | "port": 5678, 24 | "host": "localhost", 25 | "pathMappings": [ 26 | { 27 | "localRoot": "${workspaceFolder}", 28 | "remoteRoot": "." 29 | } 30 | ] 31 | }, 32 | { 33 | // Example of attaching to my production server 34 | "name": "Python: Attach Remote", 35 | "type": "python", 36 | "request": "attach", 37 | "port": 5678, 38 | "host": "homeassistant.local", 39 | "pathMappings": [ 40 | { 41 | "localRoot": "${workspaceFolder}", 42 | "remoteRoot": "/usr/src/homeassistant" 43 | } 44 | ] 45 | } 46 | */ 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/settings.default.json: -------------------------------------------------------------------------------- 1 | { 2 | // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json 3 | // Added --no-cov to work around TypeError: message must be set 4 | // https://github.com/microsoft/vscode-python/issues/14067 5 | "python.testing.pytestArgs": ["--no-cov"], 6 | // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings 7 | "python.testing.pytestEnabled": false, 8 | // https://code.visualstudio.com/docs/python/linting#_general-settings 9 | "pylint.importStrategy": "fromEnvironment" 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant Core", 6 | "type": "shell", 7 | "command": ".devcontainer/scripts/develop", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Stop Home Assistant Core", 12 | "type": "shell", 13 | "command": "pkill hass ; exit 0", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Restart Home Assistant Core", 18 | "type": "shell", 19 | "command": "pkill hass ; .devcontainer/scripts/develop", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Show Home Assistant version", 24 | "type": "shell", 25 | "command": "clear ; echo -e \"The installed Home Assistant version is $(hass --version).\\n\"", 26 | "problemMatcher": [] 27 | }, 28 | { 29 | "label": "Clear Home Assistant config", 30 | "type": "shell", 31 | "command": ".devcontainer/scripts/clear-config", 32 | "problemMatcher": [] 33 | }, 34 | { 35 | "label": "Upgrade Home Assistant to latest (stable)", 36 | "type": "shell", 37 | "command": ".devcontainer/scripts/upgrade", 38 | "problemMatcher": [] 39 | }, 40 | { 41 | "label": "Upgrade Home Assistant to latest (beta)", 42 | "type": "shell", 43 | "command": ".devcontainer/scripts/upgrade --prerelease", 44 | "problemMatcher": [] 45 | }, 46 | { 47 | "label": "Load Home Assistant from github - dev branch", 48 | "type": "shell", 49 | "command": ".devcontainer/scripts/dev-branch", 50 | "problemMatcher": [] 51 | }, 52 | { 53 | "label": "Load specific version of Home Assistant", 54 | "type": "shell", 55 | "command": ".devcontainer/scripts/specific-version", 56 | "problemMatcher": [] 57 | }, 58 | { 59 | "label": "Lint with ruff", 60 | "type": "shell", 61 | "command": ".devcontainer/scripts/lint", 62 | "problemMatcher": [] 63 | }, 64 | { 65 | "label": "Type-check with mypy", 66 | "type": "shell", 67 | "command": ".devcontainer/scripts/type-check", 68 | "problemMatcher": [] 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Note di sviluppo 2 | 3 | Questa è la mia prima esperienza con le integrazioni di Home Assistant e, in generale, con Python. Purtroppo, mio malgrado, ho scoperto che la **documentazione di Home Assistant** per quanto riguarda la creazione di nuove integrazioni è **scarsa e incompleta**. 4 | 5 | Nella prima versione (commit [d239dae](https://github.com/virtualdj/pun_sensor/commit/d239dae713ae2d06e0e80f8625eab84dc3bb4e02)) ho provato ad effettuare un polling ogni 10 secondi sia per verificare se è sopraggiunto l'orario di aggiornamento dei prezzi che per calcolare la fascia oraria corrente. Ma, specie per il calcolo della fascia, non era il metodo corretto perché non è detto che l'aggiornamento avvenisse al secondo 0 della nuova fascia. 6 | Così, cercando altri sorgenti in giro su GitHub, ho scoperto che esiste una funzione in Home Assistant chiamata `async_track_point_in_time` che consente di schedulare l'esecuzione di una routine in un determinato istante nel tempo, che viene rispettato perfettamente. La versione successiva è stata quindi riscritta utilizzando questo metodo (più efficiente). 7 | 8 | Ovviamente non ho alcuna certezza che tutto questo sia la maniera giusta di procedere, ma funziona! Per chi di interesse, questi sono i progetti da cui ho tratto del codice interessante da utilizzare per il mio: 9 | 10 | - [zaubererty/homeassistant-mvpv](https://github.com/zaubererty/homeassistant-mvpv/blob/d124543a36ab90b94b85a2211f41fee5943239ac/custom_components/mypv/coordinator.py) 11 | - [Gradecak/spaarnelanden-containers](https://github.com/Gradecak/spaarnelanden-containers/blob/39db00072bdd4f99d1cf543fba314d161147259c/custom_components/spaarnelanden/sensor.py) 12 | - [nintendo_wishlist](https://github.com/custom-components/sensor.nintendo_wishlist/tree/main/custom_components/nintendo_wishlist) 13 | - [saso5/homeassistant-mojelektro](https://github.com/saso5/homeassistant-mojelektro/tree/d747e74a842be5697494da6403a1055fcb4322bf/custom_components/mojelektro) 14 | - [YodaDaCoda/hass-solarman-modbus](https://github.com/YodaDaCoda/hass-solarman-modbus/blob/36ebd2d7eef7834867805ae01de433e8f8ab2ddb/custom_components/solarman/config_flow.py) 15 | - [bruxy70/Garbage-Collection](https://github.com/bruxy70/Garbage-Collection/blob/ae73818b3b0786ebcf72b16a6f27428e516686e6/custom_components/garbage_collection/sensor.py) 16 | - [dcmeglio/alarmdecoder-hass](https://github.com/dcmeglio/alarmdecoder-hass/blob/a898ae18cc5562b2a5fc3a73511302b6d242fd07/custom_components/alarmdecoder/__init__.py) 17 | - [BenPru/luxtronik](https://github.com/BenPru/luxtronik/blob/a6c5adfe91532237075fe17df63b59120a8b7098/custom_components/luxtronik/sensor.py#L856-L857) e [collse/Home-AssistantConfig](https://github.com/collse/Home-AssistantConfig/blob/e4a1bc6ee3c470619e4169ac903b88f5dad3b6a8/custom_components/elastic/sensor.py#L66) per due esempi di come assegnare un _entity-id_ predeterminato quando si usa anche l'_unique_id_ senza ricevere errori `AttributeError: can't set attribute 'entity_id'` (altra cosa non sufficientemente documentata di HomeAssistant) 18 | - [dlashua/bolted](https://github.com/dlashua/bolted/blob/50065eba8ffb4abe498587cd889aa9ff7873aeb3/custom_components/bolted/entity_manager.py), [pippyn/Home-Assistant-Sensor-Afvalbeheer](https://github.com/pippyn/Home-Assistant-Sensor-Afvalbeheer/blob/master/custom_components/afvalbeheer/sensor.py) e [questo articolo](https://aarongodfrey.dev/programming/restoring-an-entity-in-home-assistant/) per come salvare e ripristinare lo stato di una entità con `RestoreEntity` 19 | - Il componente di Home Assistant [energyzero](https://github.com/home-assistant/core/tree/dev/homeassistant/components/energyzero) per il blocco del `config_flow` già configurato e per esprimere correttamente le unità di misura 20 | - La [PR #99213](https://github.com/home-assistant/core/pull/99213/files) di Home Assistant per il suggerimento di usare `async_call_later` anziché sommare il timedelta all'ora corrente 21 | - La [PR #76793](https://github.com/home-assistant/core/pull/76793/files) di Home Assistant per un esempio di come usare il [cancellation token](https://developers.home-assistant.io/docs/integration_listen_events/#available-event-helpers) restituito da `async_track_point_in_time` 22 | - I commit [1](https://github.com/home-assistant/core/commit/c574d86ddbafd6c18995ad9efb297fda3ce4292c) e [2](https://github.com/home-assistant/core/commit/36e7689d139d0f517bbdd8f8f2c11e18936d27b3) per risolvere il warning nei log `[homeassistant.util.loop] Detected blocking call to import_module inside the event loop by custom integration` comparso con la versione 2024.5.0 di Home Assistant e dovuto alle librerie importate 23 | - La configurazione del _devcontainer_ con script ispirati dalla repository [astrandb/viva](https://github.com/astrandb/viva/tree/main/scripts) e da post sulla [community Home Assistant](https://community.home-assistant.io/t/developing-home-assistant-core-in-a-vscode-devcontainer/235650/36); utili anche [questo](https://www.hacf.fr/dev_tuto_1_environnement/) e [questo](https://svrooij.io/2023/01/18/home-assistant-component/) 24 | - [Questa parte](https://github.com/bdraco/home-assistant/blob/4224234b7abfd1b31f75637b910f4fb89d5b4a0d/homeassistant/components/workday/__init__.py#L27-L31) di codice per effettuare il precaricamento corretto del modulo _holidays_ con `async_add_import_executor_job` senza che desse errori nel lint (si vede bene in [questa commit](https://github.com/virtualdj/pun_sensor/commit/17141bd3dd2914e6dab27c4fbe6a07c2274c29e4))... Ma come si poteva capire? 25 | - La [documentazione](https://developers.home-assistant.io/docs/config_entries_config_flow_handler#config-entry-migration) e [questo esempio](https://github.com/home-assistant/core/blob/2cc54867944d804f7033f0ff3f5e458ec579aabe/homeassistant/components/tuya/__init__.py#L193-L211) per capire come salvare un dato interno (il minuto di esecuzione, nello specifico) nella configurazione di Home Assistant, non mutabile, che richiede l'esecuzione di `async_update_entry` su una copia della stessa 26 | - Ho tentato di modificare i sensori per fare in modo che derivassero da `RestoreSensor` anziché da `RestoreEntity`, come [suggerito dalla documentazione](https://developers.home-assistant.io/docs/core/entity/sensor/?_highlight=monetary#restoring-sensor-states) ufficiale di Home Assistant, tuttavia ho dovuto desistere perché la funzione `self.async_get_last_sensor_data()` può salvare solo 2 dati, cioè `native_value` e `native_unit_of_measurement` (un esempio [qui](https://github.com/jonathan-ek/solis_modbus/blob/b768066e07d92041f8cc57e4dc6d4a67c18334ca/custom_components/solis_modbus/number.py#L251-L253) oppure [qui](https://github.com/ckarrie/ha-netgear-plus/blob/8f0aa265319cc7c4cc7100a060ab16f0858426cd/custom_components/netgear_plus/netgear_entities.py#L110-L112)). Questi però non sono sufficienti per il nostro scopo, perché serve anche sapere se il sensore è disponibile (`self._available`, che al limite si potrebbe rendere implicito con `native_value = None`) ma soprattutto il nome della fascia corrente (`self._friendly_name`) per `PrezzoFasciaPUNSensorEntity`. Quindi, in definitiva, ho lasciato tutto com'era, sfruttando `self.async_get_last_extra_data()` e il dizionario personalizzato fornito da `def extra_restore_state_data(self)` che comunque ripristina `native_value` e non lo `state` come scrive la documentazione. 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 virtualdj 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prezzi PUN del mese 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) 4 | 5 | [![Validate](https://github.com/virtualdj/pun_sensor/actions/workflows/validate.yaml/badge.svg?branch=master)](https://github.com/virtualdj/pun_sensor/actions/workflows/validate.yaml) 6 | [![release](https://img.shields.io/github/v/release/virtualdj/pun_sensor?style=flat-square)](https://github.com/virtualdj/pun_sensor/releases) 7 | 8 | Integrazione per **Home Assistant** (basata inizialmente sullo script [pun-fasce](https://github.com/virtualdj/pun-fasce)) che mostra i prezzi stimati del mese corrente per fasce orarie (F1, F2, F3, mono-oraria e F23\*) nonché la fascia oraria attuale. 9 | 10 | I valori vengono scaricati dal sito [MercatoElettrico.org](https://gme.mercatoelettrico.org/it-it/Home/Esiti/Elettricita/MGP/Esiti/PUN) per l'intero mese e viene calcolata la media per fasce giorno per giorno, in questo modo verso la fine del mese il valore mostrato si avvicina sempre di più al prezzo reale del PUN in bolletta (per i contratti a prezzo variabile). 11 | 12 | Oltre a questo, sono stati inseriti un sensore con il prezzo del PUN orario e uno con il prezzo zonale orario di un'area geografica selezionata in fase di configurazione. 13 | 14 | ## Installazione in Home Assistant 15 | 16 | Installare usando [HACS](https://hacs.xyz/) tramite il menu con i tre puntini nell'angolo in alto a destra e scegliendo _Add custom repository_ e aggiungendo l'URL https://github.com/virtualdj/pun_sensor alla lista. 17 | 18 | Installare **manualmente** clonando o copiando questa repository e poi copiando la cartella `custom_components/pun_sensor` nella cartella `/custom_components/pun_sensor` di Home Assistant, che andrà successivamente riavviato. 19 | 20 | ### Configurazione 21 | 22 | Dopo l'aggiunta dell'integrazione oppure cliccando il pulsante _Configurazione_ nelle impostazioni di Home Assistant, verrà visualizzata questa finestra: 23 | 24 | ![Screenshot impostazioni](screenshots_settings.png "Impostazioni") 25 | 26 | La prima casella a discesa permette di selezionare la _zona geografica_ di riferimento per i prezzi zonali. 27 | 28 | Tramite lo slider invece è possibile selezionare un'_ora del giorno_ in cui scaricare i prezzi aggiornati dell'energia (default: 1); il minuto di esecuzione, invece, è determinato automaticamente per evitare di gravare eccessivamente sulle API del sito (e mantenuto fisso, finché l'ora non viene modificata). Se per qualche ragione il sito non fosse raggiungibile, verranno effettuati altri tentativi dopo 10, 60, 120 e 180 minuti. 29 | 30 | Nel caso si fosse interessati ai prezzi zonali, selezionare un'orario uguale o superiore a 15, così da essere sicuri che il GME abbia pubblicato i dati anche del giorno successivo (accessibili tramite gli [attributi dello stesso sensore](#prezzo-zonale)). 31 | 32 | Se la casella di controllo _Usa solo dati reali ad inizio mese_ è **attivata**, all'inizio del mese quando non ci sono i prezzi per tutte le fasce orarie questi vengono disabilitati (non viene mostrato quindi un prezzo in €/kWh finché i dati non sono in numero sufficiente); nel caso invece la casella fosse **disattivata** (default) nel conteggio vengono inclusi gli ultimi giorni del mese precedente in modo da avere sempre un valore in €/kWh. 33 | 34 | ### Aggiornamento manuale 35 | 36 | È possibile forzare un **aggiornamento manuale** richiamando il servizio _Home Assistant Core Integration: Aggiorna entità_ (`homeassistant.update_entity`) e passando come destinazione una qualsiasi entità tra quelle fornite da questa integrazione: questo causerà chiaramente un nuovo download immediato dei dati. 37 | 38 | ### Aspetto dei dati 39 | 40 | ![Screenshot integrazione](screenshots_main.png "Dati visualizzati") 41 | 42 | L'integrazione fornisce il nome della fascia corrente relativa all'orario di Home Assistant (tra F1 / F2 / F3), i prezzi delle tre fasce F1 / F2 / F3 più la fascia mono-oraria, la [fascia F23](#fascia-f23-)\* e il prezzo della fascia corrente. Questi sono i dati intesi come mensili, da paragonare a quelli in bolletta una volta aggiunti costi fissi e tasse (vedere [_prezzo al dettaglio_](#prezzo-al-dettaglio)). 43 | 44 | Poi ci sono i due sensori con i prezzi orari (con il simbolo dell'orologio nell'icona), ad esempio utilizzabili per calcoli con impianti fotovoltaici: [PUN orario](#pun-orario) e [prezzo zonale](#prezzo-zonale). 45 | 46 | ### Prezzo al dettaglio 47 | 48 | Questo componente fornisce informazioni sul prezzo all'**ingrosso** dell'energia elettrica: per calcolare il prezzo al dettaglio, è necessario creare un sensore fittizio (o _template sensor_), basato sui dati specifici del proprio contratto con il fornitore finale aggiungendo tasse e costi fissi. 49 | 50 | Di seguito un esempio di un sensore configurato manualmente modificando il file `configuration.yaml` di Home Assistant: 51 | 52 | ```yml 53 | # Template sensors section 54 | template: 55 | - sensor: 56 | - unique_id: prezzo_attuale_energia_al_dettaglio 57 | name: "Prezzo attuale energia al dettaglio" 58 | icon: mdi:currency-eur 59 | unit_of_measurement: "€/kWh" 60 | state: > 61 | {{ (1.1 * (states('sensor.pun_prezzo_fascia_corrente')|float(0) + 0.0087 + 0.04 + 0.0227))|round(3) }} 62 | ``` 63 | 64 | ### Fascia F23 (\*) 65 | 66 | A partire dalla versione v0.5.0, è stato aggiunto il sensore relativo al calcolo della fascia F23, cioè quella contrapposta alla F1 nella bioraria. Il calcolo non è documentato molto nei vari siti (si veda [QUI](https://github.com/virtualdj/pun_sensor/issues/24#issuecomment-1806864251)) e non è affatto la media dei prezzi in F2 e F3 come si potrebbe pensare: c'è invece una percentuale fissa, [come ha scoperto _virtualj_](https://github.com/virtualdj/pun_sensor/issues/24#issuecomment-1829846806). 67 | Pertanto, seppur questo metodo non sia ufficiale, è stato implementato perché i risultati corrispondono sempre alle tabelle pubblicate online. 68 | 69 | ### Prezzo zonale 70 | 71 | Oltre al prezzo zonale corrente, negli **attributi** del sensore vengono memorizzati i prezzi scaricati per la giornata di **oggi** (prefisso: `oggi_h_`) e **domani** (prefisso: `domani_h_`), con l'ora a 2 cifre. 72 | Di seguito un esempio di come visualizzarli e/o utilizzarli in un template. 73 | 74 | ```jinja 75 | Prezzo zonale 76 | {% for h in range(24) -%} 77 | {%- set prezzo = state_attr("sensor.pun_prezzo_zonale", "oggi_h_" + "%02d" % h) -%} 78 | {%- if prezzo is not none -%} 79 | Oggi, ore {{ "%02d" % h }} = {{ "%.6f" % prezzo }} €/kWh 80 | {%- else -%} 81 | Oggi, ore {{ "%02d" % h }} = n.d. 82 | {%- endif %} 83 | {% endfor %} 84 | ``` 85 | 86 | I dati sono visibili anche in _Home Assistant > Strumenti per sviluppatori > Stati_ filtrando `sensor.pun_prezzo_zonale` come entità e attivando la casella di controllo _Attributi_. 87 | 88 | ### PUN orario 89 | 90 | In maniera simile al prezzo zonale, anche il valore del PUN orario (nome sensore: `sensor.pun_orario`) ha gli attributi con i prezzi di oggi e domani, se disponibili. 91 | 92 | ### In caso di problemi 93 | 94 | È possibile abilitare la registrazione dei log tramite l'interfaccia grafica in **Impostazioni > Dispositivi e servizi > Prezzi PUN del mese** e cliccando sul pulsante **Abilita la registrazione di debug**. 95 | 96 | ![Abilitazione log di debug](screenshot_debug_1.png "Abilitazione log di debug") 97 | 98 | Il tasto verrà modificato come nell'immagine qui sotto: 99 | 100 | ![Estrazione log di debug](screenshot_debug_2.png "Estrazione log di debug") 101 | 102 | Dopo che si verifica il problema, premerlo nuovamente: in questo modo verrà scaricato un file di log con le informazioni da allegare alle [Issue](https://github.com/virtualdj/pun_sensor/issues). 103 | 104 | ## Note di sviluppo 105 | 106 | Ho lasciato un diario dell'esperienza di programmazione di questa integrazione in [questa pagina](DEVELOPMENT.md). Potrete trovare qualche lamentela, ma soprattutto link alle pagine dei progetti che mi hanno aiutato a svilupparla così com'è ora. 107 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Example configuration.yaml entry 2 | # Do not use default_config 3 | # default_config: 4 | # But enable the single components instead: 5 | backup: 6 | config: 7 | energy: 8 | history: 9 | homeassistant_alerts: 10 | logbook: 11 | my: 12 | sun: 13 | webhook: 14 | 15 | logger: 16 | default: info 17 | logs: 18 | homeassistant.setup: warning 19 | custom_components.pun_sensor: debug 20 | 21 | # Disable ffmpeg 22 | ffmpeg: 23 | ffmpeg_bin: /usr/bin/true 24 | 25 | # Run with debugpy and wait for debugger to connect 26 | # debugpy: 27 | # start: true 28 | # wait: true 29 | 30 | # Enable this part if you want to use CodeSpaces 31 | http: 32 | use_x_forwarded_for: true 33 | trusted_proxies: 34 | - ::1 35 | - 127.0.0.1 36 | ip_ban_enabled: true 37 | login_attempts_threshold: 3 38 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/__init__.py: -------------------------------------------------------------------------------- 1 | """Prezzi PUN del mese.""" 2 | 3 | from datetime import timedelta 4 | import logging 5 | 6 | from awesomeversion.awesomeversion import AwesomeVersion 7 | from holidays import country_holidays 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import __version__ as HA_VERSION 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.event import async_call_later, async_track_point_in_time 13 | import homeassistant.util.dt as dt_util 14 | 15 | from .const import ( 16 | CONF_ACTUAL_DATA_ONLY, 17 | CONF_SCAN_HOUR, 18 | CONF_ZONA, 19 | DOMAIN, 20 | WEB_RETRIES_MINUTES, 21 | ) 22 | from .coordinator import PUNDataUpdateCoordinator 23 | from .interfaces import DEFAULT_ZONA, Zona 24 | 25 | if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2024.5.0"): 26 | from homeassistant.setup import SetupPhases, async_pause_setup 27 | 28 | # Ottiene il logger 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | # Definisce i tipi di entità 32 | PLATFORMS: list[str] = ["sensor"] 33 | 34 | 35 | async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: 36 | """Impostazione dell'integrazione da configurazione Home Assistant.""" 37 | 38 | # Carica le dipendenze di holidays in background per evitare errori nel log 39 | if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2024.5.0"): 40 | with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): 41 | await hass.async_add_import_executor_job(country_holidays, "IT") 42 | 43 | # Salva il coordinator nella configurazione 44 | coordinator = PUNDataUpdateCoordinator(hass, config) 45 | hass.data.setdefault(DOMAIN, {})[config.entry_id] = coordinator 46 | 47 | # Aggiorna immediatamente la fascia oraria corrente 48 | await coordinator.update_fascia() 49 | 50 | # Aggiorna immediatamente il prezzo zonale corrente 51 | await coordinator.update_prezzo_zonale() 52 | 53 | # Crea i sensori con la configurazione specificata 54 | await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) 55 | 56 | # Schedula l'aggiornamento via web 10 secondi dopo l'avvio 57 | coordinator.schedule_token = async_call_later( 58 | hass, timedelta(seconds=10), coordinator.update_pun 59 | ) 60 | 61 | # Registra il callback di modifica opzioni 62 | config.async_on_unload(config.add_update_listener(update_listener)) 63 | return True 64 | 65 | 66 | async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: 67 | """Rimozione dell'integrazione da Home Assistant.""" 68 | 69 | # Scarica i sensori (disabilitando di conseguenza il coordinator) 70 | unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS) 71 | if unload_ok: 72 | hass.data[DOMAIN].pop(config.entry_id) 73 | 74 | return unload_ok 75 | 76 | 77 | async def update_listener(hass: HomeAssistant, config: ConfigEntry) -> None: 78 | """Modificate le opzioni da Home Assistant.""" 79 | 80 | # Recupera il coordinator 81 | coordinator = hass.data[DOMAIN][config.entry_id] 82 | 83 | # Aggiorna le impostazioni del coordinator dalle opzioni 84 | if (CONF_SCAN_HOUR in config.options) and ( 85 | config.options[CONF_SCAN_HOUR] != coordinator.scan_hour 86 | ): 87 | # Modificata l'ora di scansione nelle opzioni 88 | coordinator.scan_hour = config.options[CONF_SCAN_HOUR] 89 | 90 | # Rigenera il minuto di esecuzione 91 | coordinator.update_scan_minutes_from_config( 92 | hass=hass, config=config, new_minute=True 93 | ) 94 | 95 | # Calcola la data della prossima esecuzione (all'ora definita) 96 | now = dt_util.now() 97 | next_update_pun = now.replace( 98 | hour=coordinator.scan_hour, 99 | minute=coordinator.scan_minute, 100 | second=0, 101 | microsecond=0, 102 | ) 103 | if next_update_pun <= now: 104 | # Se l'evento è già trascorso, passa a domani alla stessa ora 105 | next_update_pun = next_update_pun + timedelta(days=1) 106 | 107 | # Annulla eventuali schedulazioni attive 108 | if coordinator.schedule_token is not None: 109 | coordinator.schedule_token() 110 | coordinator.schedule_token = None 111 | 112 | # Schedula la prossima esecuzione 113 | coordinator.web_retries = WEB_RETRIES_MINUTES 114 | coordinator.schedule_token = async_track_point_in_time( 115 | coordinator.hass, coordinator.update_pun, next_update_pun 116 | ) 117 | _LOGGER.debug( 118 | "Prossimo aggiornamento web: %s", 119 | next_update_pun.strftime("%d/%m/%Y %H:%M:%S %z"), 120 | ) 121 | 122 | if (CONF_ACTUAL_DATA_ONLY in config.options) and ( 123 | config.options[CONF_ACTUAL_DATA_ONLY] != coordinator.actual_data_only 124 | ): 125 | # Modificata impostazione 'Usa dati reali' 126 | coordinator.actual_data_only = config.options[CONF_ACTUAL_DATA_ONLY] 127 | _LOGGER.debug( 128 | "Nuovo valore 'usa dati reali': %s.", coordinator.actual_data_only 129 | ) 130 | 131 | # Annulla eventuali schedulazioni attive 132 | if coordinator.schedule_token is not None: 133 | coordinator.schedule_token() 134 | coordinator.schedule_token = None 135 | 136 | # Esegue un nuovo aggiornamento immediatamente 137 | coordinator.web_retries = WEB_RETRIES_MINUTES 138 | coordinator.schedule_token = async_call_later( 139 | coordinator.hass, timedelta(seconds=5), coordinator.update_pun 140 | ) 141 | 142 | if (CONF_ZONA in config.options) and ( 143 | (coordinator.pun_data.zona is None) 144 | or (config.options[CONF_ZONA] != coordinator.pun_data.zona.name) 145 | ): 146 | # Modificata la zona di riferimento, cerca l'enum 147 | try: 148 | new_zona = Zona[config.options[CONF_ZONA]] 149 | 150 | except KeyError: 151 | # La zona non esiste 152 | _LOGGER.error( 153 | "La zona specificata '%s' non esiste. Reimpostata la precedente.", 154 | config.options[CONF_ZONA], 155 | ) 156 | new_zona = coordinator.pun_data.zona 157 | 158 | # Controlla se l'operazione ha avuto successo 159 | if new_zona != coordinator.pun_data.zona: 160 | # Modifica la zona geografica 161 | coordinator.pun_data.zona = new_zona 162 | _LOGGER.debug( 163 | "Modificata la zona geografica in: %s.", coordinator.pun_data.zona.value 164 | ) 165 | 166 | # Annulla eventuali schedulazioni attive 167 | if coordinator.schedule_token is not None: 168 | coordinator.schedule_token() 169 | coordinator.schedule_token = None 170 | 171 | # Esegue un nuovo aggiornamento immediatamente 172 | coordinator.web_retries = WEB_RETRIES_MINUTES 173 | coordinator.schedule_token = async_call_later( 174 | coordinator.hass, timedelta(seconds=5), coordinator.update_pun 175 | ) 176 | 177 | 178 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 179 | """Migra una vecchia configurazione alla nuova versione.""" 180 | _LOGGER.debug( 181 | "Migrazione configurazione da versione %s.", 182 | config_entry.version, 183 | ) 184 | if config_entry.version == 1: 185 | # Migrazione da versione 1 -> 2 186 | # Implementata zona per prezzi zonali 187 | new_data = {**config_entry.data} 188 | new_data[CONF_ZONA] = DEFAULT_ZONA.name 189 | 190 | if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2024.3.0"): 191 | hass.config_entries.async_update_entry( 192 | config_entry, data=new_data, version=2 193 | ) 194 | else: 195 | # Le release precedenti ad HA 2024.3.0 non supportano la versione 196 | hass.config_entries.async_update_entry(config_entry, data=new_data) 197 | 198 | # Migrazione completata 199 | _LOGGER.debug( 200 | "Migrazione configurazione alla versione %s completata con successo.", 201 | config_entry.version, 202 | ) 203 | return True 204 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/config_flow.py: -------------------------------------------------------------------------------- 1 | """UI di configurazione per pun_sensor.""" 2 | 3 | from awesomeversion.awesomeversion import AwesomeVersion 4 | import voluptuous as vol 5 | 6 | from homeassistant import config_entries 7 | from homeassistant.const import __version__ as HA_VERSION 8 | from homeassistant.core import callback 9 | from homeassistant.data_entry_flow import FlowResult 10 | from homeassistant.helpers import selector 11 | import homeassistant.helpers.config_validation as cv 12 | 13 | from .const import CONF_ACTUAL_DATA_ONLY, CONF_SCAN_HOUR, CONF_ZONA, DOMAIN 14 | from .interfaces import DEFAULT_ZONA, Zona 15 | 16 | # Configurazione del selettore compatibile con HA 2023.4.0 17 | selector_config = selector.SelectSelectorConfig( 18 | options=[ 19 | selector.SelectOptionDict(value=zona.name, label=zona.value) for zona in Zona 20 | ], 21 | mode=selector.SelectSelectorMode.DROPDOWN, 22 | translation_key="zona", 23 | ) 24 | if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2023.9.0"): 25 | selector_config["sort"] = True 26 | 27 | 28 | class PUNOptionsFlow(config_entries.OptionsFlow): 29 | """Opzioni per prezzi PUN (= riconfigurazione successiva).""" 30 | 31 | def __init__(self, entry: config_entries.ConfigEntry) -> None: 32 | """Inizializzazione opzioni.""" 33 | if AwesomeVersion(HA_VERSION) < AwesomeVersion("2024.12.0b0"): 34 | self.config_entry = entry 35 | 36 | async def async_step_init(self, user_input=None) -> FlowResult: 37 | """Gestisce le opzioni di configurazione.""" 38 | errors: dict[str, str] | None = {} 39 | if user_input is not None: 40 | # Configurazione valida (validazione integrata nello schema) 41 | return self.async_create_entry(title="PUN", data=user_input) 42 | 43 | # Schema dati di opzione (con default sui valori attuali) 44 | data_schema = { 45 | vol.Required( 46 | CONF_ZONA, 47 | default=self.config_entry.options.get( 48 | CONF_ZONA, self.config_entry.data[CONF_ZONA] 49 | ), 50 | ): selector.SelectSelector(selector_config), 51 | vol.Required( 52 | CONF_SCAN_HOUR, 53 | default=self.config_entry.options.get( 54 | CONF_SCAN_HOUR, self.config_entry.data[CONF_SCAN_HOUR] 55 | ), 56 | ): vol.All(cv.positive_int, vol.Range(min=0, max=23)), 57 | vol.Optional( 58 | CONF_ACTUAL_DATA_ONLY, 59 | default=self.config_entry.options.get( 60 | CONF_ACTUAL_DATA_ONLY, self.config_entry.data[CONF_ACTUAL_DATA_ONLY] 61 | ), 62 | ): cv.boolean, 63 | } 64 | 65 | # Mostra la schermata di configurazione, con gli eventuali errori 66 | return self.async_show_form( 67 | step_id="init", data_schema=vol.Schema(data_schema), errors=errors 68 | ) 69 | 70 | 71 | class PUNConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 72 | """Configurazione per prezzi PUN (= prima configurazione).""" 73 | 74 | # Versione della configurazione 75 | VERSION = 2 76 | 77 | @staticmethod 78 | @callback 79 | def async_get_options_flow( 80 | config_entry: config_entries.ConfigEntry, 81 | ) -> PUNOptionsFlow: 82 | """Ottiene le opzioni per questa configurazione.""" 83 | return PUNOptionsFlow(config_entry) 84 | 85 | async def async_step_user(self, user_input=None): 86 | """Gestione prima configurazione da Home Assistant.""" 87 | # Controlla che l'integrazione non venga eseguita più volte 88 | await self.async_set_unique_id(DOMAIN) 89 | self._abort_if_unique_id_configured() 90 | 91 | errors = {} 92 | if user_input is not None: 93 | # Configurazione valida (validazione integrata nello schema) 94 | return self.async_create_entry(title="PUN", data=user_input) 95 | 96 | # Schema dati di configurazione (con default fissi) 97 | data_schema = { 98 | vol.Required(CONF_ZONA, default=DEFAULT_ZONA.name): selector.SelectSelector( 99 | selector_config 100 | ), 101 | vol.Required(CONF_SCAN_HOUR, default=1): vol.All( 102 | cv.positive_int, vol.Range(min=0, max=23) 103 | ), 104 | vol.Optional(CONF_ACTUAL_DATA_ONLY, default=False): cv.boolean, 105 | } 106 | 107 | # Mostra la schermata di configurazione, con gli eventuali errori 108 | return self.async_show_form( 109 | step_id="user", data_schema=vol.Schema(data_schema), errors=errors 110 | ) 111 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/const.py: -------------------------------------------------------------------------------- 1 | """Costanti utilizzate da pun_sensor.""" 2 | 3 | # Dominio HomeAssistant 4 | DOMAIN = "pun_sensor" 5 | 6 | # Tipi di sensore da creare 7 | PUN_FASCIA_MONO = 0 8 | PUN_FASCIA_F1 = 1 9 | PUN_FASCIA_F2 = 2 10 | PUN_FASCIA_F3 = 3 11 | PUN_FASCIA_F23 = 4 12 | 13 | # Intervalli di tempo per i tentativi 14 | WEB_RETRIES_MINUTES = [1, 10, 60, 120, 180] 15 | 16 | # Tipi di aggiornamento 17 | COORD_EVENT = "coordinator_event" 18 | EVENT_UPDATE_FASCIA = "event_update_fascia" 19 | EVENT_UPDATE_PUN = "event_update_pun" 20 | EVENT_UPDATE_PREZZO_ZONALE = "event_update_prezzo_zonale" 21 | 22 | # Parametri configurabili da configuration.yaml 23 | CONF_SCAN_HOUR = "scan_hour" 24 | CONF_ACTUAL_DATA_ONLY = "actual_data_only" 25 | CONF_ZONA = "zona" 26 | 27 | # Parametri interni 28 | CONF_SCAN_MINUTE = "scan_minute" 29 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/coordinator.py: -------------------------------------------------------------------------------- 1 | """Coordinator per pun_sensor.""" 2 | 3 | from datetime import date, datetime, timedelta 4 | import io 5 | import logging 6 | import random 7 | from statistics import mean 8 | import zipfile 9 | from zoneinfo import ZoneInfo 10 | 11 | from aiohttp import ClientSession, ServerConnectionError 12 | 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.core import HomeAssistant, callback 15 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 16 | from homeassistant.helpers.event import async_call_later, async_track_point_in_time 17 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 18 | import homeassistant.util.dt as dt_util 19 | 20 | from .const import ( 21 | CONF_ACTUAL_DATA_ONLY, 22 | CONF_SCAN_HOUR, 23 | CONF_SCAN_MINUTE, 24 | CONF_ZONA, 25 | COORD_EVENT, 26 | DOMAIN, 27 | EVENT_UPDATE_FASCIA, 28 | EVENT_UPDATE_PREZZO_ZONALE, 29 | EVENT_UPDATE_PUN, 30 | WEB_RETRIES_MINUTES, 31 | ) 32 | from .interfaces import DEFAULT_ZONA, Fascia, PunData, PunValues, Zona 33 | from .utils import extract_xml, get_fascia, get_hour_datetime, get_next_date 34 | 35 | # Ottiene il logger 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | # Usa sempre il fuso orario italiano (i dati del sito sono per il mercato italiano) 39 | tz_pun = ZoneInfo("Europe/Rome") 40 | 41 | 42 | class PUNDataUpdateCoordinator(DataUpdateCoordinator): 43 | """Classe coordinator di aggiornamento dati.""" 44 | 45 | session: ClientSession 46 | 47 | def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: 48 | """Gestione dell'aggiornamento da Home Assistant.""" 49 | super().__init__( 50 | hass, 51 | _LOGGER, 52 | # Nome dei dati (a fini di log) 53 | name=DOMAIN, 54 | # Nessun update_interval (aggiornamento automatico disattivato) 55 | ) 56 | 57 | # Salva la sessione client e la configurazione 58 | self.session = async_get_clientsession(hass) 59 | 60 | # Inizializza i valori di configurazione (dalle opzioni o dalla configurazione iniziale) 61 | self.actual_data_only = config.options.get( 62 | CONF_ACTUAL_DATA_ONLY, config.data.get(CONF_ACTUAL_DATA_ONLY, False) 63 | ) 64 | self.scan_hour = config.options.get( 65 | CONF_SCAN_HOUR, config.data.get(CONF_SCAN_HOUR, 1) 66 | ) 67 | 68 | # Inizializza i dati PUN e la zona geografica 69 | self.pun_data: PunData = PunData() 70 | try: 71 | # Estrae il valore dalla configurazione come stringa 72 | zona_string = config.options.get( 73 | CONF_ZONA, config.data.get(CONF_ZONA, DEFAULT_ZONA) 74 | ) 75 | 76 | # Tenta di associare la stringa all'enum 77 | # (per verificare che sia corretta) 78 | self.pun_data.zona = Zona[zona_string] 79 | 80 | except KeyError: 81 | # La zona non è valida 82 | _LOGGER.error( 83 | "La zona specificata '%s' non esiste. Reimpostata zona di default '%s'.", 84 | zona_string, 85 | DEFAULT_ZONA.value, 86 | ) 87 | 88 | # Aggiorna la configurazione salvata con la zona di default 89 | # (per la prossima esecuzione) 90 | self.pun_data.zona = DEFAULT_ZONA 91 | 92 | @callback 93 | async def async_restore_default_zona() -> None: 94 | """Mostra una notifica di errore all'utente e reimposta la zona di default.""" 95 | 96 | # Mostra il messaggio all'utente che così può attivarsi 97 | await hass.services.async_call( 98 | "persistent_notification", 99 | "create", 100 | { 101 | "title": "Errore integrazione PUN", 102 | "message": f"La zona geografica specificata '{zona_string}' non esiste più.\nÈ stata reimpostata la zona di default '{DEFAULT_ZONA.value}'.", 103 | }, 104 | ) 105 | 106 | # Reimposta sia la configurazione che le opzioni 107 | new_data = { 108 | **config.data, 109 | CONF_ZONA: DEFAULT_ZONA.name, 110 | } 111 | new_options = { 112 | **config.options, 113 | CONF_ZONA: DEFAULT_ZONA.name, 114 | } 115 | hass.config_entries.async_update_entry( 116 | config, data=new_data, options=new_options 117 | ) 118 | 119 | # Accoda l'esecuzione 120 | hass.add_job(async_restore_default_zona) 121 | 122 | # Carica il minuto di esecuzione dalla configurazione (o lo crea se non esiste) 123 | self.scan_minute = 0 124 | self.update_scan_minutes_from_config(hass=hass, config=config, new_minute=False) 125 | 126 | # Inizializza i valori di default 127 | self.web_retries = WEB_RETRIES_MINUTES 128 | self.schedule_token = None 129 | self.pun_values: PunValues = PunValues() 130 | self.fascia_corrente: Fascia | None = None 131 | self.fascia_successiva: Fascia | None = None 132 | self.prossimo_cambio_fascia: datetime | None = None 133 | self.termine_prossima_fascia: datetime | None = None 134 | self.orario_prezzo: datetime = get_hour_datetime(dt_util.now(time_zone=tz_pun)) 135 | 136 | _LOGGER.debug( 137 | "Coordinator inizializzato (con 'usa dati reali' = %s).", 138 | self.actual_data_only, 139 | ) 140 | 141 | def clean_tokens(self): 142 | """Annulla eventuali schedulazioni attive.""" 143 | if self.schedule_token is not None: 144 | self.schedule_token() 145 | self.schedule_token = None 146 | 147 | def update_scan_minutes_from_config( 148 | self, hass: HomeAssistant, config: ConfigEntry, new_minute: bool = False 149 | ): 150 | """Imposta il minuto di aggiornamento nell'ora configurata. 151 | 152 | Determina casualmente in quale minuto eseguire l'aggiornamento web 153 | per evitare che le integrazioni di tutti gli utenti richiamino le API nello 154 | stesso momento, a parità di ora. 155 | """ 156 | 157 | # Controlla se estrarre a caso i minuti 158 | if new_minute or (CONF_SCAN_MINUTE not in config.data): 159 | # Genera un minuto casuale e lo inserisce nella configurazione 160 | self.scan_minute = random.randint(0, 59) 161 | new_data = { 162 | **config.data, 163 | CONF_SCAN_MINUTE: self.scan_minute, 164 | } 165 | 166 | @callback 167 | def async_update_entry() -> None: 168 | """Aggiorna la configurazione con i nuovi dati.""" 169 | self.hass.config_entries.async_update_entry(config, data=new_data) 170 | 171 | # Accoda l'esecuzione del salvataggio dell'impostazione 172 | hass.add_job(async_update_entry) 173 | else: 174 | # Carica i minuti dalla configurazione 175 | self.scan_minute = config.data.get(CONF_SCAN_MINUTE, 0) 176 | 177 | async def _async_update_data(self): 178 | """Aggiornamento dati a intervalli prestabiliti.""" 179 | 180 | # Calcola l'intervallo di date per il mese corrente 181 | date_end = dt_util.now().date() 182 | date_start = date(date_end.year, date_end.month, 1) 183 | 184 | # All'inizio del mese, aggiunge i valori del mese precedente 185 | # a meno che CONF_ACTUAL_DATA_ONLY non sia impostato 186 | if (not self.actual_data_only) and (date_end.day < 4): 187 | date_start = date_start - timedelta(days=3) 188 | 189 | # Aggiunge un giorno (domani) per il calcolo del prezzo zonale 190 | date_end += timedelta(days=1) 191 | 192 | # Converte le date in stringa da passare all'API Mercato elettrico 193 | start_date_param = date_start.strftime("%Y%m%d") 194 | end_date_param = date_end.strftime("%Y%m%d") 195 | 196 | # URL del sito Mercato elettrico 197 | download_url = f"https://gme.mercatoelettrico.org/DesktopModules/GmeDownload/API/ExcelDownload/downloadzipfile?DataInizio={start_date_param}&DataFine={end_date_param}&Date={end_date_param}&Mercato=MGP&Settore=Prezzi&FiltroDate=InizioFine" 198 | 199 | # Imposta gli header della richiesta 200 | heads = { 201 | "moduleid": "12103", 202 | "referer": "https://gme.mercatoelettrico.org/en-us/Home/Results/Electricity/MGP/Download?valore=Prezzi", 203 | "sec-ch-ua-mobile": "?0", 204 | "sec-ch-ua-platform": "Windows", 205 | "sec-fetch-dest": "empty", 206 | "sec-fetch-mode": "cors", 207 | "sec-fetch-site": "same-origin", 208 | "sec-gpc": "1", 209 | "tabid": "1749", 210 | "userid": "-1", 211 | } 212 | 213 | # Effettua il download dello ZIP con i file XML 214 | _LOGGER.debug("Inizio download file ZIP con XML.") 215 | async with self.session.get(download_url, headers=heads) as response: 216 | # Aspetta la request 217 | bytes_response = await response.read() 218 | 219 | # Se la richiesta NON e' andata a buon fine ritorna l'errore subito 220 | if response.status != 200: 221 | _LOGGER.error("Richiesta fallita con errore %s", response.status) 222 | raise ServerConnectionError( 223 | f"Richiesta fallita con errore {response.status}" 224 | ) 225 | 226 | # La richiesta e' andata a buon fine, tenta l'estrazione 227 | try: 228 | archive = zipfile.ZipFile(io.BytesIO(bytes_response), "r") 229 | 230 | # Ritorna error se l'output non è uno ZIP, o ha un errore IO 231 | except (zipfile.BadZipfile, OSError) as e: # not a zip: 232 | _LOGGER.error( 233 | "Download fallito con URL: %s, lunghezza %s, risposta %s", 234 | download_url, 235 | response.content_length, 236 | response.status, 237 | ) 238 | raise UpdateFailed("Archivio ZIP scaricato dal sito non valido.") from e 239 | 240 | # Mostra i file nell'archivio 241 | _LOGGER.debug( 242 | "%s file trovati nell'archivio (%s)", 243 | len(archive.namelist()), 244 | ", ".join(str(fn) for fn in archive.namelist()), 245 | ) 246 | 247 | # Estrae i dati dall'archivio 248 | self.pun_data = extract_xml( 249 | archive, self.pun_data, dt_util.now(time_zone=tz_pun).date() 250 | ) 251 | archive.close() 252 | 253 | # Per ogni fascia, calcola il valore del pun 254 | for fascia, value_list in self.pun_data.pun.items(): 255 | # Se abbiamo valori nella fascia 256 | if len(value_list) > 0: 257 | # Calcola la media dei pun e aggiorna il valore del pun attuale 258 | # per la fascia corrispondente 259 | self.pun_values.value[fascia] = mean(self.pun_data.pun[fascia]) 260 | else: 261 | # Skippiamo i dict se vuoti 262 | pass 263 | 264 | # Calcola la fascia F23 (a partire da F2 ed F3) 265 | # NOTA: la motivazione del calcolo è oscura ma sembra corretta; vedere: 266 | # https://github.com/virtualdj/pun_sensor/issues/24#issuecomment-1829846806 267 | if ( 268 | len(self.pun_data.pun[Fascia.F2]) and len(self.pun_data.pun[Fascia.F3]) 269 | ) > 0: 270 | self.pun_values.value[Fascia.F23] = ( 271 | 0.46 * self.pun_values.value[Fascia.F2] 272 | + 0.54 * self.pun_values.value[Fascia.F3] 273 | ) 274 | else: 275 | self.pun_values.value[Fascia.F23] = 0 276 | 277 | # Logga i dati 278 | _LOGGER.debug( 279 | "Numero di dati: %s", 280 | ", ".join( 281 | str(f"{len(dati)} ({fascia.value})") 282 | for fascia, dati in self.pun_data.pun.items() 283 | if fascia != Fascia.F23 284 | ), 285 | ) 286 | _LOGGER.debug( 287 | "Valori PUN: %s", 288 | ", ".join( 289 | f"{prezzo} ({fascia.value})" 290 | for fascia, prezzo in self.pun_values.value.items() 291 | ), 292 | ) 293 | 294 | # Notifica che i dati PUN (prezzi) sono stati aggiornati 295 | self.async_set_updated_data({COORD_EVENT: EVENT_UPDATE_PUN}) 296 | 297 | async def update_fascia(self, now=None): 298 | """Aggiorna la fascia oraria corrente (al cambio fascia).""" 299 | 300 | # Scrive l'ora corrente (a scopi di debug) 301 | _LOGGER.debug( 302 | "Ora corrente sistema: %s", 303 | dt_util.now().strftime("%a %d/%m/%Y %H:%M:%S %z"), 304 | ) 305 | _LOGGER.debug( 306 | "Ora corrente fuso orario italiano: %s", 307 | dt_util.now(time_zone=tz_pun).strftime("%a %d/%m/%Y %H:%M:%S %z"), 308 | ) 309 | 310 | # Ottiene la fascia oraria corrente e il prossimo aggiornamento 311 | self.fascia_corrente, self.prossimo_cambio_fascia = get_fascia( 312 | dt_util.now(time_zone=tz_pun) 313 | ) 314 | 315 | # Calcola la fascia futura ri-applicando lo stesso algoritmo 316 | self.fascia_successiva, self.termine_prossima_fascia = get_fascia( 317 | self.prossimo_cambio_fascia 318 | ) 319 | _LOGGER.info( 320 | "Nuova fascia corrente: %s (prossima: %s alle %s)", 321 | self.fascia_corrente.value, 322 | self.fascia_successiva.value, 323 | self.prossimo_cambio_fascia.strftime("%a %d/%m/%Y %H:%M:%S %z"), 324 | ) 325 | 326 | # Notifica che i dati sono stati aggiornati (fascia) 327 | self.async_set_updated_data({COORD_EVENT: EVENT_UPDATE_FASCIA}) 328 | 329 | # Schedula la prossima esecuzione 330 | async_track_point_in_time( 331 | self.hass, self.update_fascia, self.prossimo_cambio_fascia 332 | ) 333 | 334 | async def update_pun(self, now=None): 335 | """Aggiorna i prezzi PUN da Internet (funziona solo se schedulata).""" 336 | # Aggiorna i dati da web 337 | try: 338 | # Esegue l'aggiornamento 339 | await self._async_update_data() 340 | 341 | # Se non ci sono eccezioni, ha avuto successo 342 | # Ricarica i tentativi per la prossima esecuzione 343 | self.web_retries = WEB_RETRIES_MINUTES 344 | 345 | # Errore nel fetch dei dati se la response non e' 200 346 | # pylint: disable=broad-exception-caught 347 | except (Exception, UpdateFailed, ServerConnectionError) as e: 348 | # Errori durante l'esecuzione dell'aggiornamento, riprova dopo 349 | # Annulla eventuali schedulazioni attive 350 | self.clean_tokens() 351 | 352 | # Prepara la schedulazione 353 | if self.web_retries: 354 | # Minuti dopo 355 | retry_in_minutes = self.web_retries.pop(0) 356 | _LOGGER.warning( 357 | "Errore durante l'aggiornamento dei dati, nuovo tentativo tra %s minut%s.", 358 | retry_in_minutes, 359 | "o" if retry_in_minutes == 1 else "i", 360 | exc_info=e, 361 | ) 362 | self.schedule_token = async_call_later( 363 | self.hass, timedelta(minutes=retry_in_minutes), self.update_pun 364 | ) 365 | else: 366 | # Tentativi esauriti, passa al giorno dopo 367 | _LOGGER.error( 368 | "Errore durante l'aggiornamento via web, tentativi esauriti.", 369 | exc_info=e, 370 | ) 371 | next_update_pun = get_next_date( 372 | dataora=dt_util.now(time_zone=tz_pun), 373 | ora=self.scan_hour, 374 | minuto=self.scan_minute, 375 | offset=1, 376 | ) 377 | self.schedule_token = async_track_point_in_time( 378 | self.hass, self.update_pun, next_update_pun 379 | ) 380 | _LOGGER.debug( 381 | "Prossimo aggiornamento web: %s", 382 | next_update_pun.strftime("%d/%m/%Y %H:%M:%S %z"), 383 | ) 384 | 385 | # Esce e attende la prossima schedulazione 386 | return 387 | 388 | # Calcola la data della prossima esecuzione 389 | next_update_pun = get_next_date( 390 | dataora=dt_util.now(time_zone=tz_pun), 391 | ora=self.scan_hour, 392 | minuto=self.scan_minute, 393 | ) 394 | if next_update_pun <= dt_util.now(): 395 | # Se l'evento è già trascorso, passa a domani alla stessa ora 396 | next_update_pun = next_update_pun + timedelta(days=1) 397 | 398 | # Annulla eventuali schedulazioni attive 399 | self.clean_tokens() 400 | 401 | # Schedula la prossima esecuzione 402 | self.schedule_token = async_track_point_in_time( 403 | self.hass, self.update_pun, next_update_pun 404 | ) 405 | _LOGGER.debug( 406 | "Prossimo aggiornamento web: %s", 407 | next_update_pun.strftime("%d/%m/%Y %H:%M:%S %z"), 408 | ) 409 | 410 | async def update_prezzo_zonale(self, now=None): 411 | """Aggiorna il prezzo zonale corrente (ogni ora).""" 412 | 413 | # Aggiorna il nuovo orario 414 | self.orario_prezzo = get_hour_datetime(dt_util.now(time_zone=tz_pun)) 415 | 416 | # Notifica che i dati sono stati aggiornati (orario prezzo zonale) 417 | self.async_set_updated_data({COORD_EVENT: EVENT_UPDATE_PREZZO_ZONALE}) 418 | 419 | # Schedula la prossima esecuzione all'ora successiva 420 | next_update_prezzo_zonale = ( 421 | dt_util.now(time_zone=tz_pun) + timedelta(hours=1) 422 | ).replace(minute=0, second=0, microsecond=0) 423 | async_track_point_in_time( 424 | self.hass, self.update_prezzo_zonale, next_update_prezzo_zonale 425 | ) 426 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/interfaces.py: -------------------------------------------------------------------------------- 1 | """Interfacce di gestione di pun_sensor.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class PunData: 7 | """Classe che contiene i valori del PUN orario per ciascuna fascia.""" 8 | 9 | def __init__(self) -> None: 10 | """Inizializza le liste di ciascuna fascia e i prezzi zonali.""" 11 | 12 | self.pun: dict[Fascia, list[float]] = { 13 | Fascia.MONO: [], 14 | Fascia.F1: [], 15 | Fascia.F2: [], 16 | Fascia.F3: [], 17 | Fascia.F23: [], 18 | } 19 | 20 | self.zona: Zona | None = None 21 | self.prezzi_zonali: dict[str, float | None] = {} 22 | self.pun_orari: dict[str, float | None] = {} 23 | 24 | 25 | class Fascia(Enum): 26 | """Enumerazione con i tipi di fascia oraria.""" 27 | 28 | MONO = "MONO" 29 | F1 = "F1" 30 | F2 = "F2" 31 | F3 = "F3" 32 | F23 = "F23" 33 | 34 | 35 | class PunValues: 36 | """Classe che contiene il PUN attuale di ciascuna fascia.""" 37 | 38 | value: dict[Fascia, float] 39 | value = { 40 | Fascia.MONO: 0.0, 41 | Fascia.F1: 0.0, 42 | Fascia.F2: 0.0, 43 | Fascia.F3: 0.0, 44 | Fascia.F23: 0.0, 45 | } 46 | 47 | 48 | class Zona(Enum): 49 | """Enumerazione con i nomi delle zone per i prezzi zonali.""" 50 | 51 | AUST = "Austria" 52 | XAUS = "Austria Coupling" 53 | CALA = "Calabria" 54 | CNOR = "Centro Nord" 55 | CSUD = "Centro Sud" 56 | CORS = "Corsica" 57 | COAC = "Corsica AC" 58 | FRAN = "Francia" 59 | XFRA = "Francia Coupling" 60 | GREC = "Grecia" 61 | XGRE = "Grecia Coupling" 62 | NAT = "Italia" 63 | COUP = "Italia Coupling" 64 | MALT = "Malta" 65 | MONT = "Montenegro" 66 | NORD = "Nord" 67 | SARD = "Sardegna" 68 | SICI = "Sicilia" 69 | SLOV = "Slovenia" 70 | BSP = "Slovenia Coupling" 71 | SUD = "Sud" 72 | SVIZ = "Svizzera" 73 | 74 | 75 | # Zona predefinita 76 | DEFAULT_ZONA = Zona.NAT 77 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "pun_sensor", 3 | "name": "Prezzi PUN del mese", 4 | "codeowners": ["@virtualdj"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/virtualdj/pun_sensor", 8 | "import_executor": true, 9 | "integration_type": "hub", 10 | "iot_class": "cloud_polling", 11 | "issue_tracker": "https://github.com/virtualdj/pun_sensor/issues", 12 | "loggers": ["pun_sensor"], 13 | "requirements": [], 14 | "single_config_entry": true, 15 | "version": "0.0.0" 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/sensor.py: -------------------------------------------------------------------------------- 1 | """Implementazione sensori di pun_sensor.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from awesomeversion.awesomeversion import AwesomeVersion 7 | 8 | from homeassistant.components.sensor import ( 9 | ENTITY_ID_FORMAT, 10 | SensorDeviceClass, 11 | SensorEntity, 12 | SensorStateClass, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.const import CURRENCY_EURO, UnitOfEnergy, __version__ as HA_VERSION 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.restore_state import ( 19 | ExtraStoredData, 20 | RestoredExtraData, 21 | RestoreEntity, 22 | ) 23 | from homeassistant.helpers.typing import DiscoveryInfoType 24 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 25 | 26 | from . import PUNDataUpdateCoordinator 27 | from .const import ( 28 | COORD_EVENT, 29 | DOMAIN, 30 | EVENT_UPDATE_FASCIA, 31 | EVENT_UPDATE_PREZZO_ZONALE, 32 | EVENT_UPDATE_PUN, 33 | ) 34 | from .interfaces import Fascia, PunValues 35 | from .utils import datetime_to_packed_string, get_next_date 36 | 37 | ATTR_PREFIX_PREZZO_OGGI = "oggi_h_" 38 | ATTR_PREFIX_PREZZO_DOMANI = "domani_h_" 39 | 40 | # Ottiene il logger 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | 44 | async def async_setup_entry( 45 | hass: HomeAssistant, 46 | config: ConfigEntry, 47 | async_add_entities: AddEntitiesCallback, 48 | discovery_info: DiscoveryInfoType | None = None, 49 | ) -> None: 50 | """Inizializza e crea i sensori.""" 51 | 52 | # Restituisce il coordinator 53 | coordinator = hass.data[DOMAIN][config.entry_id] 54 | 55 | # Crea i sensori dei valori del pun (legati al coordinator) 56 | entities: list[SensorEntity] = [] 57 | entities.extend( 58 | PUNSensorEntity(coordinator, fascia) for fascia in PunValues().value 59 | ) 60 | 61 | # Crea sensori aggiuntivi 62 | entities.append(FasciaPUNSensorEntity(coordinator)) 63 | entities.append(PrezzoFasciaPUNSensorEntity(coordinator)) 64 | entities.append(PrezzoZonaleSensorEntity(coordinator)) 65 | entities.append(PUNOrarioSensorEntity(coordinator)) 66 | 67 | # Aggiunge i sensori ma non aggiorna automaticamente via web 68 | # per lasciare il tempo ad Home Assistant di avviarsi 69 | async_add_entities(entities, update_before_add=False) 70 | 71 | 72 | class PUNSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): 73 | """Sensore PUN relativo al prezzo medio mensile per fasce.""" 74 | 75 | def __init__(self, coordinator: PUNDataUpdateCoordinator, fascia: Fascia) -> None: 76 | """Inizializza il sensore.""" 77 | super().__init__(coordinator) 78 | 79 | # Inizializza coordinator e tipo 80 | self.coordinator = coordinator 81 | self.fascia = fascia 82 | 83 | # ID univoco sensore basato su un nome fisso 84 | match self.fascia: 85 | case Fascia.MONO: 86 | self.entity_id = ENTITY_ID_FORMAT.format("pun_mono_orario") 87 | case Fascia.F1: 88 | self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f1") 89 | case Fascia.F2: 90 | self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f2") 91 | case Fascia.F3: 92 | self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f3") 93 | case Fascia.F23: 94 | self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f23") 95 | case _: 96 | self.entity_id = None 97 | self._attr_unique_id = self.entity_id 98 | self._attr_has_entity_name = True 99 | 100 | # Inizializza le proprietà comuni 101 | self._attr_state_class = SensorStateClass.MEASUREMENT 102 | self._attr_suggested_display_precision = 6 103 | self._available = False 104 | self._native_value = 0 105 | 106 | def _handle_coordinator_update(self) -> None: 107 | """Gestisce l'aggiornamento dei dati dal coordinator.""" 108 | 109 | # Identifica l'evento che ha scatenato l'aggiornamento 110 | if self.coordinator.data is None: 111 | return 112 | if (coordinator_event := self.coordinator.data.get(COORD_EVENT)) is None: 113 | return 114 | 115 | # Aggiorna il sensore in caso di variazione di prezzi 116 | if coordinator_event != EVENT_UPDATE_PUN: 117 | return 118 | 119 | if self.fascia != Fascia.F23: 120 | # Tutte le fasce tranne F23 121 | if len(self.coordinator.pun_data.pun[self.fascia]) > 0: 122 | # Ci sono dati, sensore disponibile 123 | self._available = True 124 | self._native_value = self.coordinator.pun_values.value[self.fascia] 125 | else: 126 | # Non ci sono dati, sensore non disponibile 127 | self._available = False 128 | 129 | elif ( 130 | len(self.coordinator.pun_data.pun[Fascia.F2]) 131 | and len(self.coordinator.pun_data.pun[Fascia.F3]) 132 | ) > 0: 133 | # Caso speciale per fascia F23: affinché sia disponibile devono 134 | # esserci dati sia sulla fascia F2 che sulla F3, 135 | # visto che è calcolata a partire da questi 136 | self._available = True 137 | self._native_value = self.coordinator.pun_values.value[self.fascia] 138 | else: 139 | # Non ci sono dati, sensore non disponibile 140 | self._available = False 141 | 142 | # Aggiorna lo stato di Home Assistant 143 | self.async_write_ha_state() 144 | 145 | @property 146 | def extra_restore_state_data(self) -> ExtraStoredData: 147 | """Determina i dati da salvare per il ripristino successivo.""" 148 | return RestoredExtraData( 149 | {"native_value": self._native_value if self._available else None} 150 | ) 151 | 152 | async def async_added_to_hass(self) -> None: 153 | """Entità aggiunta ad Home Assistant.""" 154 | await super().async_added_to_hass() 155 | 156 | # Recupera lo stato precedente, se esiste 157 | if (old_data := await self.async_get_last_extra_data()) is not None: 158 | if (old_native_value := old_data.as_dict().get("native_value")) is not None: 159 | self._available = True 160 | self._native_value = old_native_value 161 | 162 | @property 163 | def should_poll(self) -> bool: 164 | """Determina l'aggiornamento automatico.""" 165 | return False 166 | 167 | @property 168 | def available(self) -> bool: 169 | """Determina se il valore è disponibile.""" 170 | return self._available 171 | 172 | @property 173 | def native_value(self) -> float: 174 | """Valore corrente del sensore.""" 175 | return self._native_value 176 | 177 | @property 178 | def native_unit_of_measurement(self) -> str: 179 | """Unita' di misura.""" 180 | return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" 181 | 182 | @property 183 | def icon(self) -> str: 184 | """Icona da usare nel frontend.""" 185 | return "mdi:chart-line" 186 | 187 | @property 188 | def name(self) -> str | None: 189 | """Restituisce il nome del sensore.""" 190 | if self.fascia == Fascia.MONO: 191 | return "PUN mono-orario" 192 | if self.fascia: 193 | return f"PUN fascia {self.fascia.value}" 194 | return None 195 | 196 | 197 | class FasciaPUNSensorEntity(CoordinatorEntity, SensorEntity): 198 | """Sensore che rappresenta il nome la fascia oraria PUN corrente.""" 199 | 200 | def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: 201 | """Inizializza il sensore.""" 202 | super().__init__(coordinator) 203 | 204 | # Inizializza coordinator 205 | self.coordinator = coordinator 206 | 207 | # ID univoco sensore basato su un nome fisso 208 | self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_corrente") 209 | self._attr_unique_id = self.entity_id 210 | self._attr_has_entity_name = True 211 | 212 | def _handle_coordinator_update(self) -> None: 213 | """Gestisce l'aggiornamento dei dati dal coordinator.""" 214 | 215 | # Identifica l'evento che ha scatenato l'aggiornamento 216 | if self.coordinator.data is None: 217 | return 218 | if (coordinator_event := self.coordinator.data.get(COORD_EVENT)) is None: 219 | return 220 | 221 | # Aggiorna il sensore in caso di variazione di fascia 222 | if coordinator_event != EVENT_UPDATE_FASCIA: 223 | return 224 | 225 | self.async_write_ha_state() 226 | 227 | @property 228 | def should_poll(self) -> bool: 229 | """Determina l'aggiornamento automatico.""" 230 | return False 231 | 232 | @property 233 | def available(self) -> bool: 234 | """Determina se il valore è disponibile.""" 235 | return self.coordinator.fascia_corrente is not None 236 | 237 | @property 238 | def device_class(self) -> SensorDeviceClass | None: 239 | """Classe del sensore.""" 240 | return SensorDeviceClass.ENUM 241 | 242 | @property 243 | def options(self) -> list[str] | None: 244 | """Possibili stati del sensore.""" 245 | return [Fascia.F1.value, Fascia.F2.value, Fascia.F3.value] 246 | 247 | @property 248 | def native_value(self) -> str | None: 249 | """Restituisce la fascia corrente come stato.""" 250 | if not self.coordinator.fascia_corrente: 251 | return None 252 | return self.coordinator.fascia_corrente.value 253 | 254 | @property 255 | def extra_state_attributes(self) -> dict[str, Any] | None: 256 | """Attributi aggiuntivi del sensore.""" 257 | return { 258 | "fascia_successiva": self.coordinator.fascia_successiva.value 259 | if self.coordinator.fascia_successiva 260 | else None, 261 | "inizio_fascia_successiva": self.coordinator.prossimo_cambio_fascia, 262 | "termine_fascia_successiva": self.coordinator.termine_prossima_fascia, 263 | } 264 | 265 | @property 266 | def icon(self) -> str: 267 | """Icona da usare nel frontend.""" 268 | return "mdi:timeline-clock-outline" 269 | 270 | @property 271 | def name(self) -> str: 272 | """Restituisce il nome del sensore.""" 273 | return "Fascia corrente" 274 | 275 | 276 | class PrezzoFasciaPUNSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): 277 | """Sensore che rappresenta il prezzo PUN della fascia corrente.""" 278 | 279 | def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: 280 | """Inizializza il sensore.""" 281 | super().__init__(coordinator) 282 | 283 | # Inizializza coordinator 284 | self.coordinator = coordinator 285 | 286 | # ID univoco sensore basato su un nome fisso 287 | self.entity_id = ENTITY_ID_FORMAT.format("pun_prezzo_fascia_corrente") 288 | self._attr_unique_id = self.entity_id 289 | self._attr_has_entity_name = True 290 | 291 | # Inizializza le proprietà comuni 292 | self._attr_state_class = SensorStateClass.MEASUREMENT 293 | self._attr_suggested_display_precision = 6 294 | self._available = False 295 | self._native_value = 0 296 | self._friendly_name = "Prezzo fascia corrente" 297 | 298 | def _handle_coordinator_update(self) -> None: 299 | """Gestisce l'aggiornamento dei dati dal coordinator.""" 300 | 301 | # Identifica l'evento che ha scatenato l'aggiornamento 302 | if self.coordinator.data is None: 303 | return 304 | if (coordinator_event := self.coordinator.data.get(COORD_EVENT)) is None: 305 | return 306 | 307 | # Aggiorna il sensore in caso di variazione di prezzi o di fascia 308 | if coordinator_event not in (EVENT_UPDATE_PUN, EVENT_UPDATE_FASCIA): 309 | return 310 | 311 | if self.coordinator.fascia_corrente is not None: 312 | self._available = ( 313 | len(self.coordinator.pun_data.pun[self.coordinator.fascia_corrente]) > 0 314 | ) 315 | self._native_value = self.coordinator.pun_values.value[ 316 | self.coordinator.fascia_corrente 317 | ] 318 | self._friendly_name = ( 319 | f"Prezzo fascia corrente ({self.coordinator.fascia_corrente.value})" 320 | ) 321 | else: 322 | self._available = False 323 | self._native_value = 0 324 | self._friendly_name = "Prezzo fascia corrente" 325 | self.async_write_ha_state() 326 | 327 | @property 328 | def extra_restore_state_data(self) -> ExtraStoredData: 329 | """Determina i dati da salvare per il ripristino successivo.""" 330 | return RestoredExtraData( 331 | { 332 | "native_value": self._native_value if self._available else None, 333 | "friendly_name": self._friendly_name if self._available else None, 334 | } 335 | ) 336 | 337 | async def async_added_to_hass(self) -> None: 338 | """Entità aggiunta ad Home Assistant.""" 339 | await super().async_added_to_hass() 340 | 341 | # Recupera lo stato precedente, se esiste 342 | if (old_data := await self.async_get_last_extra_data()) is not None: 343 | if (old_native_value := old_data.as_dict().get("native_value")) is not None: 344 | self._available = True 345 | self._native_value = old_native_value 346 | if ( 347 | old_friendly_name := old_data.as_dict().get("friendly_name") 348 | ) is not None: 349 | self._friendly_name = old_friendly_name 350 | 351 | @property 352 | def should_poll(self) -> bool: 353 | """Determina l'aggiornamento automatico.""" 354 | return False 355 | 356 | @property 357 | def available(self) -> bool: 358 | """Determina se il valore è disponibile.""" 359 | return self._available 360 | 361 | @property 362 | def native_value(self) -> float: 363 | """Restituisce il prezzo della fascia corrente.""" 364 | return self._native_value 365 | 366 | @property 367 | def native_unit_of_measurement(self) -> str: 368 | """Unita' di misura.""" 369 | return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" 370 | 371 | @property 372 | def icon(self) -> str: 373 | """Icona da usare nel frontend.""" 374 | return "mdi:currency-eur" 375 | 376 | @property 377 | def name(self) -> str: 378 | """Restituisce il nome del sensore.""" 379 | return self._friendly_name 380 | 381 | 382 | class PrezzoZonaleSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): 383 | """Sensore del prezzo zonale aggiornato ogni ora.""" 384 | 385 | def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: 386 | """Inizializza il sensore.""" 387 | super().__init__(coordinator) 388 | 389 | # Inizializza coordinator e tipo 390 | self.coordinator = coordinator 391 | 392 | # ID univoco sensore basato su un nome fisso 393 | self.entity_id = ENTITY_ID_FORMAT.format("pun_prezzo_zonale") 394 | self._attr_unique_id = self.entity_id 395 | self._attr_has_entity_name = True 396 | 397 | # Inizializza le proprietà comuni 398 | self._attr_state_class = SensorStateClass.MEASUREMENT 399 | self._attr_suggested_display_precision = 6 400 | self._available: bool = False 401 | self._native_value: float = 0 402 | self._friendly_name: str = "Prezzo zonale" 403 | self._prezzi_zonali: dict[str, float | None] = {} 404 | 405 | def _handle_coordinator_update(self) -> None: 406 | """Gestisce l'aggiornamento dei dati dal coordinator.""" 407 | 408 | # Identifica l'evento che ha scatenato l'aggiornamento 409 | if self.coordinator.data is None: 410 | return 411 | if (coordinator_event := self.coordinator.data.get(COORD_EVENT)) is None: 412 | return 413 | 414 | # Aggiornata la zona e/o i prezzi 415 | if coordinator_event == EVENT_UPDATE_PUN: 416 | if self.coordinator.pun_data.zona is not None: 417 | # Imposta il nome della zona 418 | self._friendly_name = ( 419 | f"Prezzo zonale ({self.coordinator.pun_data.zona.value})" 420 | ) 421 | # Verifica che il coordinator abbia i prezzi 422 | if self.coordinator.pun_data.prezzi_zonali: 423 | # Copia i dati dal coordinator in locale (per il backup) 424 | self._prezzi_zonali = dict(self.coordinator.pun_data.prezzi_zonali) 425 | else: 426 | # Nessuna zona impostata 427 | self._friendly_name = "Prezzo zonale" 428 | self._prezzi_zonali = {} 429 | self._available = False 430 | self.async_write_ha_state() 431 | return 432 | 433 | # Cambiato l'orario del prezzo 434 | if coordinator_event in (EVENT_UPDATE_PUN, EVENT_UPDATE_PREZZO_ZONALE): 435 | if self.coordinator.pun_data.zona is not None: 436 | # Controlla se il prezzo orario esiste per l'ora corrente 437 | if ( 438 | datetime_to_packed_string(self.coordinator.orario_prezzo) 439 | in self._prezzi_zonali 440 | ): 441 | # Aggiorna il valore al prezzo orario 442 | if ( 443 | valore := self._prezzi_zonali[ 444 | datetime_to_packed_string(self.coordinator.orario_prezzo) 445 | ] 446 | ) is not None: 447 | self._native_value = valore 448 | self._available = True 449 | else: 450 | # Prezzo non disponibile 451 | self._available = False 452 | else: 453 | # Orario non disponibile 454 | self._available = False 455 | else: 456 | # Nessuna zona impostata 457 | self._available = False 458 | 459 | # Aggiorna lo stato di Home Assistant 460 | self.async_write_ha_state() 461 | 462 | @property 463 | def extra_restore_state_data(self) -> ExtraStoredData: 464 | """Determina i dati da salvare per il ripristino successivo.""" 465 | 466 | # Salva i dati per la prossima istanza 467 | return RestoredExtraData( 468 | { 469 | "friendly_name": self._friendly_name if self._available else None, 470 | "zona": self.coordinator.pun_data.zona.name 471 | if self.coordinator.pun_data.zona is not None 472 | else None, 473 | "prezzi_zonali": self._prezzi_zonali, 474 | } 475 | ) 476 | 477 | async def async_added_to_hass(self) -> None: 478 | """Entità aggiunta ad Home Assistant.""" 479 | await super().async_added_to_hass() 480 | 481 | # Recupera lo stato precedente, se esiste 482 | if (old_data := await self.async_get_last_extra_data()) is not None: 483 | # Recupera il dizionario con i valori precedenti 484 | old_data_dict = old_data.as_dict() 485 | 486 | # Zona geografica 487 | if (old_zona_str := old_data_dict.get("zona")) is not None: 488 | # Verifica che la zona attuale sia disponibile 489 | # (se non lo è, c'è un errore nella configurazione) 490 | if self.coordinator.pun_data.zona is None: 491 | _LOGGER.warning( 492 | "La zona geografica memorizzata '%s' non sembra essere più valida.", 493 | old_zona_str, 494 | ) 495 | self._available = False 496 | return 497 | 498 | # Controlla se la zona memorizzata è diversa dall'attuale 499 | if old_zona_str != self.coordinator.pun_data.zona.name: 500 | _LOGGER.debug( 501 | "Ignorati i dati precedenti, perché riferiti alla zona '%s' (anziché '%s').", 502 | old_zona_str, 503 | self.coordinator.pun_data.zona.name, 504 | ) 505 | self._available = False 506 | return 507 | 508 | # Nome 509 | if (old_friendly_name := old_data_dict.get("friendly_name")) is not None: 510 | self._friendly_name = old_friendly_name 511 | 512 | # Valori delle fasce orarie 513 | if (old_prezzi_zonali := old_data_dict.get("prezzi_zonali")) is not None: 514 | self._prezzi_zonali = old_prezzi_zonali 515 | 516 | # Controlla se il prezzo orario esiste per l'ora corrente 517 | if ( 518 | datetime_to_packed_string(self.coordinator.orario_prezzo) 519 | in self._prezzi_zonali 520 | ): 521 | # Aggiorna il valore al prezzo orario 522 | if ( 523 | valore := self._prezzi_zonali[ 524 | datetime_to_packed_string(self.coordinator.orario_prezzo) 525 | ] 526 | ) is not None: 527 | self._native_value = valore 528 | self._available = True 529 | else: 530 | # Prezzo non disponibile 531 | self._available = False 532 | else: 533 | # Imposta come non disponibile 534 | self._available = False 535 | 536 | @property 537 | def should_poll(self) -> bool: 538 | """Determina l'aggiornamento automatico.""" 539 | return False 540 | 541 | @property 542 | def available(self) -> bool: 543 | """Determina se il valore è disponibile.""" 544 | return self._available 545 | 546 | @property 547 | def native_value(self) -> float: 548 | """Valore corrente del sensore.""" 549 | return self._native_value 550 | 551 | @property 552 | def native_unit_of_measurement(self) -> str: 553 | """Unita' di misura.""" 554 | return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" 555 | 556 | @property 557 | def icon(self) -> str: 558 | """Icona da usare nel frontend.""" 559 | return "mdi:map-clock-outline" 560 | 561 | @property 562 | def name(self) -> str | None: 563 | """Restituisce il nome del sensore.""" 564 | return self._friendly_name 565 | 566 | @property 567 | def extra_state_attributes(self) -> dict[str, Any]: 568 | """Restituisce gli attributi di stato.""" 569 | 570 | # Crea il dizionario degli attributi 571 | attributes: dict[str, Any] = {} 572 | 573 | # Aggiunge i prezzi orari negli attributi, ora per ora 574 | if self.coordinator.pun_data.zona is not None: 575 | for h in range(24): 576 | # Prezzi di oggi 577 | data_oggi = get_next_date( 578 | dataora=self.coordinator.orario_prezzo, ora=h, offset=0 579 | ) 580 | attributes[ATTR_PREFIX_PREZZO_OGGI + f"{h:02d}"] = ( 581 | self._prezzi_zonali.get(datetime_to_packed_string(data_oggi)) 582 | ) 583 | 584 | for h in range(24): 585 | # Prezzi di domani 586 | data_domani = get_next_date( 587 | dataora=self.coordinator.orario_prezzo, ora=h, offset=1 588 | ) 589 | attributes[ATTR_PREFIX_PREZZO_DOMANI + f"{h:02d}"] = ( 590 | self._prezzi_zonali.get(datetime_to_packed_string(data_domani)) 591 | ) 592 | 593 | # Restituisce gli attributi 594 | return attributes 595 | 596 | 597 | class PUNOrarioSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): 598 | """Sensore del prezzo PUN aggiornato ogni ora.""" 599 | 600 | def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: 601 | """Inizializza il sensore.""" 602 | super().__init__(coordinator) 603 | 604 | # Inizializza coordinator e tipo 605 | self.coordinator = coordinator 606 | 607 | # ID univoco sensore basato su un nome fisso 608 | self.entity_id = ENTITY_ID_FORMAT.format("pun_orario") 609 | self._attr_unique_id = self.entity_id 610 | self._attr_has_entity_name = True 611 | 612 | # Inizializza le proprietà comuni 613 | self._attr_state_class = SensorStateClass.MEASUREMENT 614 | self._attr_suggested_display_precision = 6 615 | self._available: bool = False 616 | self._native_value: float = 0 617 | self._friendly_name: str = "PUN orario" 618 | self._pun_orari: dict[str, float | None] = {} 619 | 620 | def _handle_coordinator_update(self) -> None: 621 | """Gestisce l'aggiornamento dei dati dal coordinator.""" 622 | 623 | # Identifica l'evento che ha scatenato l'aggiornamento 624 | if self.coordinator.data is None: 625 | return 626 | if (coordinator_event := self.coordinator.data.get(COORD_EVENT)) is None: 627 | return 628 | 629 | # Aggiornati i prezzi PUN 630 | if coordinator_event == EVENT_UPDATE_PUN: 631 | # Verifica che il coordinator abbia i prezzi 632 | if self.coordinator.pun_data.pun_orari: 633 | # Copia i dati dal coordinator in locale (per il backup) 634 | self._pun_orari = dict(self.coordinator.pun_data.pun_orari) 635 | 636 | # Cambiato l'orario del prezzo 637 | if coordinator_event in (EVENT_UPDATE_PUN, EVENT_UPDATE_PREZZO_ZONALE): 638 | # Controlla se il PUN orario esiste per l'ora corrente 639 | if ( 640 | datetime_to_packed_string(self.coordinator.orario_prezzo) 641 | in self._pun_orari 642 | ): 643 | # Aggiorna il valore al prezzo orario 644 | if ( 645 | valore := self._pun_orari[ 646 | datetime_to_packed_string(self.coordinator.orario_prezzo) 647 | ] 648 | ) is not None: 649 | self._native_value = valore 650 | self._available = True 651 | else: 652 | # Prezzo non disponibile 653 | self._available = False 654 | else: 655 | # Orario non disponibile 656 | self._available = False 657 | 658 | # Aggiorna lo stato di Home Assistant 659 | self.async_write_ha_state() 660 | 661 | @property 662 | def extra_restore_state_data(self) -> ExtraStoredData: 663 | """Determina i dati da salvare per il ripristino successivo.""" 664 | 665 | # Salva i dati per la prossima istanza 666 | return RestoredExtraData( 667 | { 668 | "pun_orari": self._pun_orari, 669 | } 670 | ) 671 | 672 | async def async_added_to_hass(self) -> None: 673 | """Entità aggiunta ad Home Assistant.""" 674 | await super().async_added_to_hass() 675 | 676 | # Recupera lo stato precedente, se esiste 677 | if (old_data := await self.async_get_last_extra_data()) is not None: 678 | # Recupera il dizionario con i valori precedenti 679 | old_data_dict = old_data.as_dict() 680 | 681 | # Valori dei prezzi orari 682 | if (old_pun_orari := old_data_dict.get("pun_orari")) is not None: 683 | self._pun_orari = old_pun_orari 684 | 685 | # Controlla se il prezzo orario esiste per l'ora corrente 686 | if ( 687 | datetime_to_packed_string(self.coordinator.orario_prezzo) 688 | in self._pun_orari 689 | ): 690 | # Aggiorna il valore al prezzo orario 691 | if ( 692 | valore := self._pun_orari[ 693 | datetime_to_packed_string(self.coordinator.orario_prezzo) 694 | ] 695 | ) is not None: 696 | self._native_value = valore 697 | self._available = True 698 | else: 699 | # Prezzo non disponibile 700 | self._available = False 701 | else: 702 | # Imposta come non disponibile 703 | self._available = False 704 | 705 | @property 706 | def should_poll(self) -> bool: 707 | """Determina l'aggiornamento automatico.""" 708 | return False 709 | 710 | @property 711 | def available(self) -> bool: 712 | """Determina se il valore è disponibile.""" 713 | return self._available 714 | 715 | @property 716 | def native_value(self) -> float: 717 | """Valore corrente del sensore.""" 718 | return self._native_value 719 | 720 | @property 721 | def native_unit_of_measurement(self) -> str: 722 | """Unita' di misura.""" 723 | return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" 724 | 725 | @property 726 | def icon(self) -> str: 727 | """Icona da usare nel frontend.""" 728 | if AwesomeVersion(HA_VERSION) < AwesomeVersion("2024.1.0"): 729 | return "mdi:receipt-clock-outline" 730 | return "mdi:invoice-clock-outline" 731 | 732 | @property 733 | def name(self) -> str | None: 734 | """Restituisce il nome del sensore.""" 735 | return self._friendly_name 736 | 737 | @property 738 | def extra_state_attributes(self) -> dict[str, Any]: 739 | """Restituisce gli attributi di stato.""" 740 | 741 | # Crea il dizionario degli attributi 742 | attributes: dict[str, Any] = {} 743 | 744 | # Aggiunge i prezzi orari negli attributi, ora per ora 745 | for h in range(24): 746 | # Prezzi di oggi 747 | data_oggi = get_next_date( 748 | dataora=self.coordinator.orario_prezzo, ora=h, offset=0 749 | ) 750 | attributes[ATTR_PREFIX_PREZZO_OGGI + f"{h:02d}"] = self._pun_orari.get( 751 | datetime_to_packed_string(data_oggi) 752 | ) 753 | 754 | for h in range(24): 755 | # Prezzi di domani 756 | data_domani = get_next_date( 757 | dataora=self.coordinator.orario_prezzo, ora=h, offset=1 758 | ) 759 | attributes[ATTR_PREFIX_PREZZO_DOMANI + f"{h:02d}"] = self._pun_orari.get( 760 | datetime_to_packed_string(data_domani) 761 | ) 762 | 763 | # Restituisce gli attributi 764 | return attributes 765 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Impostazioni scraping PUN", 6 | "data": { 7 | "zona": "Zona geografica per prezzi zonali", 8 | "scan_hour": "Ora inizio download dati (0-23)", 9 | "actual_data_only": "Usa solo dati reali ad inizio mese" 10 | } 11 | } 12 | }, 13 | "error": { 14 | "unknown": "Errore sconosciuto" 15 | }, 16 | "abort": { 17 | "already_configured": "Integrazione già configurata." 18 | } 19 | }, 20 | "options": { 21 | "step": { 22 | "init": { 23 | "title": "Modifica impostazioni scraping PUN", 24 | "data": { 25 | "zona": "Zona geografica per prezzi zonali", 26 | "scan_hour": "Ora inizio download dati (0-23)", 27 | "actual_data_only": "Usa solo dati reali ad inizio mese" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "PUN scraping settings", 6 | "data": { 7 | "zona": "Geographical area for district prices", 8 | "scan_hour": "Web download start hour (0-23)", 9 | "actual_data_only": "Use only real data at month start" 10 | } 11 | } 12 | }, 13 | "error": { 14 | "unknown": "Errore sconosciuto" 15 | }, 16 | "abort": { 17 | "already_configured": "Integration already configured." 18 | } 19 | }, 20 | "options": { 21 | "step": { 22 | "init": { 23 | "title": "Edit PUN scraping settings", 24 | "data": { 25 | "zona": "Geographical area for district prices", 26 | "scan_hour": "Web download start hour (0-23)", 27 | "actual_data_only": "Use only real data at month start" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Impostazioni scraping PUN", 6 | "data": { 7 | "zona": "Zona geografica per prezzi zonali", 8 | "scan_hour": "Ora inizio download dati (0-23)", 9 | "actual_data_only": "Usa solo dati reali ad inizio mese" 10 | } 11 | } 12 | }, 13 | "error": { 14 | "unknown": "Errore sconosciuto" 15 | }, 16 | "abort": { 17 | "already_configured": "Integrazione già configurata." 18 | } 19 | }, 20 | "options": { 21 | "step": { 22 | "init": { 23 | "title": "Modifica impostazioni scraping PUN", 24 | "data": { 25 | "zona": "Zona geografica per prezzi zonali", 26 | "scan_hour": "Ora inizio download dati (0-23)", 27 | "actual_data_only": "Usa solo dati reali ad inizio mese" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /custom_components/pun_sensor/utils.py: -------------------------------------------------------------------------------- 1 | """Metodi di utilità generale.""" 2 | 3 | from datetime import date, datetime, timedelta 4 | import logging 5 | from zipfile import ZipFile 6 | 7 | import defusedxml.ElementTree as et # type: ignore[import-untyped] 8 | import holidays 9 | 10 | from .interfaces import Fascia, PunData 11 | 12 | # Ottiene il logger 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | def get_fascia_for_xml(data: date, festivo: bool, ora: int) -> Fascia: 17 | """Restituisce la fascia oraria di un determinato giorno/ora.""" 18 | # F1 = lu-ve 8-19 19 | # F2 = lu-ve 7-8, lu-ve 19-23, sa 7-23 20 | # F3 = lu-sa 0-7, lu-sa 23-24, do, festivi 21 | 22 | # Festivi e domeniche 23 | if festivo or (data.weekday() == 6): 24 | return Fascia.F3 25 | 26 | # Sabato 27 | if data.weekday() == 5: 28 | if 7 <= ora < 23: 29 | return Fascia.F2 30 | return Fascia.F3 31 | 32 | # Altri giorni della settimana 33 | if ora == 7 or 19 <= ora < 23: 34 | return Fascia.F2 35 | if 8 <= ora < 19: 36 | return Fascia.F1 37 | return Fascia.F3 38 | 39 | 40 | def get_fascia(dataora: datetime) -> tuple[Fascia, datetime]: 41 | """Restituisce la fascia della data/ora indicata e la data del prossimo cambiamento.""" 42 | 43 | # Verifica se la data corrente è un giorno con festività 44 | festivo = dataora in holidays.IT() # type: ignore[attr-defined] 45 | 46 | # Identifica la fascia corrente 47 | # F1 = lu-ve 8-19 48 | # F2 = lu-ve 7-8, lu-ve 19-23, sa 7-23 49 | # F3 = lu-sa 0-7, lu-sa 23-24, do, festivi 50 | # Festivi 51 | if festivo: 52 | fascia = Fascia.F3 53 | 54 | # Prossima fascia: alle 7 di un giorno non domenica o festività 55 | prossima = get_next_date(dataora, 7, 1, True) 56 | 57 | return fascia, prossima 58 | match dataora.weekday(): 59 | # Domenica 60 | case 6: 61 | fascia = Fascia.F3 62 | prossima = get_next_date(dataora, 7, 1, True) 63 | 64 | # Sabato 65 | case 5: 66 | if 7 <= dataora.hour < 23: 67 | # Sabato dalle 7 alle 23 68 | fascia = Fascia.F2 69 | # Prossima fascia: alle 23 dello stesso giorno 70 | prossima = get_next_date(dataora, 23) 71 | # abbiamo solo due fasce quindi facciamo solo il check per la prossima fascia 72 | else: 73 | # Sabato dopo le 23 e prima delle 7 74 | fascia = Fascia.F3 75 | 76 | if dataora.hour < 7: 77 | # Prossima fascia: alle 7 dello stesso giorno 78 | prossima = get_next_date(dataora, 7) 79 | else: 80 | # Prossima fascia: alle 7 di un giorno non domenica o festività 81 | prossima = get_next_date(dataora, 7, 1, True) 82 | 83 | # Altri giorni della settimana 84 | case _: 85 | if dataora.hour == 7 or 19 <= dataora.hour < 23: 86 | # Lunedì-venerdì dalle 7 alle 8 e dalle 19 alle 23 87 | fascia = Fascia.F2 88 | 89 | if dataora.hour == 7: 90 | # Prossima fascia: alle 8 dello stesso giorno 91 | prossima = get_next_date(dataora, 8) 92 | else: 93 | # Prossima fascia: alle 23 dello stesso giorno 94 | prossima = get_next_date(dataora, 23) 95 | 96 | elif 8 <= dataora.hour < 19: 97 | # Lunedì-venerdì dalle 8 alle 19 98 | fascia = Fascia.F1 99 | # Prossima fascia: alle 19 dello stesso giorno 100 | prossima = get_next_date(dataora, 19) 101 | 102 | else: 103 | # Lunedì-venerdì dalle 23 alle 7 del giorno dopo 104 | fascia = Fascia.F3 105 | 106 | if dataora.hour < 7: 107 | # Siamo dopo la mezzanotte 108 | # Prossima fascia: alle 7 dello stesso giorno 109 | prossima = get_next_date(dataora, 7) 110 | else: 111 | # Prossima fascia: alle 7 di un giorno non domenica o festività 112 | prossima = get_next_date(dataora, 7, 1, True) 113 | 114 | return fascia, prossima 115 | 116 | 117 | def get_next_date( 118 | dataora: datetime, ora: int, offset: int = 0, feriale: bool = False, minuto: int = 0 119 | ) -> datetime: 120 | """Ritorna una datetime in base ai parametri. 121 | 122 | Args: 123 | dataora (datetime): passa la data di riferimento. 124 | ora (int): l'ora a cui impostare la data. 125 | offset (int = 0): scostamento in giorni rispetto a dataora. 126 | feriale (bool = False): se True ritorna sempre una giornata lavorativa (no festivi, domeniche) 127 | minuto (int = 0): minuto a cui impostare la data. 128 | 129 | Returns: 130 | prossima (datetime): L'istanza di datetime corrispondente. 131 | 132 | """ 133 | 134 | prossima = (dataora + timedelta(days=offset)).replace( 135 | hour=ora, minute=minuto, second=0, microsecond=0 136 | ) 137 | 138 | if feriale: 139 | while (prossima in holidays.IT()) or (prossima.weekday() == 6): # type: ignore[attr-defined] 140 | prossima += timedelta(days=1) 141 | 142 | return prossima 143 | 144 | 145 | def get_hour_datetime(dataora: datetime) -> datetime: 146 | """Restituisce un datetime con solo la data e l'ora. 147 | 148 | Args: 149 | dataora (datetime): Data e ora di partenza. 150 | 151 | Returns: 152 | datetime: La nuova data con solo giorno e ora. 153 | 154 | """ 155 | return datetime( 156 | year=dataora.year, 157 | month=dataora.month, 158 | day=dataora.day, 159 | hour=dataora.hour, 160 | minute=0, 161 | second=0, 162 | microsecond=0, 163 | ) 164 | 165 | 166 | def datetime_to_packed_string(dataora: datetime) -> str: 167 | """Restituisce una stringa usabile come chiave dizionario a partire da un datime. 168 | 169 | Args: 170 | dataora (datetime): Data e ora di partenza. 171 | 172 | Returns: 173 | str: Stringa in formato YYYYMMDDHH. 174 | 175 | """ 176 | return dataora.strftime("%Y%m%d%H") 177 | 178 | 179 | def extract_xml(archive: ZipFile, pun_data: PunData, today: date) -> PunData: 180 | """Estrae i valori del pun per ogni fascia da un archivio zip contenente un XML. 181 | 182 | Args: 183 | archive (ZipFile): archivio ZIP con i file XML all'interno. 184 | pun_data (PunData): riferimento alla struttura che verrà modificata con i dati da XML. 185 | today (date): data di oggi, utilizzata per memorizzare il prezzo zonale. 186 | 187 | Returns: 188 | List[ list[MONO: float], list[F1: float], list[F2: float], list[F3: float] ] 189 | 190 | """ 191 | # Carica le festività 192 | it_holidays = holidays.IT() # type: ignore[attr-defined] 193 | 194 | # Azzera i dati precedenti 195 | for fascia_da_svuotare in pun_data.pun.values(): 196 | fascia_da_svuotare.clear() 197 | 198 | # Esamina ogni file XML nello ZIP (ordinandoli prima) 199 | for fn in sorted(archive.namelist()): 200 | # Scompatta il file XML in memoria 201 | xml_tree = et.parse(archive.open(fn)) 202 | 203 | # Parsing dell'XML (1 file = 1 giorno) 204 | xml_root = xml_tree.getroot() 205 | 206 | # Estrae la data dal primo elemento (sarà identica per gli altri) 207 | dat_string = xml_root.find("Prezzi").find("Data").text # YYYYMMDD 208 | 209 | # Converte la stringa giorno in data 210 | dat_date = date( 211 | int(dat_string[0:4]), 212 | int(dat_string[4:6]), 213 | int(dat_string[6:8]), 214 | ) 215 | 216 | # Verifica la festività 217 | festivo = dat_date in it_holidays 218 | 219 | # Estrae le rimanenti informazioni 220 | for prezzi in xml_root.iter("Prezzi"): 221 | # Estrae l'ora dall'XML 222 | ora = int(prezzi.find("Ora").text) - 1 # 1..24 223 | 224 | # Estrae il prezzo PUN dall'XML in un float 225 | if (prezzo_xml := prezzi.find("PUN")) is not None: 226 | prezzo_string = prezzo_xml.text.replace(".", "").replace(",", ".") 227 | prezzo = float(prezzo_string) / 1000 228 | 229 | # Per le medie mensili, considera solo i dati fino ad oggi 230 | if dat_date <= today: 231 | # Estrae la fascia oraria 232 | fascia = get_fascia_for_xml(dat_date, festivo, ora) 233 | 234 | # Calcola le statistiche 235 | pun_data.pun[Fascia.MONO].append(prezzo) 236 | pun_data.pun[fascia].append(prezzo) 237 | 238 | # Per il PUN orario, considera solo oggi e domani 239 | if dat_date >= today: 240 | # Compone l'orario 241 | orario_prezzo = datetime_to_packed_string( 242 | datetime( 243 | year=dat_date.year, 244 | month=dat_date.month, 245 | day=dat_date.day, 246 | hour=ora, 247 | minute=0, 248 | second=0, 249 | microsecond=0, 250 | ) 251 | ) 252 | # E salva il prezzo per quell'orario 253 | pun_data.pun_orari[orario_prezzo] = prezzo 254 | else: 255 | # PUN non valido 256 | _LOGGER.warning( 257 | "PUN non specificato per %s ad orario: %s.", dat_string, ora 258 | ) 259 | 260 | # Per i prezzi zonali, considera solo oggi e domani 261 | if dat_date >= today: 262 | # Compone l'orario 263 | orario_prezzo = datetime_to_packed_string( 264 | datetime( 265 | year=dat_date.year, 266 | month=dat_date.month, 267 | day=dat_date.day, 268 | hour=ora, 269 | minute=0, 270 | second=0, 271 | microsecond=0, 272 | ) 273 | ) 274 | 275 | # Controlla che la zona del prezzo zonale sia impostata 276 | if pun_data.zona is not None: 277 | # Estrae il prezzo zonale dall'XML in un float 278 | # basandosi sul nome dell'enum 279 | if ( 280 | prezzo_zonale_xml := prezzi.find(pun_data.zona.name) 281 | ) is not None: 282 | prezzo_zonale_string = prezzo_zonale_xml.text.replace( 283 | ".", "" 284 | ).replace(",", ".") 285 | pun_data.prezzi_zonali[orario_prezzo] = ( 286 | float(prezzo_zonale_string) / 1000 287 | ) 288 | else: 289 | pun_data.prezzi_zonali[orario_prezzo] = None 290 | 291 | return pun_data 292 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Prezzi PUN del mese", 3 | "homeassistant": "2023.4", 4 | "hide_default_branch": true, 5 | "zip_release": true, 6 | "filename": "pun_sensor.zip", 7 | "render_readme": true 8 | } 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pylint."MESSAGES CONTROL"] 2 | # Reasons disabled: 3 | # format - handled by ruff 4 | # locally-disabled - it spams too much 5 | # duplicate-code - unavoidable 6 | # cyclic-import - doesn't test if both import on load 7 | # abstract-class-little-used - prevents from setting right foundation 8 | # unused-argument - generic callbacks and setup methods create a lot of warnings 9 | # too-many-* - are not enforced for the sake of readability 10 | # too-few-* - same as too-many-* 11 | # abstract-method - with intro of async there are always methods missing 12 | # inconsistent-return-statements - doesn't handle raise 13 | # too-many-ancestors - it's too strict. 14 | # wrong-import-order - isort guards this 15 | # consider-using-f-string - str.format sometimes more readable 16 | # --- 17 | # Pylint CodeStyle plugin 18 | # consider-using-namedtuple-or-dataclass - too opinionated 19 | # consider-using-assignment-expr - decision to use := better left to devs 20 | disable = [ 21 | "format", 22 | "abstract-method", 23 | "cyclic-import", 24 | "duplicate-code", 25 | "inconsistent-return-statements", 26 | "locally-disabled", 27 | "not-context-manager", 28 | "too-few-public-methods", 29 | "too-many-ancestors", 30 | "too-many-arguments", 31 | "too-many-instance-attributes", 32 | "too-many-lines", 33 | "too-many-locals", 34 | "too-many-public-methods", 35 | "too-many-boolean-expressions", 36 | "wrong-import-order", 37 | "consider-using-f-string", 38 | # "consider-using-namedtuple-or-dataclass", 39 | # "consider-using-assignment-expr", 40 | 41 | # Handled by ruff 42 | # Ref: 43 | "await-outside-async", # PLE1142 44 | "bad-str-strip-call", # PLE1310 45 | "bad-string-format-type", # PLE1307 46 | "bidirectional-unicode", # PLE2502 47 | "continue-in-finally", # PLE0116 48 | "duplicate-bases", # PLE0241 49 | "format-needs-mapping", # F502 50 | "function-redefined", # F811 51 | # Needed because ruff does not understand type of __all__ generated by a function 52 | # "invalid-all-format", # PLE0605 53 | "invalid-all-object", # PLE0604 54 | "invalid-character-backspace", # PLE2510 55 | "invalid-character-esc", # PLE2513 56 | "invalid-character-nul", # PLE2514 57 | "invalid-character-sub", # PLE2512 58 | "invalid-character-zero-width-space", # PLE2515 59 | "logging-too-few-args", # PLE1206 60 | "logging-too-many-args", # PLE1205 61 | "missing-format-string-key", # F524 62 | "mixed-format-string", # F506 63 | "no-method-argument", # N805 64 | "no-self-argument", # N805 65 | "nonexistent-operator", # B002 66 | "nonlocal-without-binding", # PLE0117 67 | "not-in-loop", # F701, F702 68 | "notimplemented-raised", # F901 69 | "return-in-init", # PLE0101 70 | "return-outside-function", # F706 71 | "syntax-error", # E999 72 | "too-few-format-args", # F524 73 | "too-many-format-args", # F522 74 | "too-many-star-expressions", # F622 75 | "truncated-format-string", # F501 76 | "undefined-all-variable", # F822 77 | "undefined-variable", # F821 78 | "used-prior-global-declaration", # PLE0118 79 | "yield-inside-async-function", # PLE1700 80 | "yield-outside-function", # F704 81 | "anomalous-backslash-in-string", # W605 82 | "assert-on-string-literal", # PLW0129 83 | "assert-on-tuple", # F631 84 | "bad-format-string", # W1302, F 85 | "bad-format-string-key", # W1300, F 86 | "bare-except", # E722 87 | "binary-op-exception", # PLW0711 88 | "cell-var-from-loop", # B023 89 | # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work 90 | "duplicate-except", # B014 91 | "duplicate-key", # F601 92 | "duplicate-string-formatting-argument", # F 93 | "duplicate-value", # F 94 | "eval-used", # PGH001 95 | "exec-used", # S102 96 | # "expression-not-assigned", # B018, ruff catches new occurrences, needs more work 97 | "f-string-without-interpolation", # F541 98 | "forgotten-debug-statement", # T100 99 | "format-string-without-interpolation", # F 100 | # "global-statement", # PLW0603, ruff catches new occurrences, needs more work 101 | "global-variable-not-assigned", # PLW0602 102 | "implicit-str-concat", # ISC001 103 | "import-self", # PLW0406 104 | "inconsistent-quotes", # Q000 105 | "invalid-envvar-default", # PLW1508 106 | "keyword-arg-before-vararg", # B026 107 | "logging-format-interpolation", # G 108 | "logging-fstring-interpolation", # G 109 | "logging-not-lazy", # G 110 | "misplaced-future", # F404 111 | "named-expr-without-context", # PLW0131 112 | "nested-min-max", # PLW3301 113 | # "pointless-statement", # B018, ruff catches new occurrences, needs more work 114 | "raise-missing-from", # TRY200 115 | # "redefined-builtin", # A001, ruff is way more stricter, needs work 116 | "try-except-raise", # TRY302 117 | "unused-argument", # ARG001, we don't use it 118 | "unused-format-string-argument", #F507 119 | "unused-format-string-key", # F504 120 | "unused-import", # F401 121 | "unused-variable", # F841 122 | "useless-else-on-loop", # PLW0120 123 | "wildcard-import", # F403 124 | "bad-classmethod-argument", # N804 125 | "consider-iterating-dictionary", # SIM118 126 | "empty-docstring", # D419 127 | "invalid-name", # N815 128 | "line-too-long", # E501, disabled globally 129 | "missing-class-docstring", # D101 130 | "missing-final-newline", # W292 131 | "missing-function-docstring", # D103 132 | "missing-module-docstring", # D100 133 | "multiple-imports", #E401 134 | "singleton-comparison", # E711, E712 135 | "subprocess-run-check", # PLW1510 136 | "superfluous-parens", # UP034 137 | "ungrouped-imports", # I001 138 | "unidiomatic-typecheck", # E721 139 | "unnecessary-direct-lambda-call", # PLC3002 140 | "unnecessary-lambda-assignment", # PLC3001 141 | "unneeded-not", # SIM208 142 | "useless-import-alias", # PLC0414 143 | "wrong-import-order", # I001 144 | "wrong-import-position", # E402 145 | "comparison-of-constants", # PLR0133 146 | "comparison-with-itself", # PLR0124 147 | # "consider-alternative-union-syntax", # UP007 148 | "consider-merging-isinstance", # PLR1701 149 | # "consider-using-alias", # UP006 150 | "consider-using-dict-comprehension", # C402 151 | "consider-using-generator", # C417 152 | "consider-using-get", # SIM401 153 | "consider-using-set-comprehension", # C401 154 | "consider-using-sys-exit", # PLR1722 155 | "consider-using-ternary", # SIM108 156 | "literal-comparison", # F632 157 | "property-with-parameters", # PLR0206 158 | "super-with-arguments", # UP008 159 | "too-many-branches", # PLR0912 160 | "too-many-return-statements", # PLR0911 161 | "too-many-statements", # PLR0915 162 | "trailing-comma-tuple", # COM818 163 | "unnecessary-comprehension", # C416 164 | "use-a-generator", # C417 165 | "use-dict-literal", # C406 166 | "use-list-literal", # C405 167 | "useless-object-inheritance", # UP004 168 | "useless-return", # PLR1711 169 | # "no-self-use", # PLR6301 # Optional plugin, not enabled 170 | 171 | # Handled by mypy 172 | # Ref: 173 | "abstract-class-instantiated", 174 | "arguments-differ", 175 | "assigning-non-slot", 176 | "assignment-from-no-return", 177 | "assignment-from-none", 178 | "bad-exception-cause", 179 | "bad-format-character", 180 | "bad-reversed-sequence", 181 | "bad-super-call", 182 | "bad-thread-instantiation", 183 | "catching-non-exception", 184 | "comparison-with-callable", 185 | "deprecated-class", 186 | "dict-iter-missing-items", 187 | "format-combined-specification", 188 | "global-variable-undefined", 189 | "import-error", 190 | "inconsistent-mro", 191 | "inherit-non-class", 192 | "init-is-generator", 193 | "invalid-class-object", 194 | "invalid-enum-extension", 195 | "invalid-envvar-value", 196 | "invalid-format-returned", 197 | "invalid-hash-returned", 198 | "invalid-metaclass", 199 | "invalid-overridden-method", 200 | "invalid-repr-returned", 201 | "invalid-sequence-index", 202 | "invalid-slice-index", 203 | "invalid-slots-object", 204 | "invalid-slots", 205 | "invalid-star-assignment-target", 206 | "invalid-str-returned", 207 | "invalid-unary-operand-type", 208 | "invalid-unicode-codec", 209 | "isinstance-second-argument-not-valid-type", 210 | "method-hidden", 211 | "misplaced-format-function", 212 | "missing-format-argument-key", 213 | "missing-format-attribute", 214 | "missing-kwoa", 215 | "no-member", 216 | "no-value-for-parameter", 217 | "non-iterator-returned", 218 | "non-str-assignment-to-dunder-name", 219 | "nonlocal-and-global", 220 | "not-a-mapping", 221 | "not-an-iterable", 222 | "not-async-context-manager", 223 | "not-callable", 224 | "not-context-manager", 225 | "overridden-final-method", 226 | "raising-bad-type", 227 | "raising-non-exception", 228 | "redundant-keyword-arg", 229 | "relative-beyond-top-level", 230 | "self-cls-assignment", 231 | "signature-differs", 232 | "star-needs-assignment-target", 233 | "subclassed-final-class", 234 | "super-without-brackets", 235 | "too-many-function-args", 236 | "typevar-double-variance", 237 | "typevar-name-mismatch", 238 | "unbalanced-dict-unpacking", 239 | "unbalanced-tuple-unpacking", 240 | "unexpected-keyword-arg", 241 | "unhashable-member", 242 | "unpacking-non-sequence", 243 | "unsubscriptable-object", 244 | "unsupported-assignment-operation", 245 | "unsupported-binary-operation", 246 | "unsupported-delete-operation", 247 | "unsupported-membership-test", 248 | "used-before-assignment", 249 | "using-final-decorator-in-unsupported-version", 250 | "wrong-exception-operation", 251 | ] 252 | enable = [ 253 | #"useless-suppression", # temporarily every now and then to clean them up 254 | "use-symbolic-message-instead", 255 | ] 256 | 257 | [tool.pytest.ini_options] 258 | asyncio_mode = "auto" 259 | 260 | [tool.ruff] 261 | required-version = ">=0.4.1" 262 | exclude = [".devcontainer", ".github", ".vscode", ".config"] 263 | 264 | [tool.ruff.lint] 265 | select = [ 266 | "A001", # Variable {name} is shadowing a Python builtin 267 | "B002", # Python does not support the unary prefix increment 268 | "B005", # Using .strip() with multi-character strings is misleading 269 | "B007", # Loop control variable {name} not used within loop body 270 | "B014", # Exception handler with duplicate exception 271 | "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. 272 | "B017", # pytest.raises(BaseException) should be considered evil 273 | "B018", # Found useless attribute access. Either assign it to a variable or remove it. 274 | "B023", # Function definition does not bind loop variable {name} 275 | "B026", # Star-arg unpacking after a keyword argument is strongly discouraged 276 | "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? 277 | "B904", # Use raise from to specify exception cause 278 | "B905", # zip() without an explicit strict= parameter 279 | "C", # complexity 280 | "COM818", # Trailing comma on bare tuple prohibited 281 | "D", # docstrings 282 | "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() 283 | "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) 284 | "E", # pycodestyle 285 | "F", # pyflakes/autoflake 286 | "FLY", # flynt 287 | "G", # flake8-logging-format 288 | "I", # isort 289 | "INP", # flake8-no-pep420 290 | "ISC", # flake8-implicit-str-concat 291 | "ICN001", # import concentions; {name} should be imported as {asname} 292 | "LOG", # flake8-logging 293 | "N804", # First argument of a class method should be named cls 294 | "N805", # First argument of a method should be named self 295 | "N815", # Variable {name} in class scope should not be mixedCase 296 | "PERF", # Perflint 297 | "PGH", # pygrep-hooks 298 | "PIE", # flake8-pie 299 | "PL", # pylint 300 | "PT", # flake8-pytest-style 301 | "PYI", # flake8-pyi 302 | "RET", # flake8-return 303 | "RSE", # flake8-raise 304 | "RUF005", # Consider iterable unpacking instead of concatenation 305 | "RUF006", # Store a reference to the return value of asyncio.create_task 306 | "RUF013", # PEP 484 prohibits implicit Optional 307 | "RUF018", # Avoid assignment expressions in assert statements 308 | "RUF019", # Unnecessary key check before dictionary access 309 | # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up 310 | "S102", # Use of exec detected 311 | "S103", # bad-file-permissions 312 | "S108", # hardcoded-temp-file 313 | "S306", # suspicious-mktemp-usage 314 | "S307", # suspicious-eval-usage 315 | "S313", # suspicious-xmlc-element-tree-usage 316 | "S314", # suspicious-xml-element-tree-usage 317 | "S315", # suspicious-xml-expat-reader-usage 318 | "S316", # suspicious-xml-expat-builder-usage 319 | "S317", # suspicious-xml-sax-usage 320 | "S318", # suspicious-xml-mini-dom-usage 321 | "S319", # suspicious-xml-pull-dom-usage 322 | "S320", # suspicious-xmle-tree-usage 323 | "S601", # paramiko-call 324 | "S602", # subprocess-popen-with-shell-equals-true 325 | "S604", # call-with-shell-equals-true 326 | "S608", # hardcoded-sql-expression 327 | "S609", # unix-command-wildcard-injection 328 | "SIM", # flake8-simplify 329 | "SLOT", # flake8-slots 330 | "T100", # Trace found: {name} used 331 | "T20", # flake8-print 332 | "TID251", # Banned imports 333 | "TRY", # tryceratops 334 | "UP", # pyupgrade 335 | "W", # pycodestyle 336 | ] 337 | 338 | ignore = [ 339 | "D202", # No blank lines allowed after function docstring 340 | "D203", # 1 blank line required before class docstring 341 | "D213", # Multi-line docstring summary should start at the second line 342 | "D406", # Section name should end with a newline 343 | "D407", # Section name underlining 344 | "E501", # line too long 345 | 346 | "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives 347 | "PLR0911", # Too many return statements ({returns} > {max_returns}) 348 | "PLR0912", # Too many branches ({branches} > {max_branches}) 349 | "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) 350 | "PLR0915", # Too many statements ({statements} > {max_statements}) 351 | "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable 352 | "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target 353 | "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception 354 | "PT012", # `pytest.raises()` block should contain a single simple statement 355 | "PT018", # Assertion should be broken down into multiple parts 356 | "RUF001", # String contains ambiguous unicode character. 357 | "RUF002", # Docstring contains ambiguous unicode character. 358 | "RUF003", # Comment contains ambiguous unicode character. 359 | "RUF015", # Prefer next(...) over single element slice 360 | "SIM102", # Use a single if statement instead of nested if statements 361 | "SIM108", # Use ternary operator {contents} instead of if-else-block 362 | "SIM115", # Use context handler for opening files 363 | "TRY003", # Avoid specifying long messages outside the exception class 364 | "TRY400", # Use `logging.exception` instead of `logging.error` 365 | # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 366 | "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` 367 | # Ignored due to incompatible with mypy: https://github.com/python/mypy/issues/15238 368 | "UP040", # Checks for use of TypeAlias annotation for declaring type aliases. 369 | 370 | # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 371 | "W191", 372 | "E111", 373 | "E114", 374 | "E117", 375 | "D206", 376 | "D300", 377 | "Q", 378 | "COM812", 379 | "COM819", 380 | "ISC001", 381 | 382 | # Disabled because ruff does not understand type of __all__ generated by a function 383 | "PLE0605", 384 | 385 | # temporarily disabled 386 | "PT019", 387 | "PYI024", # Use typing.NamedTuple instead of collections.namedtuple 388 | "RET503", 389 | "RET502", 390 | "RET501", 391 | "TRY002", 392 | "TRY301", 393 | ] 394 | 395 | [tool.ruff.lint.flake8-import-conventions.extend-aliases] 396 | voluptuous = "vol" 397 | "homeassistant.helpers.area_registry" = "ar" 398 | "homeassistant.helpers.category_registry" = "cr" 399 | "homeassistant.helpers.config_validation" = "cv" 400 | "homeassistant.helpers.device_registry" = "dr" 401 | "homeassistant.helpers.entity_registry" = "er" 402 | "homeassistant.helpers.floor_registry" = "fr" 403 | "homeassistant.helpers.issue_registry" = "ir" 404 | "homeassistant.helpers.label_registry" = "lr" 405 | "homeassistant.util.dt" = "dt_util" 406 | 407 | [tool.ruff.lint.flake8-pytest-style] 408 | fixture-parentheses = false 409 | mark-parentheses = false 410 | 411 | [tool.ruff.lint.flake8-tidy-imports.banned-api] 412 | "async_timeout".msg = "use asyncio.timeout instead" 413 | "pytz".msg = "use zoneinfo instead" 414 | 415 | [tool.ruff.lint.isort] 416 | force-sort-within-sections = true 417 | known-first-party = ["homeassistant"] 418 | combine-as-imports = true 419 | split-on-trailing-comma = false 420 | 421 | [tool.ruff.lint.per-file-ignores] 422 | 423 | # Allow for main entry & scripts to write to stdout 424 | "homeassistant/__main__.py" = ["T201"] 425 | "homeassistant/scripts/*" = ["T201"] 426 | "script/*" = ["T20"] 427 | 428 | [tool.ruff.lint.mccabe] 429 | max-complexity = 25 430 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | holidays -------------------------------------------------------------------------------- /requirements_ha.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | colorlog==6.8.2 3 | pip>=21.3.1 4 | ruff==0.8.3 5 | pre-commit==4.0.0 6 | zlib_ng 7 | zeroconf 8 | defusedxml 9 | mutagen 10 | homeassistant 11 | -------------------------------------------------------------------------------- /screenshot_debug_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualdj/pun_sensor/6dfc82614830b623d4927aef6643160a2e304ac4/screenshot_debug_1.png -------------------------------------------------------------------------------- /screenshot_debug_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualdj/pun_sensor/6dfc82614830b623d4927aef6643160a2e304ac4/screenshot_debug_2.png -------------------------------------------------------------------------------- /screenshots_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualdj/pun_sensor/6dfc82614830b623d4927aef6643160a2e304ac4/screenshots_main.png -------------------------------------------------------------------------------- /screenshots_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualdj/pun_sensor/6dfc82614830b623d4927aef6643160a2e304ac4/screenshots_settings.png -------------------------------------------------------------------------------- /set_manifest.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Check for git and python3 4 | for command in git python3 5 | do 6 | if ! command -v "$command" &> /dev/null 7 | then 8 | echo "$command is not found in your PATH." 9 | exit 1 10 | fi 11 | done 12 | 13 | # Get the latest tag name 14 | latest_tag=$(git describe --tags --abbrev=0) 15 | echo "Latest tag (might be wrong): $latest_tag" 16 | 17 | # Patch the manifest 18 | python3 .github/workflows/update_manifest.py --version $latest_tag 19 | --------------------------------------------------------------------------------