diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4f8c128..b2d524f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,14 +6,12 @@ "PYTHONASYNCIODEBUG": "1" }, "runArgs": ["-e", "GIT_EDITOR=code --wait"], - "forwardPorts": [ - 8123 - ], + "forwardPorts": [8123], "portsAttributes": { - "8123": { - "label": "Home Assistant", - "onAutoForward": "notify" - } + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } }, "customizations": { "vscode": { @@ -25,8 +23,9 @@ "ms-python.vscode-pylance", "redhat.vscode-yaml", "esbenp.prettier-vscode", - "ryanluker.vscode-coverage-gutters", - "thibault-vanderseypen.i18n-json-editor" + "thibault-vanderseypen.i18n-json-editor", + "eamodio.gitlens", + "ms-python.mypy-type-checker" ], "settings": { "files.eol": "\n", @@ -39,6 +38,7 @@ "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true }, + "python.linting.mypyArgs": ["--cache-dir=.mypy_cache"], "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, @@ -47,16 +47,13 @@ "files.trimTrailingWhitespace": false }, "i18nJsonEditor.forceKeyUPPERCASE": false, - "i18nJsonEditor.supportedFolders": [ - "translations", - "i18n" - ] + "i18nJsonEditor.supportedFolders": ["translations", "i18n"] } } }, "remoteUser": "vscode", "features": { - "ghcr.io/devcontainers/features/rust:1": {} - //"ghcr.io/devcontainers-contrib/features/ffmpeg-apt-get:1": {} + "ghcr.io/devcontainers/features/rust:1": {} + //"ghcr.io/devcontainers-contrib/features/ffmpeg-apt-get:1": {} } } diff --git a/.devcontainer/scripts/clear-config b/.devcontainer/scripts/clear-config new file mode 100644 index 0000000..9333bf2 --- /dev/null +++ b/.devcontainer/scripts/clear-config @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/../.." + +# Termina Home Assistant se in esecuzione +if pgrep hass; then pkill hass; fi + +# Elimina i file nella cartella 'config' (escluso il file 'configuration.yaml') +find config -mindepth 1 ! -name 'configuration.yaml' -exec rm -rf {} + +echo "Configuration cleared." diff --git a/.devcontainer/scripts/type-check b/.devcontainer/scripts/type-check new file mode 100644 index 0000000..2728d62 --- /dev/null +++ b/.devcontainer/scripts/type-check @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/../.." + +echo "Be patient, may take several minutes..." +mypy --cache-dir=.mypy_cache custom_components/pun_sensor \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 94a9188..b0cc496 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -15,7 +15,7 @@ categories: - 'bug' - title: '🧰 Manutenzione' - label: + labels: - 'dependencies' - 'documentation' - 'maintenance' diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml index 09823d1..34658de 100644 --- a/.github/workflows/release-drafter.yaml +++ b/.github/workflows/release-drafter.yaml @@ -5,6 +5,7 @@ on: branches: - master + jobs: update_release_draft: name: Update release draft diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index eb0c484..e0dc52b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,7 +2,7 @@ name: Release on: release: -# types: [prereleased,published] + # types: [prereleased,published] types: [published] jobs: diff --git a/.gitignore b/.gitignore index 2f367cf..6a4cfc1 100644 --- a/.gitignore +++ b/.gitignore @@ -130,4 +130,4 @@ dmypy.json # Home Assistant Config /config/* -!/config/configuration.yaml \ No newline at end of file +!/config/configuration.yaml diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9942129..2c0fe78 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -19,6 +19,12 @@ "command": "pkill hass ; .devcontainer/scripts/develop", "problemMatcher": [] }, + { + "label": "Clear Home Assistant config", + "type": "shell", + "command": ".devcontainer/scripts/clear-config", + "problemMatcher": [] + }, { "label": "Upgrade Home Assistant to latest (beta)", "type": "shell", @@ -42,6 +48,12 @@ "type": "shell", "command": ".devcontainer/scripts/lint", "problemMatcher": [] + }, + { + "label": "Type-check with mypy", + "type": "shell", + "command": ".devcontainer/scripts/type-check", + "problemMatcher": [] } ] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..75913cb --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,26 @@ +# Note di sviluppo + +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**. + +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. +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). + +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: + +- [zaubererty/homeassistant-mvpv](https://github.com/zaubererty/homeassistant-mvpv/blob/d124543a36ab90b94b85a2211f41fee5943239ac/custom_components/mypv/coordinator.py) +- [Gradecak/spaarnelanden-containers](https://github.com/Gradecak/spaarnelanden-containers/blob/39db00072bdd4f99d1cf543fba314d161147259c/custom_components/spaarnelanden/sensor.py) +- [nintendo_wishlist](https://github.com/custom-components/sensor.nintendo_wishlist/tree/main/custom_components/nintendo_wishlist) +- [saso5/homeassistant-mojelektro](https://github.com/saso5/homeassistant-mojelektro/tree/d747e74a842be5697494da6403a1055fcb4322bf/custom_components/mojelektro) +- [YodaDaCoda/hass-solarman-modbus](https://github.com/YodaDaCoda/hass-solarman-modbus/blob/36ebd2d7eef7834867805ae01de433e8f8ab2ddb/custom_components/solarman/config_flow.py) +- [bruxy70/Garbage-Collection](https://github.com/bruxy70/Garbage-Collection/blob/ae73818b3b0786ebcf72b16a6f27428e516686e6/custom_components/garbage_collection/sensor.py) +- [dcmeglio/alarmdecoder-hass](https://github.com/dcmeglio/alarmdecoder-hass/blob/a898ae18cc5562b2a5fc3a73511302b6d242fd07/custom_components/alarmdecoder/__init__.py) +- [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) +- [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` +- 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 +- 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 +- 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` +- 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 +- 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/) +- [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? +- 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 +- 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. diff --git a/README.md b/README.md index c0ac00a..df0e516 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ [![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) [![release](https://img.shields.io/github/v/release/virtualdj/pun_sensor?style=flat-square)](https://github.com/virtualdj/pun_sensor/releases) -Integrazione per **Home Assistant** (basata sullo script [pun-fasce](https://github.com/virtualdj/pun-fasce)) che mostra i prezzi stimati del mese corrente per fasce orarie (F1, F2 e F3 e mono-oraria) nonché la fascia oraria attuale. +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. -I valori vengono scaricati dal sito [MercatoElettrico.org](https://storico.mercatoelettrico.org/It/Default.aspx) 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). +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). ## Installazione in Home Assistant @@ -21,19 +21,19 @@ Dopo l'aggiunta dell'integrazione oppure cliccando il pulsante _Configurazione_ ![Screenshot impostazioni](screenshots_settings.png "Impostazioni") -Qui è possibile selezionare un'ora del giorno in cui scaricare i prezzi aggiornati dell'energia (default: 1). Nel caso il sito non fosse raggiungibile, verranno effettuati altri tentativi dopo 10, 60, 120 e 180 minuti. +Qui è 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é non l'ora non viene modificata). Nel caso per qualche ragione il sito non fosse raggiungibile, verranno effettuati altri tentativi dopo 10, 60, 120 e 180 minuti. 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. ### Aggiornamento manuale -È 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 dei dati. +È 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. ### Aspetto dei dati ![Screenshot integrazione](screenshots_main.png "Dati visualizzati") -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 e il prezzo della fascia corrente. +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\* e il prezzo della fascia corrente. ### Prezzo al dettaglio @@ -53,7 +53,7 @@ template: {{ (1.1 * (states('sensor.pun_prezzo_fascia_corrente')|float(0) + 0.0087 + 0.04 + 0.0227))|round(3) }} ``` -### Fascia F23 +### Fascia F23 (\*) 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). Pertanto, seppur questo metodo non sia ufficiale, è stato implementato perché i risultati corrispondono sempre alle tabelle pubblicate online. @@ -72,24 +72,4 @@ Dopo che si verifica il problema, premerlo nuovamente: in questo modo verrà sca ## Note di sviluppo -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**. - -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. -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). - -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: - -- [zaubererty/homeassistant-mvpv](https://github.com/zaubererty/homeassistant-mvpv/blob/d124543a36ab90b94b85a2211f41fee5943239ac/custom_components/mypv/coordinator.py) -- [Gradecak/spaarnelanden-containers](https://github.com/Gradecak/spaarnelanden-containers/blob/39db00072bdd4f99d1cf543fba314d161147259c/custom_components/spaarnelanden/sensor.py) -- [nintendo_wishlist](https://github.com/custom-components/sensor.nintendo_wishlist/tree/main/custom_components/nintendo_wishlist) -- [saso5/homeassistant-mojelektro](https://github.com/saso5/homeassistant-mojelektro/tree/d747e74a842be5697494da6403a1055fcb4322bf/custom_components/mojelektro) -- [YodaDaCoda/hass-solarman-modbus](https://github.com/YodaDaCoda/hass-solarman-modbus/blob/36ebd2d7eef7834867805ae01de433e8f8ab2ddb/custom_components/solarman/config_flow.py) -- [bruxy70/Garbage-Collection](https://github.com/bruxy70/Garbage-Collection/blob/ae73818b3b0786ebcf72b16a6f27428e516686e6/custom_components/garbage_collection/sensor.py) -- [dcmeglio/alarmdecoder-hass](https://github.com/dcmeglio/alarmdecoder-hass/blob/a898ae18cc5562b2a5fc3a73511302b6d242fd07/custom_components/alarmdecoder/__init__.py) -- [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) -- [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` -- 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 -- 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 -- 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` -- 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 -- 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/) +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. diff --git a/config/configuration.yaml b/config/configuration.yaml index a144e78..c148f66 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -4,6 +4,8 @@ default_config: logger: default: info logs: + homeassistant.setup: warning + homeassistant.components.go2rtc: fatal custom_components.pun_sensor: debug # Disable ffmpeg diff --git a/custom_components/pun_sensor/__init__.py b/custom_components/pun_sensor/__init__.py index 3744e45..6621f8f 100644 --- a/custom_components/pun_sensor/__init__.py +++ b/custom_components/pun_sensor/__init__.py @@ -1,60 +1,37 @@ -"""Prezzi PUN del mese""" -from datetime import date, timedelta, datetime -import holidays -from statistics import mean -import zipfile, io -from bs4 import BeautifulSoup -import defusedxml.ElementTree as et -from typing import Tuple -from functools import partial +"""Prezzi PUN del mese.""" -from aiohttp import ClientSession -from homeassistant.core import HomeAssistant -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import ( - DataUpdateCoordinator, - UpdateFailed, -) -from homeassistant.helpers.event import async_track_point_in_time, async_call_later -import homeassistant.util.dt as dt_util -from zoneinfo import ZoneInfo +from datetime import timedelta +import logging from awesomeversion.awesomeversion import AwesomeVersion +from holidays import country_holidays + +from homeassistant.config_entries import ConfigEntry from homeassistant.const import __version__ as HA_VERSION -if (AwesomeVersion(HA_VERSION) >= AwesomeVersion("2024.5.0")): - from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later, async_track_point_in_time +import homeassistant.util.dt as dt_util -from .const import ( - DOMAIN, - PUN_FASCIA_MONO, - PUN_FASCIA_F23, - PUN_FASCIA_F1, - PUN_FASCIA_F2, - PUN_FASCIA_F3, - CONF_SCAN_HOUR, - CONF_ACTUAL_DATA_ONLY, - COORD_EVENT, - EVENT_UPDATE_FASCIA, - EVENT_UPDATE_PUN -) +from .const import CONF_ACTUAL_DATA_ONLY, CONF_SCAN_HOUR, DOMAIN, WEB_RETRIES_MINUTES +from .coordinator import PUNDataUpdateCoordinator -import logging -_LOGGER = logging.getLogger(__name__) +if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2024.5.0"): + from homeassistant.setup import SetupPhases, async_pause_setup -# Usa sempre il fuso orario italiano (i dati del sito sono per il mercato italiano) -tz_pun = ZoneInfo('Europe/Rome') +# Ottiene il logger +_LOGGER = logging.getLogger(__name__) # Definisce i tipi di entità PLATFORMS: list[str] = ["sensor"] + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: - """Impostazione dell'integrazione da configurazione Home Assistant""" + """Impostazione dell'integrazione da configurazione Home Assistant.""" # Carica le dipendenze di holidays in background per evitare errori nel log - if (AwesomeVersion(HA_VERSION) >= AwesomeVersion("2024.5.0")): + if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2024.5.0"): with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - await hass.async_add_import_executor_job(holidays.IT) + await hass.async_add_import_executor_job(country_holidays, "IT") # Salva il coordinator nella configurazione coordinator = PUNDataUpdateCoordinator(hass, config) @@ -67,15 +44,18 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) # Schedula l'aggiornamento via web 10 secondi dopo l'avvio - coordinator.schedule_token = async_call_later(hass, timedelta(seconds=10), coordinator.update_pun) + coordinator.schedule_token = async_call_later( + hass, timedelta(seconds=10), coordinator.update_pun + ) # Registra il callback di modifica opzioni config.async_on_unload(config.add_update_listener(update_listener)) return True + async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: - """Rimozione dell'integrazione da Home Assistant""" - + """Rimozione dell'integrazione da Home Assistant.""" + # Scarica i sensori (disabilitando di conseguenza il coordinator) unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS) if unload_ok: @@ -83,23 +63,33 @@ async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: return unload_ok + async def update_listener(hass: HomeAssistant, config: ConfigEntry) -> None: - """Modificate le opzioni da Home Assistant""" + """Modificate le opzioni da Home Assistant.""" # Recupera il coordinator coordinator = hass.data[DOMAIN][config.entry_id] # Aggiorna le impostazioni del coordinator dalle opzioni if config.options[CONF_SCAN_HOUR] != coordinator.scan_hour: - # Modificata l'ora di scansione + # Modificata l'ora di scansione nelle opzioni coordinator.scan_hour = config.options[CONF_SCAN_HOUR] + # Rigenera il minuto di esecuzione + coordinator.update_scan_minutes_from_config( + hass=hass, config=config, new_minute=True + ) + # Calcola la data della prossima esecuzione (all'ora definita) - next_update_pun = dt_util.now().replace(hour=coordinator.scan_hour, - minute=0, second=0, microsecond=0) - if next_update_pun.hour < dt_util.now().hour: - # Se l'ora impostata è minore della corrente, schedula a domani - # (perciò se è uguale esegue subito l'aggiornamento) + now = dt_util.now() + next_update_pun = now.replace( + hour=coordinator.scan_hour, + minute=coordinator.scan_minute, + second=0, + microsecond=0, + ) + if next_update_pun <= now: + # Se l'evento è già trascorso, passa a domani alla stessa ora next_update_pun = next_update_pun + timedelta(days=1) # Annulla eventuali schedulazioni attive @@ -108,14 +98,21 @@ async def update_listener(hass: HomeAssistant, config: ConfigEntry) -> None: coordinator.schedule_token = None # Schedula la prossima esecuzione - coordinator.web_retries = 0 - coordinator.schedule_token = async_track_point_in_time(coordinator.hass, coordinator.update_pun, next_update_pun) - _LOGGER.debug('Prossimo aggiornamento web: %s', next_update_pun.strftime('%d/%m/%Y %H:%M:%S %z')) + coordinator.web_retries = WEB_RETRIES_MINUTES + coordinator.schedule_token = async_track_point_in_time( + coordinator.hass, coordinator.update_pun, next_update_pun + ) + _LOGGER.debug( + "Prossimo aggiornamento web: %s", + next_update_pun.strftime("%d/%m/%Y %H:%M:%S %z"), + ) if config.options[CONF_ACTUAL_DATA_ONLY] != coordinator.actual_data_only: # Modificata impostazione 'Usa dati reali' coordinator.actual_data_only = config.options[CONF_ACTUAL_DATA_ONLY] - _LOGGER.debug('Nuovo valore \'usa dati reali\': %s.', coordinator.actual_data_only) + _LOGGER.debug( + "Nuovo valore 'usa dati reali': %s.", coordinator.actual_data_only + ) # Annulla eventuali schedulazioni attive if coordinator.schedule_token is not None: @@ -123,381 +120,7 @@ async def update_listener(hass: HomeAssistant, config: ConfigEntry) -> None: coordinator.schedule_token = None # Esegue un nuovo aggiornamento immediatamente - coordinator.web_retries = 0 - coordinator.schedule_token = async_call_later(coordinator.hass, timedelta(seconds=5), coordinator.update_pun) - - -class PUNDataUpdateCoordinator(DataUpdateCoordinator): - session: ClientSession - - def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: - """Gestione dell'aggiornamento da Home Assistant""" - super().__init__( - hass, - _LOGGER, - # Nome dei dati (a fini di log) - name = DOMAIN, - # Nessun update_interval (aggiornamento automatico disattivato) + coordinator.web_retries = WEB_RETRIES_MINUTES + coordinator.schedule_token = async_call_later( + coordinator.hass, timedelta(seconds=5), coordinator.update_pun ) - - # Salva la sessione client e la configurazione - self.session = async_get_clientsession(hass) - - # Inizializza i valori di configurazione (dalle opzioni o dalla configurazione iniziale) - self.actual_data_only = config.options.get(CONF_ACTUAL_DATA_ONLY, config.data[CONF_ACTUAL_DATA_ONLY]) - self.scan_hour = config.options.get(CONF_SCAN_HOUR, config.data[CONF_SCAN_HOUR]) - - # Inizializza i valori di default - self.web_retries = 0 - self.schedule_token = None - self.pun = [0.0, 0.0, 0.0, 0.0, 0.0] - self.orari = [0, 0, 0, 0, 0] - self.fascia_corrente = None - self.prossimo_cambio_fascia = None - self.termine_prossima_fascia = None - self.fascia_successiva = None - _LOGGER.debug('Coordinator inizializzato (con \'usa dati reali\' = %s).', self.actual_data_only) - - async def _async_update_data(self): - """Aggiornamento dati a intervalli prestabiliti""" - - # Calcola l'intervallo di date per il mese corrente - date_end = dt_util.now().date() - date_start = date(date_end.year, date_end.month, 1) - - # All'inizio del mese, aggiunge i valori del mese precedente - # a meno che CONF_ACTUAL_DATA_ONLY non sia impostato - if (not self.actual_data_only) and (date_end.day < 4): - date_start = date_start - timedelta(days=3) - - # URL del sito Mercato elettrico - LOGIN_URL = 'https://storico.mercatoelettrico.org/It/Tools/Accessodati.aspx?ReturnUrl=%2fIt%2fdownload%2fDownloadDati.aspx%3fval%3dMGP_Prezzi&val=MGP_Prezzi' - DOWNLOAD_URL = 'https://storico.mercatoelettrico.org/It/download/DownloadDati.aspx?val=MGP_Prezzi' - - # Apre la pagina per generare i cookie e i campi nascosti - _LOGGER.debug('Connessione a URL login.') - async with self.session.get(LOGIN_URL) as response: - soup = await self.hass.async_add_executor_job( - partial(BeautifulSoup, await response.read(), features='html.parser') - ) - - # Recupera i campi nascosti __VIEWSTATE e __EVENTVALIDATION per la prossima richiesta - viewstate = soup.find('input',{'name':'__VIEWSTATE'})['value'] - eventvalidation = soup.find('input',{'name':'__EVENTVALIDATION'})['value'] - login_payload = { - 'ctl00$ContentPlaceHolder1$CBAccetto1': 'on', - 'ctl00$ContentPlaceHolder1$CBAccetto2': 'on', - 'ctl00$ContentPlaceHolder1$Button1': 'Accetto', - '__VIEWSTATE': viewstate, - '__EVENTVALIDATION': eventvalidation - } - - # Effettua il login (che se corretto porta alla pagina di download XML grazie al 'ReturnUrl') - _LOGGER.debug('Invio credenziali a URL login.') - async with self.session.post(LOGIN_URL, data=login_payload) as response: - soup = await self.hass.async_add_executor_job( - partial(BeautifulSoup, await response.read(), features='html.parser') - ) - - # Recupera i campi nascosti __VIEWSTATE per la prossima richiesta - viewstate = soup.find('input',{'name':'__VIEWSTATE'})['value'] - data_request_payload = { - 'ctl00$ContentPlaceHolder1$tbDataStart': date_start.strftime('%d/%m/%Y'), - 'ctl00$ContentPlaceHolder1$tbDataStop': date_end.strftime('%d/%m/%Y'), - 'ctl00$ContentPlaceHolder1$btnScarica': 'scarica+file+xml+compresso', - '__VIEWSTATE': viewstate - } - - # Effettua il download dello ZIP con i file XML - _LOGGER.debug('Inizio download file ZIP con XML.') - async with self.session.post(DOWNLOAD_URL, data=data_request_payload) as response: - # Scompatta lo ZIP in memoria - try: - archive = zipfile.ZipFile(io.BytesIO(await response.read())) - except: - # Esce perché l'output non è uno ZIP - raise UpdateFailed('Archivio ZIP scaricato dal sito non valido.') - - # Mostra i file nell'archivio - _LOGGER.debug(f'{ len(archive.namelist()) } file trovati nell\'archivio (' + ', '.join(str(fn) for fn in archive.namelist()) + ').') - - # Carica le festività - it_holidays = holidays.IT() - - # Inizializza le variabili di conteggio dei risultati - mono = [] - f1 = [] - f2 = [] - f3 = [] - - # Esamina ogni file XML nello ZIP (ordinandoli prima) - for fn in sorted(archive.namelist()): - # Scompatta il file XML in memoria - xml_tree = et.parse(archive.open(fn)) - - # Parsing dell'XML (1 file = 1 giorno) - xml_root = xml_tree.getroot() - - # Estrae la data dal primo elemento (sarà identica per gli altri) - dat_string = xml_root.find('Prezzi').find('Data').text #YYYYMMDD - - # Converte la stringa giorno in data - dat_date = date(int(dat_string[0:4]), int(dat_string[4:6]), int(dat_string[6:8])) - - # Verifica la festività - festivo = dat_date in it_holidays - - # Estrae le rimanenti informazioni - for prezzi in xml_root.iter('Prezzi'): - # Estrae l'ora dall'XML - ora = int(prezzi.find('Ora').text) - 1 # 1..24 - - # Estrae il prezzo PUN dall'XML in un float - prezzo_string = prezzi.find('PUN').text - prezzo_string = prezzo_string.replace('.','').replace(',','.') - prezzo = float(prezzo_string) / 1000 - - # Estrae la fascia oraria - fascia = get_fascia_for_xml(dat_date, festivo, ora) - - # Calcola le statistiche - mono.append(prezzo) - if fascia == 3: - f3.append(prezzo) - elif fascia == 2: - f2.append(prezzo) - elif fascia == 1: - f1.append(prezzo) - - # Salva i risultati nel coordinator - self.orari[PUN_FASCIA_MONO] = len(mono) - self.orari[PUN_FASCIA_F1] = len(f1) - self.orari[PUN_FASCIA_F2] = len(f2) - self.orari[PUN_FASCIA_F3] = len(f3) - if self.orari[PUN_FASCIA_MONO] > 0: - self.pun[PUN_FASCIA_MONO] = mean(mono) - if self.orari[PUN_FASCIA_F1] > 0: - self.pun[PUN_FASCIA_F1] = mean(f1) - if self.orari[PUN_FASCIA_F2] > 0: - self.pun[PUN_FASCIA_F2] = mean(f2) - if self.orari[PUN_FASCIA_F3] > 0: - self.pun[PUN_FASCIA_F3] = mean(f3) - - # Calcola la fascia F23 (a partire da F2 ed F3) - # NOTA: la motivazione del calcolo è oscura ma sembra corretta; vedere: - # https://github.com/virtualdj/pun_sensor/issues/24#issuecomment-1829846806 - if self.orari[PUN_FASCIA_F2] > 0 and self.orari[PUN_FASCIA_F3] > 0: - # Esistono dati sia per F2 che per F3 - self.orari[PUN_FASCIA_F23] = self.orari[PUN_FASCIA_F2] + self.orari[PUN_FASCIA_F3] - self.pun[PUN_FASCIA_F23] = 0.46 * self.pun[PUN_FASCIA_F2] + 0.54 * self.pun[PUN_FASCIA_F3] - else: - # Devono esserci dati sia per F2 che per F3 affinché il risultato sia valido - self.orari[PUN_FASCIA_F23] = 0 - self.pun[PUN_FASCIA_F23] = 0 - - # Logga i dati - _LOGGER.debug('Numero di dati: ' + ', '.join(str(i) for i in self.orari)) - _LOGGER.debug('Valori PUN: ' + ', '.join(str(f) for f in self.pun)) - return - - async def update_fascia(self, now=None): - """Aggiorna la fascia oraria corrente""" - - # Scrive l'ora corrente (a scopi di debug) - _LOGGER.debug('Ora corrente sistema: %s', dt_util.now().strftime('%a %d/%m/%Y %H:%M:%S %z')) - _LOGGER.debug('Ora corrente fuso orario italiano: %s', dt_util.now(time_zone=tz_pun).strftime('%a %d/%m/%Y %H:%M:%S %z')) - - # Ottiene la fascia oraria corrente e il prossimo aggiornamento - self.fascia_corrente, self.prossimo_cambio_fascia = get_fascia(dt_util.now(time_zone=tz_pun)) - - # Calcola la fascia futura ri-applicando lo stesso algoritmo - self.fascia_successiva, self.termine_prossima_fascia = get_fascia(self.prossimo_cambio_fascia) - - _LOGGER.info( - 'Nuova fascia corrente: F%s (prossima: F%s alle %s)', - self.fascia_corrente, - self.fascia_successiva, - self.prossimo_cambio_fascia.strftime('%a %d/%m/%Y %H:%M:%S %z') - ) - - # Notifica che i dati sono stati aggiornati (fascia) - self.async_set_updated_data({ COORD_EVENT: EVENT_UPDATE_FASCIA }) - - # Schedula la prossima esecuzione - async_track_point_in_time(self.hass, self.update_fascia, self.prossimo_cambio_fascia) - - async def update_pun(self, now=None): - """Aggiorna i prezzi PUN da Internet (funziona solo se schedulata)""" - - # Aggiorna i dati da web - try: - # Esegue l'aggiornamento - await self._async_update_data() - - # Se non ci sono eccezioni, ha avuto successo - self.web_retries = 0 - except Exception as e: - # Errori durante l'esecuzione dell'aggiornamento, riprova dopo - if (self.web_retries == 0): - # Primo errore, riprova dopo 1 minuto - self.web_retries = 5 - retry_in_minutes = 1 - elif (self.web_retries == 5): - # Secondo errore, riprova dopo 10 minuti - self.web_retries -= 1 - retry_in_minutes = 10 - elif (self.web_retries == 1): - # Ultimo errore, tentativi esauriti - self.web_retries = 0 - - # Schedula al giorno dopo - retry_in_minutes = 0 - else: - # Ulteriori errori (4, 3, 2) - self.web_retries -= 1 - retry_in_minutes = 60 * (4 - self.web_retries) - - # Annulla eventuali schedulazioni attive - if self.schedule_token is not None: - self.schedule_token() - self.schedule_token = None - - # Prepara la schedulazione - if (retry_in_minutes > 0): - # Minuti dopo - _LOGGER.warn('Errore durante l\'aggiornamento via web, nuovo tentativo tra %s minut%s.', retry_in_minutes, 'o' if retry_in_minutes == 1 else 'i', exc_info=e) - self.schedule_token = async_call_later(self.hass, timedelta(minutes=retry_in_minutes), self.update_pun) - else: - # Giorno dopo - _LOGGER.error('Errore durante l\'aggiornamento via web, tentativi esauriti.', exc_info=e) - next_update_pun = dt_util.now().replace(hour=self.scan_hour, - minute=0, second=0, microsecond=0) + timedelta(days=1) - self.schedule_token = async_track_point_in_time(self.hass, self.update_pun, next_update_pun) - _LOGGER.debug('Prossimo aggiornamento web: %s', next_update_pun.strftime('%d/%m/%Y %H:%M:%S %z')) - - # Esce e attende la prossima schedulazione - return - - # Notifica che i dati PUN sono stati aggiornati con successo - self.async_set_updated_data({ COORD_EVENT: EVENT_UPDATE_PUN }) - - # Calcola la data della prossima esecuzione - next_update_pun = dt_util.now().replace(hour=self.scan_hour, - minute=0, second=0, microsecond=0) - if next_update_pun <= dt_util.now(): - # Se l'evento è già trascorso la esegue domani alla stessa ora - next_update_pun = next_update_pun + timedelta(days=1) - - # Annulla eventuali schedulazioni attive - if self.schedule_token is not None: - self.schedule_token() - self.schedule_token = None - - # Schedula la prossima esecuzione - self.schedule_token = async_track_point_in_time(self.hass, self.update_pun, next_update_pun) - _LOGGER.debug('Prossimo aggiornamento web: %s', next_update_pun.strftime('%d/%m/%Y %H:%M:%S %z')) - -def get_fascia_for_xml(data, festivo, ora) -> int: - """Restituisce il numero di fascia oraria di un determinato giorno/ora""" - #F1 = lu-ve 8-19 - #F2 = lu-ve 7-8, lu-ve 19-23, sa 7-23 - #F3 = lu-sa 0-7, lu-sa 23-24, do, festivi - if festivo or (data.weekday() == 6): - # Festivi e domeniche - return 3 - elif (data.weekday() == 5): - # Sabato - if (ora >= 7) and (ora < 23): - return 2 - else: - return 3 - else: - # Altri giorni della settimana - if (ora == 7) or ((ora >= 19) and (ora < 23)): - return 2 - elif (ora == 23) or ((ora >= 0) and (ora < 7)): - return 3 - return 1 - -def get_fascia(dataora: datetime) -> Tuple[int, datetime]: - """Restituisce la fascia della data/ora indicata (o quella corrente) e la data del prossimo cambiamento""" - - # Verifica se la data corrente è un giorno con festività - festivo = dataora in holidays.IT() - - # Identifica la fascia corrente - # F1 = lu-ve 8-19 - # F2 = lu-ve 7-8, lu-ve 19-23, sa 7-23 - # F3 = lu-sa 0-7, lu-sa 23-24, do, festivi - if festivo or (dataora.weekday() == 6): - # Festivi e domeniche - fascia = 3 - - # Prossima fascia: alle 7 di un giorno non domenica o festività - prossima = (dataora + timedelta(days=1)).replace(hour=7, - minute=0, second=0, microsecond=0) - while ((prossima in holidays.IT()) or (prossima.weekday() == 6)): - prossima += timedelta(days=1) - - elif (dataora.weekday() == 5): - # Sabato - if (dataora.hour >= 7) and (dataora.hour < 23): - # Sabato dalle 7 alle 23 - fascia = 2 - - # Prossima fascia: alle 23 dello stesso giorno - prossima = dataora.replace(hour=23, - minute=0, second=0, microsecond=0) - elif (dataora.hour < 7): - # Sabato tra le 0 e le 7 - fascia = 3 - - # Prossima fascia: alle 7 dello stesso giorno - prossima = dataora.replace(hour=7, - minute=0, second=0, microsecond=0) - else: - # Sabato dopo le 23 - fascia = 3 - - # Prossima fascia: alle 7 di un giorno non domenica o festività - prossima = (dataora + timedelta(days=1)).replace(hour=7, - minute=0, second=0, microsecond=0) - while ((prossima in holidays.IT()) or (prossima.weekday() == 6)): - prossima += timedelta(days=1) - else: - # Altri giorni della settimana - if (dataora.hour == 7): - # Lunedì-venerdì dalle 7 alle 8 - fascia = 2 - - # Prossima fascia: alle 8 dello stesso giorno - prossima = dataora.replace(hour=8, - minute=0, second=0, microsecond=0) - - elif ((dataora.hour >= 19) and (dataora.hour < 23)): - # Lunedì-venerdì dalle 19 alle 23 - fascia = 2 - - # Prossima fascia: alle 23 dello stesso giorno - prossima = dataora.replace(hour=23, - minute=0, second=0, microsecond=0) - - elif ((dataora.hour == 23) or ((dataora.hour >= 0) and (dataora.hour < 7))): - # Lunedì-venerdì dalle 23 alle 24 e dalle 0 alle 7 - fascia = 3 - - # Prossima fascia: alle 7 di un giorno non domenica o festività - prossima = dataora.replace(hour=7, - minute=0, second=0, microsecond=0) - while ((prossima <= dataora) or (prossima in holidays.IT()) or (prossima.weekday() == 6)): - prossima += timedelta(days=1) - - else: - # Lunedì-venerdì dalle 8 alle 19 - fascia = 1 - - # Prossima fascia: alle 19 dello stesso giorno - prossima = dataora.replace(hour=19, - minute=0, second=0, microsecond=0) - - return fascia, prossima diff --git a/custom_components/pun_sensor/config_flow.py b/custom_components/pun_sensor/config_flow.py index 32d19ec..68654a3 100644 --- a/custom_components/pun_sensor/config_flow.py +++ b/custom_components/pun_sensor/config_flow.py @@ -1,56 +1,67 @@ +"""UI di configurazione per pun_sensor.""" + +import voluptuous as vol + from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -import voluptuous as vol -from .const import ( - DOMAIN, - CONF_SCAN_HOUR, - CONF_ACTUAL_DATA_ONLY, -) + +from .const import CONF_ACTUAL_DATA_ONLY, CONF_SCAN_HOUR, DOMAIN + class PUNOptionsFlow(config_entries.OptionsFlow): - """Opzioni per prezzi PUN (= riconfigurazione successiva)""" + """Opzioni per prezzi PUN (= riconfigurazione successiva).""" def __init__(self, entry: config_entries.ConfigEntry) -> None: - """Inizializzazione options flow""" + """Inizializzazione opzioni.""" self.config_entry = entry async def async_step_init(self, user_input=None) -> FlowResult: - """Gestisce le opzioni""" - errors = {} + """Gestisce le opzioni.""" + errors: dict[str, str] | None = {} if user_input is not None: # Configurazione valida (validazione integrata nello schema) - return self.async_create_entry( - title='PUN', - data=user_input - ) + return self.async_create_entry(title="PUN", data=user_input) # Schema dati di opzione (con default sui valori attuali) data_schema = { - vol.Required(CONF_SCAN_HOUR, default=self.config_entry.options.get(CONF_SCAN_HOUR, self.config_entry.data[CONF_SCAN_HOUR])): vol.All(cv.positive_int, vol.Range(min=0, max=23)), - vol.Optional(CONF_ACTUAL_DATA_ONLY, default=self.config_entry.options.get(CONF_ACTUAL_DATA_ONLY, self.config_entry.data[CONF_ACTUAL_DATA_ONLY])): cv.boolean, + vol.Required( + CONF_SCAN_HOUR, + default=self.config_entry.options.get( + CONF_SCAN_HOUR, self.config_entry.data[CONF_SCAN_HOUR] + ), + ): vol.All(cv.positive_int, vol.Range(min=0, max=23)), + vol.Optional( + CONF_ACTUAL_DATA_ONLY, + default=self.config_entry.options.get( + CONF_ACTUAL_DATA_ONLY, self.config_entry.data[CONF_ACTUAL_DATA_ONLY] + ), + ): cv.boolean, } # Mostra la schermata di configurazione, con gli eventuali errori return self.async_show_form( step_id="init", data_schema=vol.Schema(data_schema), errors=errors ) - + + class PUNConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Configurazione per prezzi PUN (= prima configurazione)""" + """Configurazione per prezzi PUN (= prima configurazione).""" # Versione della configurazione (per utilizzi futuri) VERSION = 1 @staticmethod @callback - def async_get_options_flow(entry: config_entries.ConfigEntry) -> PUNOptionsFlow: - """Ottiene le opzioni per questa configurazione""" - return PUNOptionsFlow(entry) + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> PUNOptionsFlow: + """Ottiene le opzioni per questa configurazione.""" + return PUNOptionsFlow(config_entry) async def async_step_user(self, user_input=None): - """Gestione prima configurazione da Home Assistant""" + """Gestione prima configurazione da Home Assistant.""" # Controlla che l'integrazione non venga eseguita più volte await self.async_set_unique_id(DOMAIN) self._abort_if_unique_id_configured() @@ -58,14 +69,13 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: # Configurazione valida (validazione integrata nello schema) - return self.async_create_entry( - title='PUN', - data=user_input - ) + return self.async_create_entry(title="PUN", data=user_input) # Schema dati di configurazione (con default fissi) data_schema = { - vol.Required(CONF_SCAN_HOUR, default=1): vol.All(cv.positive_int, vol.Range(min=0, max=23)), + vol.Required(CONF_SCAN_HOUR, default=1): vol.All( + cv.positive_int, vol.Range(min=0, max=23) + ), vol.Optional(CONF_ACTUAL_DATA_ONLY, default=False): cv.boolean, } @@ -73,4 +83,3 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", data_schema=vol.Schema(data_schema), errors=errors ) - diff --git a/custom_components/pun_sensor/const.py b/custom_components/pun_sensor/const.py index 42f57e4..cb8b70f 100644 --- a/custom_components/pun_sensor/const.py +++ b/custom_components/pun_sensor/const.py @@ -1,3 +1,5 @@ +"""Costanti utilizzate da pun_sensor.""" + # Dominio HomeAssistant DOMAIN = "pun_sensor" @@ -8,6 +10,9 @@ PUN_FASCIA_F3 = 3 PUN_FASCIA_F23 = 4 +# Intervalli di tempo per i tentativi +WEB_RETRIES_MINUTES = [1, 10, 60, 120, 180] + # Tipi di aggiornamento COORD_EVENT = "coordinator_event" EVENT_UPDATE_FASCIA = "event_update_fascia" @@ -16,3 +21,6 @@ # Parametri configurabili da configuration.yaml CONF_SCAN_HOUR = "scan_hour" CONF_ACTUAL_DATA_ONLY = "actual_data_only" + +# Parametri interni +CONF_SCAN_MINUTE = "scan_minute" diff --git a/custom_components/pun_sensor/coordinator.py b/custom_components/pun_sensor/coordinator.py new file mode 100644 index 0000000..41c8832 --- /dev/null +++ b/custom_components/pun_sensor/coordinator.py @@ -0,0 +1,334 @@ +"""Coordinator per pun_sensor.""" + +from datetime import date, datetime, timedelta +import io +import logging +import random +from statistics import mean +import zipfile + +from aiohttp import ClientSession, ServerConnectionError +from zoneinfo import ZoneInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_call_later, async_track_point_in_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_ACTUAL_DATA_ONLY, + CONF_SCAN_HOUR, + CONF_SCAN_MINUTE, + COORD_EVENT, + DOMAIN, + EVENT_UPDATE_FASCIA, + EVENT_UPDATE_PUN, + WEB_RETRIES_MINUTES, +) +from .interfaces import Fascia, PunData, PunValues +from .utils import extract_xml, get_fascia, get_next_date + +# Ottiene il logger +_LOGGER = logging.getLogger(__name__) + +# Usa sempre il fuso orario italiano (i dati del sito sono per il mercato italiano) +tz_pun = ZoneInfo("Europe/Rome") + + +class PUNDataUpdateCoordinator(DataUpdateCoordinator): + """Classe coordinator di aggiornamento dati.""" + + session: ClientSession + + def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: + """Gestione dell'aggiornamento da Home Assistant.""" + super().__init__( + hass, + _LOGGER, + # Nome dei dati (a fini di log) + name=DOMAIN, + # Nessun update_interval (aggiornamento automatico disattivato) + ) + + # Salva la sessione client e la configurazione + self.session = async_get_clientsession(hass) + + # Inizializza i valori di configurazione (dalle opzioni o dalla configurazione iniziale) + self.actual_data_only = config.options.get( + CONF_ACTUAL_DATA_ONLY, config.data[CONF_ACTUAL_DATA_ONLY] + ) + self.scan_hour = config.options.get(CONF_SCAN_HOUR, config.data[CONF_SCAN_HOUR]) + + # Carica il minuto di esecuzione dalla configurazione (o lo crea se non esiste) + self.scan_minute = 0 + self.update_scan_minutes_from_config(hass=hass, config=config, new_minute=False) + + # Inizializza i valori di default + self.web_retries = WEB_RETRIES_MINUTES + self.schedule_token = None + self.pun_data: PunData = PunData() + self.pun_values: PunValues = PunValues() + self.fascia_corrente: Fascia | None = None + self.fascia_successiva: Fascia | None = None + self.prossimo_cambio_fascia: datetime | None = None + self.termine_prossima_fascia: datetime | None = None + + _LOGGER.debug( + "Coordinator inizializzato (con 'usa dati reali' = %s).", + self.actual_data_only, + ) + + def clean_tokens(self): + """Annulla eventuali schedulazioni attive.""" + if self.schedule_token is not None: + self.schedule_token() + self.schedule_token = None + + def update_scan_minutes_from_config( + self, hass: HomeAssistant, config: ConfigEntry, new_minute: bool = False + ): + """Imposta il minuto di aggiornamento nell'ora configurata. + + Determina casualmente in quale minuto eseguire l'aggiornamento web + per evitare che le integrazioni di tutti gli utenti richiamino le API nello + stesso momento, a parità di ora. + """ + + # Controlla se estrarre a caso i minuti + if new_minute or (CONF_SCAN_MINUTE not in config.data): + # Genera un minuto casuale e lo inserisce nella configurazione + self.scan_minute = random.randint(0, 59) + new_data = { + **config.data, + CONF_SCAN_MINUTE: self.scan_minute, + } + + @callback + def async_update_entry() -> None: + """Aggiorna la configurazione con i nuovi dati.""" + self.hass.config_entries.async_update_entry(config, data=new_data) + + # Accoda l'esecuzione del salvataggio dell'impostazione + hass.add_job(async_update_entry) + else: + # Carica i minuti dalla configurazione + self.scan_minute = config.data.get(CONF_SCAN_MINUTE, 0) + + async def _async_update_data(self): + """Aggiornamento dati a intervalli prestabiliti.""" + + # Calcola l'intervallo di date per il mese corrente + date_end = dt_util.now().date() + date_start = date(date_end.year, date_end.month, 1) + + # All'inizio del mese, aggiunge i valori del mese precedente + # a meno che CONF_ACTUAL_DATA_ONLY non sia impostato + if (not self.actual_data_only) and (date_end.day < 4): + date_start = date_start - timedelta(days=3) + + start_date_param = date_start.strftime("%Y%m%d") + end_date_param = date_end.strftime("%Y%m%d") + + # URL del sito Mercato elettrico + 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" + + # Imposta gli header della richiesta + heads = { + "moduleid": "12103", + "referrer": "https://gme.mercatoelettrico.org/en-us/Home/Results/Electricity/MGP/Download?valore=Prezzi", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "Windows", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "tabid": "1749", + "userid": "-1", + } + + # Effettua il download dello ZIP con i file XML + _LOGGER.debug("Inizio download file ZIP con XML.") + async with self.session.get(download_url, headers=heads) as response: + # Aspetta la request + bytes_response = await response.read() + + # Se la richiesta NON e' andata a buon fine ritorna l'errore subito + if response.status != 200: + _LOGGER.error("Richiesta fallita con errore %s", response.status) + raise ServerConnectionError( + f"Richiesta fallita con errore {response.status}" + ) + + # La richiesta e' andata a buon fine, tenta l'estrazione + try: + archive = zipfile.ZipFile(io.BytesIO(bytes_response), "r") + + # Ritorna error se l'output non è uno ZIP, o ha un errore IO + except (zipfile.BadZipfile, OSError) as e: # not a zip: + _LOGGER.error( + "Download fallito con URL: %s, lunghezza %s, risposta %s", + download_url, + response.content_length, + response.status, + ) + raise UpdateFailed("Archivio ZIP scaricato dal sito non valido.") from e + + # Mostra i file nell'archivio + _LOGGER.debug( + "%s file trovati nell'archivio (%s)", + len(archive.namelist()), + ", ".join(str(fn) for fn in archive.namelist()), + ) + + # Estrae i dati dall'archivio + self.pun_data = extract_xml(archive, self.pun_data) + + # Per ogni fascia, calcola il valore del pun + for fascia, value_list in self.pun_data.pun.items(): + # Se abbiamo valori nella fascia + if len(value_list) > 0: + # Calcola la media dei pun e aggiorna il valore del pun attuale + # per la fascia corrispondente + self.pun_values.value[fascia] = mean(self.pun_data.pun[fascia]) + else: + # Skippiamo i dict se vuoti + pass + + # Calcola la fascia F23 (a partire da F2 ed F3) + # NOTA: la motivazione del calcolo è oscura ma sembra corretta; vedere: + # https://github.com/virtualdj/pun_sensor/issues/24#issuecomment-1829846806 + if ( + len(self.pun_data.pun[Fascia.F2]) and len(self.pun_data.pun[Fascia.F3]) + ) > 0: + self.pun_values.value[Fascia.F23] = ( + 0.46 * self.pun_values.value[Fascia.F2] + + 0.54 * self.pun_values.value[Fascia.F3] + ) + else: + self.pun_values.value[Fascia.F23] = 0 + + # Logga i dati + _LOGGER.debug( + "Numero di dati: %s", + ", ".join(str(len(i)) for i in self.pun_data.pun.values()), + ) + _LOGGER.debug( + "Valori PUN: %s", ", ".join(str(f) for f in self.pun_values.value.values()) + ) + + async def update_fascia(self, now=None): + """Aggiorna la fascia oraria corrente.""" + + # Scrive l'ora corrente (a scopi di debug) + _LOGGER.debug( + "Ora corrente sistema: %s", + dt_util.now().strftime("%a %d/%m/%Y %H:%M:%S %z"), + ) + _LOGGER.debug( + "Ora corrente fuso orario italiano: %s", + dt_util.now(time_zone=tz_pun).strftime("%a %d/%m/%Y %H:%M:%S %z"), + ) + + # Ottiene la fascia oraria corrente e il prossimo aggiornamento + self.fascia_corrente, self.prossimo_cambio_fascia = get_fascia( + dt_util.now(time_zone=tz_pun) + ) + + # Calcola la fascia futura ri-applicando lo stesso algoritmo + self.fascia_successiva, self.termine_prossima_fascia = get_fascia( + self.prossimo_cambio_fascia + ) + _LOGGER.info( + "Nuova fascia corrente: %s (prossima: %s alle %s)", + self.fascia_corrente.value, + self.fascia_successiva.value, + self.prossimo_cambio_fascia.strftime("%a %d/%m/%Y %H:%M:%S %z"), + ) + + # Notifica che i dati sono stati aggiornati (fascia) + self.async_set_updated_data({COORD_EVENT: EVENT_UPDATE_FASCIA}) + + # Schedula la prossima esecuzione + async_track_point_in_time( + self.hass, self.update_fascia, self.prossimo_cambio_fascia + ) + + async def update_pun(self, now=None): + """Aggiorna i prezzi PUN da Internet (funziona solo se schedulata).""" + # Aggiorna i dati da web + try: + # Esegue l'aggiornamento + await self._async_update_data() + + # Se non ci sono eccezioni, ha avuto successo + # Ricarica i tentativi per la prossima esecuzione + self.web_retries = WEB_RETRIES_MINUTES + + # Errore nel fetch dei dati se la response non e' 200 + # pylint: disable=broad-exception-caught + except (Exception, UpdateFailed, ServerConnectionError) as e: + # Errori durante l'esecuzione dell'aggiornamento, riprova dopo + # Annulla eventuali schedulazioni attive + self.clean_tokens() + + # Prepara la schedulazione + if self.web_retries: + # Minuti dopo + retry_in_minutes = self.web_retries.pop(0) + _LOGGER.warning( + "Errore durante l'aggiornamento dei dati, nuovo tentativo tra %s minut%s.", + retry_in_minutes, + "o" if retry_in_minutes == 1 else "i", + exc_info=e, + ) + self.schedule_token = async_call_later( + self.hass, timedelta(minutes=retry_in_minutes), self.update_pun + ) + else: + # Tentativi esauriti, passa al giorno dopo + _LOGGER.error( + "Errore durante l'aggiornamento via web, tentativi esauriti.", + exc_info=e, + ) + next_update_pun = get_next_date( + dataora=dt_util.now(time_zone=tz_pun), + ora=self.scan_hour, + minuto=self.scan_minute, + offset=1, + ) + self.schedule_token = async_track_point_in_time( + self.hass, self.update_pun, next_update_pun + ) + _LOGGER.debug( + "Prossimo aggiornamento web: %s", + next_update_pun.strftime("%d/%m/%Y %H:%M:%S %z"), + ) + + # Esce e attende la prossima schedulazione + return + + # Notifica che i dati PUN sono stati aggiornati con successo + self.async_set_updated_data({COORD_EVENT: EVENT_UPDATE_PUN}) + + # Calcola la data della prossima esecuzione + next_update_pun = get_next_date( + dataora=dt_util.now(time_zone=tz_pun), + ora=self.scan_hour, + minuto=self.scan_minute, + ) + if next_update_pun <= dt_util.now(): + # Se l'evento è già trascorso, passa a domani alla stessa ora + next_update_pun = next_update_pun + timedelta(days=1) + + # Annulla eventuali schedulazioni attive + self.clean_tokens() + + # Schedula la prossima esecuzione + self.schedule_token = async_track_point_in_time( + self.hass, self.update_pun, next_update_pun + ) + _LOGGER.debug( + "Prossimo aggiornamento web: %s", + next_update_pun.strftime("%d/%m/%Y %H:%M:%S %z"), + ) diff --git a/custom_components/pun_sensor/interfaces.py b/custom_components/pun_sensor/interfaces.py new file mode 100644 index 0000000..be0a0f9 --- /dev/null +++ b/custom_components/pun_sensor/interfaces.py @@ -0,0 +1,41 @@ +"""Interfacce di gestione di pun_sensor.""" + +from enum import Enum + + +class PunData: + """Classe che contiene i valori del PUN orario per ciascuna fascia.""" + + def __init__(self) -> None: + """Inizializza le liste di ciascuna fascia.""" + + self.pun: dict[Fascia, list[float]] = { + Fascia.MONO: [], + Fascia.F1: [], + Fascia.F2: [], + Fascia.F3: [], + Fascia.F23: [], + } + + +class Fascia(Enum): + """Enumerazione con i tipi di fascia oraria.""" + + MONO = "MONO" + F1 = "F1" + F2 = "F2" + F3 = "F3" + F23 = "F23" + + +class PunValues: + """Classe che contiene il PUN attuale di ciascuna fascia.""" + + value: dict[Fascia, float] + value = { + Fascia.MONO: 0.0, + Fascia.F1: 0.0, + Fascia.F2: 0.0, + Fascia.F3: 0.0, + Fascia.F23: 0.0, + } diff --git a/custom_components/pun_sensor/sensor.py b/custom_components/pun_sensor/sensor.py index d567a8e..7421274 100644 --- a/custom_components/pun_sensor/sensor.py +++ b/custom_components/pun_sensor/sensor.py @@ -1,55 +1,63 @@ +"""Implementazione sensori di pun_sensor.""" + +from typing import Any + +from awesomeversion.awesomeversion import AwesomeVersion + from homeassistant.components.sensor import ( ENTITY_ID_FORMAT, + SensorDeviceClass, SensorEntity, SensorStateClass, - SensorDeviceClass ) -from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_EURO, UnitOfEnergy, __version__ as HA_VERSION +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.restore_state import ( - RestoreEntity, ExtraStoredData, - RestoredExtraData + RestoredExtraData, + RestoreEntity, ) -from typing import Any, Dict +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PUNDataUpdateCoordinator -from .const import ( - DOMAIN, - PUN_FASCIA_MONO, - PUN_FASCIA_F23, - PUN_FASCIA_F1, - PUN_FASCIA_F2, - PUN_FASCIA_F3, -) +from .const import DOMAIN +from .interfaces import Fascia, PunValues -from awesomeversion.awesomeversion import AwesomeVersion -from homeassistant.const import __version__ as HA_VERSION -from homeassistant.const import CURRENCY_EURO, UnitOfEnergy ATTR_ROUNDED_DECIMALS = "rounded_decimals" -async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry, + +class CommonSettings: + """Contiene variabili globali a tutte le classi.""" + + has_suggested_display_precision = False + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None) -> None: - """Inizializza e crea i sensori""" + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Inizializza e crea i sensori.""" # Restituisce il coordinator coordinator = hass.data[DOMAIN][config.entry_id] # Verifica la versione di Home Assistant - global has_suggested_display_precision - has_suggested_display_precision = (AwesomeVersion(HA_VERSION) >= AwesomeVersion("2023.3.0")) - - # Crea i sensori (legati al coordinator) - entities = [] - entities.append(PUNSensorEntity(coordinator, PUN_FASCIA_MONO)) - entities.append(PUNSensorEntity(coordinator, PUN_FASCIA_F23)) - entities.append(PUNSensorEntity(coordinator, PUN_FASCIA_F1)) - entities.append(PUNSensorEntity(coordinator, PUN_FASCIA_F2)) - entities.append(PUNSensorEntity(coordinator, PUN_FASCIA_F3)) + CommonSettings.has_suggested_display_precision = AwesomeVersion( + HA_VERSION + ) >= AwesomeVersion("2023.3.0") + + # Crea i sensori dei valori del pun (legati al coordinator) + entities: list[SensorEntity] = [] + entities.extend( + PUNSensorEntity(coordinator, fascia) for fascia in PunValues().value + ) + + # Crea sensori aggiuntivi entities.append(FasciaPUNSensorEntity(coordinator)) entities.append(PrezzoFasciaPUNSensorEntity(coordinator)) @@ -58,238 +66,243 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry, async_add_entities(entities, update_before_add=False) -def decode_fascia(fascia: int) -> str | None: - if fascia == 3: - return "F3" - elif fascia == 2: - return "F2" - elif fascia == 1: - return "F1" - else: - return None - - -def fmt_float(num: float): - """Formatta adeguatamente il numero decimale""" - if has_suggested_display_precision: +def fmt_float(num: float) -> float: + """Formatta adeguatamente il numero decimale.""" + if CommonSettings.has_suggested_display_precision: return num - + # In versioni precedenti di Home Assistant che non supportano # l'attributo 'suggested_display_precision' restituisce il numero - # decimale già adeguatamente formattato come stringa - return format(round(num, 6), '.6f') + # decimale già arrotondato + return round(num, 6) + class PUNSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): - """Sensore PUN relativo al prezzo medio mensile per fasce""" + """Sensore PUN relativo al prezzo medio mensile per fasce.""" - def __init__(self, coordinator: PUNDataUpdateCoordinator, tipo: int) -> None: + def __init__(self, coordinator: PUNDataUpdateCoordinator, fascia: Fascia) -> None: + """Inizializza il sensore.""" super().__init__(coordinator) # Inizializza coordinator e tipo self.coordinator = coordinator - self.tipo = tipo + self.fascia = fascia # ID univoco sensore basato su un nome fisso - if (self.tipo == PUN_FASCIA_F3): - self.entity_id = ENTITY_ID_FORMAT.format('pun_fascia_f3') - elif (self.tipo == PUN_FASCIA_F2): - self.entity_id = ENTITY_ID_FORMAT.format('pun_fascia_f2') - elif (self.tipo == PUN_FASCIA_F1): - self.entity_id = ENTITY_ID_FORMAT.format('pun_fascia_f1') - elif (self.tipo == PUN_FASCIA_MONO): - self.entity_id = ENTITY_ID_FORMAT.format('pun_mono_orario') - elif (self.tipo == PUN_FASCIA_F23): - self.entity_id = ENTITY_ID_FORMAT.format('pun_fascia_f23') - else: - self.entity_id = None + match self.fascia: + case Fascia.MONO: + self.entity_id = ENTITY_ID_FORMAT.format("pun_mono_orario") + case Fascia.F1: + self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f1") + case Fascia.F2: + self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f2") + case Fascia.F3: + self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f3") + case Fascia.F23: + self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_f23") + case _: + self.entity_id = None self._attr_unique_id = self.entity_id self._attr_has_entity_name = True # Inizializza le proprietà comuni self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.MONETARY self._attr_suggested_display_precision = 6 self._available = False self._native_value = 0 def _handle_coordinator_update(self) -> None: - """Gestisce l'aggiornamento dei dati dal coordinator""" - self._available = self.coordinator.orari[self.tipo] > 0 - if (self._available): self._native_value = self.coordinator.pun[self.tipo] + """Gestisce l'aggiornamento dei dati dal coordinator.""" + if self.fascia != Fascia.F23: + # Tutte le fasce tranne F23 + if len(self.coordinator.pun_data.pun[self.fascia]) > 0: + # Ci sono dati, sensore disponibile + self._available = True + self._native_value = self.coordinator.pun_values.value[self.fascia] + else: + # Non ci sono dati, sensore non disponibile + self._available = False + + elif ( + len(self.coordinator.pun_data.pun[Fascia.F2]) + and len(self.coordinator.pun_data.pun[Fascia.F3]) + ) > 0: + # Caso speciale per fascia F23: affinché sia disponibile devono + # esserci dati sia sulla fascia F2 che sulla F3, + # visto che è calcolata a partire da questi + self._available = True + self._native_value = self.coordinator.pun_values.value[self.fascia] + else: + # Non ci sono dati, sensore non disponibile + self._available = False + + # Aggiorna lo stato di Home Assistant self.async_write_ha_state() @property def extra_restore_state_data(self) -> ExtraStoredData: - """Determina i dati da salvare per il ripristino successivo""" - return RestoredExtraData(dict( - native_value = self._native_value if self._available else None - )) - + """Determina i dati da salvare per il ripristino successivo.""" + return RestoredExtraData( + {"native_value": self._native_value if self._available else None} + ) + async def async_added_to_hass(self) -> None: - """Entità aggiunta ad Home Assistant""" + """Entità aggiunta ad Home Assistant.""" await super().async_added_to_hass() - # Recupera lo stato precedente, se esiste + # Recupera lo stato precedente, se esiste if (old_data := await self.async_get_last_extra_data()) is not None: - if (old_native_value := old_data.as_dict().get('native_value')) is not None: + if (old_native_value := old_data.as_dict().get("native_value")) is not None: self._available = True self._native_value = old_native_value @property def should_poll(self) -> bool: - """Determina l'aggiornamento automatico""" + """Determina l'aggiornamento automatico.""" return False @property def available(self) -> bool: - """Determina se il valore è disponibile""" + """Determina se il valore è disponibile.""" return self._available @property def native_value(self) -> float: - """Valore corrente del sensore""" - return self._native_value + """Valore corrente del sensore.""" + return fmt_float(self._native_value) @property def native_unit_of_measurement(self) -> str: - """Unita' di misura""" + """Unita' di misura.""" return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" - @property - def state(self) -> str: - return fmt_float(self.native_value) - @property def icon(self) -> str: - """Icona da usare nel frontend""" + """Icona da usare nel frontend.""" return "mdi:chart-line" @property - def name(self) -> str: - """Restituisce il nome del sensore""" - if (self.tipo == PUN_FASCIA_F3): - return "PUN fascia F3" - elif (self.tipo == PUN_FASCIA_F2): - return "PUN fascia F2" - elif (self.tipo == PUN_FASCIA_F1): - return "PUN fascia F1" - elif (self.tipo == PUN_FASCIA_MONO): + def name(self) -> str | None: + """Restituisce il nome del sensore.""" + if self.fascia == Fascia.MONO: return "PUN mono-orario" - elif (self.tipo == PUN_FASCIA_F23): - return "PUN fascia F23" - else: - return None + if self.fascia: + return f"PUN fascia {self.fascia.value}" + return None @property - def extra_state_attributes(self) -> Dict[str, Any]: - """Restituisce gli attributi di stato""" - if has_suggested_display_precision: - return None - + def extra_state_attributes(self) -> dict[str, Any]: + """Restituisce gli attributi di stato.""" + if CommonSettings.has_suggested_display_precision: + return {} + # Nelle versioni precedenti di Home Assistant # restituisce un valore arrotondato come attributo - state_attr = { - ATTR_ROUNDED_DECIMALS: str(format(round(self.native_value, 3), '.3f')) - } - return state_attr + return {ATTR_ROUNDED_DECIMALS: str(format(round(self.native_value, 3), ".3f"))} + class FasciaPUNSensorEntity(CoordinatorEntity, SensorEntity): - """Sensore che rappresenta la fascia PUN corrente""" + """Sensore che rappresenta il nome la fascia oraria PUN corrente.""" def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: + """Inizializza il sensore.""" super().__init__(coordinator) # Inizializza coordinator self.coordinator = coordinator # ID univoco sensore basato su un nome fisso - self.entity_id = ENTITY_ID_FORMAT.format('pun_fascia_corrente') + self.entity_id = ENTITY_ID_FORMAT.format("pun_fascia_corrente") self._attr_unique_id = self.entity_id self._attr_has_entity_name = True def _handle_coordinator_update(self) -> None: - """Gestisce l'aggiornamento dei dati dal coordinator""" + """Gestisce l'aggiornamento dei dati dal coordinator.""" self.async_write_ha_state() @property def should_poll(self) -> bool: - """Determina l'aggiornamento automatico""" + """Determina l'aggiornamento automatico.""" return False @property def available(self) -> bool: - """Determina se il valore è disponibile""" + """Determina se il valore è disponibile.""" return self.coordinator.fascia_corrente is not None - + @property def device_class(self) -> SensorDeviceClass | None: + """Classe del sensore.""" return SensorDeviceClass.ENUM @property def options(self) -> list[str] | None: - return ["F1", "F2", "F3"] + """Possibili stati del sensore.""" + return [Fascia.F1.value, Fascia.F2.value, Fascia.F3.value] @property def native_value(self) -> str | None: - """Restituisce la fascia corrente come stato""" - return decode_fascia(self.coordinator.fascia_corrente) + """Restituisce la fascia corrente come stato.""" + if not self.coordinator.fascia_corrente: + return None + return self.coordinator.fascia_corrente.value @property def extra_state_attributes(self) -> dict[str, Any] | None: + """Attributi aggiuntivi del sensore.""" return { - 'fascia_successiva': decode_fascia(self.coordinator.fascia_successiva), - 'inizio_fascia_successiva': self.coordinator.prossimo_cambio_fascia, - 'termine_fascia_successiva': self.coordinator.termine_prossima_fascia + "fascia_successiva": self.coordinator.fascia_successiva.value + if self.coordinator.fascia_successiva + else None, + "inizio_fascia_successiva": self.coordinator.prossimo_cambio_fascia, + "termine_fascia_successiva": self.coordinator.termine_prossima_fascia, } @property def icon(self) -> str: - """Icona da usare nel frontend""" + """Icona da usare nel frontend.""" return "mdi:timeline-clock-outline" @property def name(self) -> str: - """Restituisce il nome del sensore""" + """Restituisce il nome del sensore.""" return "Fascia corrente" -class PrezzoFasciaPUNSensorEntity(FasciaPUNSensorEntity, RestoreEntity): - """Sensore che rappresenta il prezzo PUN della fascia corrente""" + +class PrezzoFasciaPUNSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): + """Sensore che rappresenta il prezzo PUN della fascia corrente.""" def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: + """Inizializza il sensore.""" super().__init__(coordinator) + # Inizializza coordinator + self.coordinator = coordinator + # ID univoco sensore basato su un nome fisso - self.entity_id = ENTITY_ID_FORMAT.format('pun_prezzo_fascia_corrente') + self.entity_id = ENTITY_ID_FORMAT.format("pun_prezzo_fascia_corrente") self._attr_unique_id = self.entity_id self._attr_has_entity_name = True # Inizializza le proprietà comuni self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.MONETARY self._attr_suggested_display_precision = 6 self._available = False self._native_value = 0 self._friendly_name = "Prezzo fascia corrente" def _handle_coordinator_update(self) -> None: - """Gestisce l'aggiornamento dei dati dal coordinator""" - if super().available: - if (self.coordinator.fascia_corrente == 3): - self._available = self.coordinator.orari[PUN_FASCIA_F3] > 0 - self._native_value = self.coordinator.pun[PUN_FASCIA_F3] - self._friendly_name = "Prezzo fascia corrente (F3)" - elif (self.coordinator.fascia_corrente == 2): - self._available = self.coordinator.orari[PUN_FASCIA_F2] > 0 - self._native_value = self.coordinator.pun[PUN_FASCIA_F2] - self._friendly_name = "Prezzo fascia corrente (F2)" - elif (self.coordinator.fascia_corrente == 1): - self._available = self.coordinator.orari[PUN_FASCIA_F1] > 0 - self._native_value = self.coordinator.pun[PUN_FASCIA_F1] - self._friendly_name = "Prezzo fascia corrente (F1)" - else: - self._available = False - self._native_value = 0 - self._friendly_name = "Prezzo fascia corrente" + """Gestisce l'aggiornamento dei dati dal coordinator.""" + if self.coordinator.fascia_corrente is not None: + self._available = ( + len(self.coordinator.pun_data.pun[self.coordinator.fascia_corrente]) > 0 + ) + self._native_value = self.coordinator.pun_values.value[ + self.coordinator.fascia_corrente + ] + self._friendly_name = ( + f"Prezzo fascia corrente ({self.coordinator.fascia_corrente.value})" + ) else: self._available = False self._native_value = 0 @@ -298,62 +311,64 @@ def _handle_coordinator_update(self) -> None: @property def extra_restore_state_data(self) -> ExtraStoredData: - """Determina i dati da salvare per il ripristino successivo""" - return RestoredExtraData(dict( - native_value = self._native_value if self._available else None, - friendly_name = self._friendly_name if self._available else None - )) - + """Determina i dati da salvare per il ripristino successivo.""" + return RestoredExtraData( + { + "native_value": self._native_value if self._available else None, + "friendly_name": self._friendly_name if self._available else None, + } + ) + async def async_added_to_hass(self) -> None: - """Entità aggiunta ad Home Assistant""" + """Entità aggiunta ad Home Assistant.""" await super().async_added_to_hass() - # Recupera lo stato precedente, se esiste + # Recupera lo stato precedente, se esiste if (old_data := await self.async_get_last_extra_data()) is not None: - if (old_native_value := old_data.as_dict().get('native_value')) is not None: + if (old_native_value := old_data.as_dict().get("native_value")) is not None: self._available = True self._native_value = old_native_value - if (old_friendly_name := old_data.as_dict().get('friendly_name')) is not None: + if ( + old_friendly_name := old_data.as_dict().get("friendly_name") + ) is not None: self._friendly_name = old_friendly_name + @property + def should_poll(self) -> bool: + """Determina l'aggiornamento automatico.""" + return False + @property def available(self) -> bool: - """Determina se il valore è disponibile""" + """Determina se il valore è disponibile.""" return self._available @property def native_value(self) -> float: - """Restituisce il prezzo della fascia corrente""" - return self._native_value + """Restituisce il prezzo della fascia corrente.""" + return fmt_float(self._native_value) @property def native_unit_of_measurement(self) -> str: - """Unita' di misura""" + """Unita' di misura.""" return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" - @property - def state(self) -> str: - return fmt_float(self.native_value) - @property def icon(self) -> str: - """Icona da usare nel frontend""" + """Icona da usare nel frontend.""" return "mdi:currency-eur" @property def name(self) -> str: - """Restituisce il nome del sensore""" + """Restituisce il nome del sensore.""" return self._friendly_name @property - def extra_state_attributes(self) -> Dict[str, Any]: - """Restituisce gli attributi di stato""" - if has_suggested_display_precision: - return None - + def extra_state_attributes(self) -> dict[str, Any]: + """Restituisce gli attributi di stato.""" + if CommonSettings.has_suggested_display_precision: + return {} + # Nelle versioni precedenti di Home Assistant # restituisce un valore arrotondato come attributo - state_attr = { - ATTR_ROUNDED_DECIMALS: str(format(round(self.native_value, 3), '.3f')) - } - return state_attr \ No newline at end of file + return {ATTR_ROUNDED_DECIMALS: str(format(round(self.native_value, 3), ".3f"))} diff --git a/custom_components/pun_sensor/utils.py b/custom_components/pun_sensor/utils.py new file mode 100644 index 0000000..6cc25ca --- /dev/null +++ b/custom_components/pun_sensor/utils.py @@ -0,0 +1,193 @@ +"""Metodi di utilità generale.""" + +from datetime import date, datetime, timedelta +import logging +from zipfile import ZipFile + +import defusedxml.ElementTree as et # type: ignore[import-untyped] +import holidays + +from .interfaces import Fascia, PunData + +# Ottiene il logger +_LOGGER = logging.getLogger(__name__) + + +def get_fascia_for_xml(data: date, festivo: bool, ora: int) -> Fascia: + """Restituisce la fascia oraria di un determinato giorno/ora.""" + # F1 = lu-ve 8-19 + # F2 = lu-ve 7-8, lu-ve 19-23, sa 7-23 + # F3 = lu-sa 0-7, lu-sa 23-24, do, festivi + + # Festivi e domeniche + if festivo or (data.weekday() == 6): + return Fascia.F3 + + # Sabato + if data.weekday() == 5: + if 7 <= ora < 23: + return Fascia.F2 + return Fascia.F3 + + # Altri giorni della settimana + if ora == 7 or 19 <= ora < 23: + return Fascia.F2 + if 8 <= ora < 19: + return Fascia.F1 + return Fascia.F3 + + +def get_fascia(dataora: datetime) -> tuple[Fascia, datetime]: + """Restituisce la fascia della data/ora indicata e la data del prossimo cambiamento.""" + + # Verifica se la data corrente è un giorno con festività + festivo = dataora in holidays.IT() # type: ignore[attr-defined] + + # Identifica la fascia corrente + # F1 = lu-ve 8-19 + # F2 = lu-ve 7-8, lu-ve 19-23, sa 7-23 + # F3 = lu-sa 0-7, lu-sa 23-24, do, festivi + # Festivi + if festivo: + fascia = Fascia.F3 + + # Prossima fascia: alle 7 di un giorno non domenica o festività + prossima = get_next_date(dataora, 7, 1, True) + + return fascia, prossima + match dataora.weekday(): + # Domenica + case 6: + fascia = Fascia.F3 + prossima = get_next_date(dataora, 7, 1, True) + + # Sabato + case 5: + if 7 <= dataora.hour < 23: + # Sabato dalle 7 alle 23 + fascia = Fascia.F2 + # Prossima fascia: alle 23 dello stesso giorno + prossima = get_next_date(dataora, 23) + # abbiamo solo due fasce quindi facciamo solo il check per la prossima fascia + else: + # Sabato dopo le 23 e prima delle 7 + fascia = Fascia.F3 + + if dataora.hour < 7: + # Prossima fascia: alle 7 dello stesso giorno + prossima = get_next_date(dataora, 7) + else: + # Prossima fascia: alle 7 di un giorno non domenica o festività + prossima = get_next_date(dataora, 7, 1, True) + + # Altri giorni della settimana + case _: + if dataora.hour == 7 or 19 <= dataora.hour < 23: + # Lunedì-venerdì dalle 7 alle 8 e dalle 19 alle 23 + fascia = Fascia.F2 + + if dataora.hour == 7: + # Prossima fascia: alle 8 dello stesso giorno + prossima = get_next_date(dataora, 8) + else: + # Prossima fascia: alle 23 dello stesso giorno + prossima = get_next_date(dataora, 23) + + elif 8 <= dataora.hour < 19: + # Lunedì-venerdì dalle 8 alle 19 + fascia = Fascia.F1 + # Prossima fascia: alle 19 dello stesso giorno + prossima = get_next_date(dataora, 19) + + else: + # Lunedì-venerdì dalle 23 alle 7 del giorno dopo + fascia = Fascia.F3 + + if dataora.hour < 7: + # Siamo dopo la mezzanotte + # Prossima fascia: alle 7 dello stesso giorno + prossima = get_next_date(dataora, 7) + else: + # Prossima fascia: alle 7 di un giorno non domenica o festività + prossima = get_next_date(dataora, 7, 1, True) + + return fascia, prossima + + +def get_next_date( + dataora: datetime, ora: int, offset: int = 0, feriale: bool = False, minuto: int = 0 +) -> datetime: + """Ritorna una datetime in base ai parametri. + + Args: + dataora (datetime): passa la data di riferimento. + ora (int): l'ora a cui impostare la data. + offset (int = 0): scostamento in giorni rispetto a dataora. + feriale (bool = False): se True ritorna sempre una giornata lavorativa (no festivi, domeniche) + minuto (int = 0): minuto a cui impostare la data. + + Returns: + prossima (datetime): L'istanza di datetime corrispondente. + + """ + + prossima = (dataora + timedelta(days=offset)).replace( + hour=ora, minute=minuto, second=0, microsecond=0 + ) + + if feriale: + while (prossima in holidays.IT()) or (prossima.weekday() == 6): # type: ignore[attr-defined] + prossima += timedelta(days=1) + + return prossima + + +def extract_xml(archive: ZipFile, pun_data: PunData) -> PunData: + """Estrae i valori del pun per ogni fascia da un archivio zip contenente un XML. + + Returns: + List[ list[MONO: float], list[F1: float], list[F2: float], list[F3: float] ] + + """ + # Carica le festività + it_holidays = holidays.IT() # type: ignore[attr-defined] + + # Esamina ogni file XML nello ZIP (ordinandoli prima) + for fn in sorted(archive.namelist()): + # Scompatta il file XML in memoria + xml_tree = et.parse(archive.open(fn)) + + # Parsing dell'XML (1 file = 1 giorno) + xml_root = xml_tree.getroot() + + # Estrae la data dal primo elemento (sarà identica per gli altri) + dat_string = xml_root.find("Prezzi").find("Data").text # YYYYMMDD + + # Converte la stringa giorno in data + dat_date = date( + int(dat_string[0:4]), + int(dat_string[4:6]), + int(dat_string[6:8]), + ) + + # Verifica la festività + festivo = dat_date in it_holidays + + # Estrae le rimanenti informazioni + for prezzi in xml_root.iter("Prezzi"): + # Estrae l'ora dall'XML + ora = int(prezzi.find("Ora").text) - 1 # 1..24 + + # Estrae il prezzo PUN dall'XML in un float + prezzo_string = prezzi.find("PUN").text + prezzo_string = prezzo_string.replace(".", "").replace(",", ".") + prezzo = float(prezzo_string) / 1000 + + # Estrae la fascia oraria + fascia = get_fascia_for_xml(dat_date, festivo, ora) + + # Calcola le statistiche + pun_data.pun[Fascia.MONO].append(prezzo) + pun_data.pun[fascia].append(prezzo) + + return pun_data diff --git a/hacs.json b/hacs.json index 3e194cf..0a73911 100644 --- a/hacs.json +++ b/hacs.json @@ -1,8 +1,9 @@ { - "name": "Prezzi PUN del mese", - "homeassistant": "2022.8", - "hide_default_branch": true, - "zip_release": true, - "filename": "pun_sensor.zip", - "render_readme": true -} \ No newline at end of file + + "name": "Prezzi PUN del mese", + "homeassistant": "2022.8", + "hide_default_branch": true, + "zip_release": true, + "filename": "pun_sensor.zip", + "render_readme": true +} diff --git a/requirements.txt b/requirements.txt index ef4b3c0..09365de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -holidays -bs4 +holidays \ No newline at end of file diff --git a/set_manifest.sh b/set_manifest.sh new file mode 100755 index 0000000..38e0dd2 --- /dev/null +++ b/set_manifest.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# Check for git and python3 +for command in git python3 +do + if ! command -v "$command" &> /dev/null + then + echo "$command is not found in your PATH." + exit 1 + fi +done + +# Get the latest tag name +latest_tag=$(git describe --tags --abbrev=0) +echo "Latest tag (might be wrong): $latest_tag" + +# Patch the manifest +python3 .github/workflows/update_manifest.py --version $latest_tag