diff --git a/.gitignore b/.gitignore index 6a4cfc1..9830823 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ downloads/ eggs/ .eggs/ lib/ +!/lib/ lib64/ parts/ sdist/ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f1ad5ff --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +""" Libraries """ diff --git a/custom_components/pun_sensor/coordinator.py b/custom_components/pun_sensor/coordinator.py index d830fcb..232260f 100644 --- a/custom_components/pun_sensor/coordinator.py +++ b/custom_components/pun_sensor/coordinator.py @@ -4,11 +4,9 @@ import io import logging import random -from statistics import mean -import zipfile from zoneinfo import ZoneInfo -from aiohttp import ClientSession, ServerConnectionError +from aiohttp import ServerConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -16,6 +14,7 @@ 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 lib.data_downloader import DataDownloader from .const import ( CONF_ACTUAL_DATA_ONLY, @@ -29,8 +28,8 @@ EVENT_UPDATE_PUN, WEB_RETRIES_MINUTES, ) -from .interfaces import DEFAULT_ZONA, Fascia, PunData, PunValues, Zona -from .utils import extract_xml, get_fascia, get_hour_datetime, get_next_date +from lib.interfaces import DEFAULT_ZONA, Fascia, PunData, Zona +from lib.utils import get_fascia, get_hour_datetime, get_next_date # Ottiene il logger _LOGGER = logging.getLogger(__name__) @@ -42,7 +41,7 @@ class PUNDataUpdateCoordinator(DataUpdateCoordinator): """Classe coordinator di aggiornamento dati.""" - session: ClientSession + data_downloader: DataDownloader def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: """Gestione dell'aggiornamento da Home Assistant.""" @@ -54,8 +53,8 @@ def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: # Nessun update_interval (aggiornamento automatico disattivato) ) - # Salva la sessione client e la configurazione - self.session = async_get_clientsession(hass) + # Create the data download instance + self.data_downloader = DataDownloader(_LOGGER, async_get_clientsession(hass)) # Inizializza i valori di configurazione (dalle opzioni o dalla configurazione iniziale) self.actual_data_only = config.options.get( @@ -126,7 +125,6 @@ async def async_restore_default_zona() -> None: # Inizializza i valori di default self.web_retries = WEB_RETRIES_MINUTES self.schedule_token = None - self.pun_values: PunValues = PunValues() self.fascia_corrente: Fascia | None = None self.fascia_successiva: Fascia | None = None self.prossimo_cambio_fascia: datetime | None = None @@ -138,6 +136,10 @@ async def async_restore_default_zona() -> None: self.actual_data_only, ) + @property + def pun_values(self): + return self.data_downloader.pun_values + def clean_tokens(self): """Annulla eventuali schedulazioni attive.""" if self.schedule_token is not None: @@ -176,120 +178,7 @@ def async_update_entry() -> None: 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) - - # Aggiunge un giorno (domani) per il calcolo del prezzo zonale - date_end += timedelta(days=1) - - # Converte le date in stringa da passare all'API Mercato elettrico - 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", - "referer": "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", - "sec-gpc": "1", - "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, dt_util.now(time_zone=tz_pun).date() - ) - archive.close() - - # 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(f"{len(dati)} ({fascia.value})") - for fascia, dati in self.pun_data.pun.items() - if fascia != Fascia.F23 - ), - ) - _LOGGER.debug( - "Valori PUN: %s", - ", ".join( - f"{prezzo} ({fascia.value})" - for fascia, prezzo in self.pun_values.value.items() - ), - ) + self.data_downloader.get(tz_pun, self.actual_data_only) # Notifica che i dati PUN (prezzi) sono stati aggiornati self.async_set_updated_data({COORD_EVENT: EVENT_UPDATE_PUN}) diff --git a/custom_components/pun_sensor/sensor.py b/custom_components/pun_sensor/sensor.py index f434814..96330b7 100644 --- a/custom_components/pun_sensor/sensor.py +++ b/custom_components/pun_sensor/sensor.py @@ -24,15 +24,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PUNDataUpdateCoordinator -from .const import ( +from lib.const import ( COORD_EVENT, DOMAIN, EVENT_UPDATE_FASCIA, EVENT_UPDATE_PREZZO_ZONALE, EVENT_UPDATE_PUN, ) -from .interfaces import Fascia, PunValues -from .utils import datetime_to_packed_string, get_next_date +from lib.interfaces import Fascia, PunValues +from lib.utils import datetime_to_packed_string, get_next_date ATTR_PREFIX_PREZZO_OGGI = "oggi_h_" ATTR_PREFIX_PREZZO_DOMANI = "domani_h_" diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..f1ad5ff --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +""" Libraries """ diff --git a/custom_components/pun_sensor/const.py b/lib/const.py similarity index 100% rename from custom_components/pun_sensor/const.py rename to lib/const.py diff --git a/lib/data_downloader.py b/lib/data_downloader.py new file mode 100644 index 0000000..e7396d8 --- /dev/null +++ b/lib/data_downloader.py @@ -0,0 +1,136 @@ +from aiohttp import ClientSession, ServerConnectionError +from datetime import * +import io +from logging import Logger +from statistics import mean +import zipfile +from zoneinfo import ZoneInfo +from lib.interfaces import Fascia, PunData, PunValues +from lib.utils import extract_xml + +class DataDownloader: + session: ClientSession + pun_values: PunValues + pun_data: PunData + logger: Logger + + def __init__(self, logger: Logger, session: ClientSession) -> None: + self.logger = logger + self.session = session + self.pun_data = PunData() + self.pun_values = PunValues() + + async def get(self, time_zone: ZoneInfo, actual_data_only = False): + # Calcola l'intervallo di date per il mese corrente + date_end = datetime.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 actual_data_only) and (date_end.day < 4): + date_start = date_start - timedelta(days=3) + + # Aggiunge un giorno (domani) per il calcolo del prezzo zonale + date_end += timedelta(days=1) + + # Converte le date in stringa da passare all'API Mercato elettrico + 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", + "referer": "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", + "sec-gpc": "1", + "tabid": "1749", + "userid": "-1", + } + + # Effettua il download dello ZIP con i file XML + self.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: + self.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: + self.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 + self.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, datetime.now(time_zone).date() + ) + archive.close() + + # 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 + self.logger.debug( + "Numero di dati: %s", + ", ".join( + str(f"{len(dati)} ({fascia.value})") + for fascia, dati in self.pun_data.pun.items() + if fascia != Fascia.F23 + ), + ) + self.logger.debug( + "Valori PUN: %s", + ", ".join( + f"{prezzo} ({fascia.value})" + for fascia, prezzo in self.pun_values.value.items() + ), + ) diff --git a/custom_components/pun_sensor/interfaces.py b/lib/interfaces.py similarity index 82% rename from custom_components/pun_sensor/interfaces.py rename to lib/interfaces.py index 330561b..045f632 100644 --- a/custom_components/pun_sensor/interfaces.py +++ b/lib/interfaces.py @@ -19,10 +19,16 @@ def __init__(self) -> None: self.zona: Zona | None = None self.prezzi_zonali: dict[str, float | None] = {} + # PUN da ora (YYYYMMDDHH) a prezzo self.pun_orari: dict[str, float | None] = {} + # Fascia da ora (YYYYMMDDHH) a Fascia + self.fasce_orarie: dict[str, Fascia] = {} + def __repr__(self): + return f'zona: {self.zona}, prezzi_zonali: {self.prezzi_zonali}, pun_orari: {self.pun_orari}' -class Fascia(Enum): + +class Fascia(str, Enum): """Enumerazione con i tipi di fascia oraria.""" MONO = "MONO" @@ -44,6 +50,9 @@ class PunValues: Fascia.F23: 0.0, } + def __repr__(self): + return repr(self.value) + class Zona(Enum): """Enumerazione con i nomi delle zone per i prezzi zonali.""" diff --git a/custom_components/pun_sensor/utils.py b/lib/utils.py similarity index 98% rename from custom_components/pun_sensor/utils.py rename to lib/utils.py index 6c9794a..d09d5e0 100644 --- a/custom_components/pun_sensor/utils.py +++ b/lib/utils.py @@ -226,11 +226,11 @@ def extract_xml(archive: ZipFile, pun_data: PunData, today: date) -> PunData: prezzo_string = prezzo_xml.text.replace(".", "").replace(",", ".") prezzo = float(prezzo_string) / 1000 + # Estrae la fascia oraria + fascia = get_fascia_for_xml(dat_date, festivo, ora) + # Per le medie mensili, considera solo i dati fino ad oggi if dat_date <= today: - # 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) @@ -251,6 +251,7 @@ def extract_xml(archive: ZipFile, pun_data: PunData, today: date) -> PunData: ) # E salva il prezzo per quell'orario pun_data.pun_orari[orario_prezzo] = prezzo + pun_data.fasce_orarie[orario_prezzo] = fascia else: # PUN non valido _LOGGER.warning( diff --git a/test/get-hourly-pun.py b/test/get-hourly-pun.py new file mode 100644 index 0000000..969a422 --- /dev/null +++ b/test/get-hourly-pun.py @@ -0,0 +1,33 @@ +""" Test that the data can be retrieved """ + +import aiohttp +import asyncio +import logging +import json +import os +import sys +from zoneinfo import ZoneInfo + +# Load parent folder as library +sys.path.append(os.path.abspath('..')) +from lib.data_downloader import DataDownloader + +async def main(): + logger = logging.getLogger(__name__) + tz_pun = ZoneInfo("Europe/Rome") + + # Use DataDownloader directly to fetch data + async with aiohttp.ClientSession() as session: + downloader = DataDownloader(logger, session) + await downloader.get(tz_pun) + + # Dump the `pun_orari` structure as JSON to stdout, mixing price + band + print(json.dumps(dict(map(lambda date: (date, [downloader.pun_data.pun_orari[date], downloader.pun_data.fasce_orarie[date]]), downloader.pun_data.pun_orari.keys())))) + +# Start asyncio loop +if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +asyncio.run(main())