diff --git a/README.md b/README.md index e2f1b22..842cab5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ La prima casella a discesa permette di selezionare la _zona geografica_ di rifer Tramite lo slider invece è possibile selezionare un'_ora del giorno_ in cui scaricare i prezzi aggiornati dell'energia (default: 1); il minuto di esecuzione, invece, è determinato automaticamente per evitare di gravare eccessivamente sulle API del sito (e mantenuto fisso, finché l'ora non viene modificata). Se per qualche ragione il sito non fosse raggiungibile, verranno effettuati altri tentativi dopo 10, 60, 120 e 180 minuti. -Nel caso si fosse interessati ai prezzi zonali, selezionare un'orario uguale o superiore a 15, così da essere sicuri che il GME abbia pubblicato i dati anche del giorno successivo (accessibili tramite gli [attributi dello stesso sensore](#prezzo-zonale)). +Nel caso si fosse interessati ai prezzi zonali, selezionare un'**orario uguale o superiore a 15**, così da essere sicuri che il GME abbia pubblicato i dati anche del **giorno successivo** (accessibili tramite gli [attributi dello stesso sensore](#prezzo-zonale)). 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. @@ -41,7 +41,7 @@ Se la casella di controllo _Usa solo dati reali ad inizio mese_ è **attivata**, L'integrazione fornisce il nome della fascia corrente relativa all'orario di Home Assistant (tra F1 / F2 / F3), i prezzi delle tre fasce F1 / F2 / F3 più la fascia mono-oraria, la [fascia F23](#fascia-f23-)\* e il prezzo della fascia corrente. Questi sono i dati intesi come mensili, da paragonare a quelli in bolletta una volta aggiunti costi fissi e tasse (vedere [_prezzo al dettaglio_](#prezzo-al-dettaglio)). -Poi ci sono i due sensori con i prezzi orari (con il simbolo dell'orologio nell'icona), ad esempio utilizzabili per calcoli con impianti fotovoltaici: [PUN orario](#pun-orario) e [prezzo zonale](#prezzo-zonale). +Poi ci sono i sensori con i prezzi orari (con il simbolo dell'orologio nell'icona), ad esempio utilizzabili per calcoli con impianti fotovoltaici: [PUN orario](#pun-orario-e-pun-15-minuti) e [prezzo zonale](#prezzo-zonale) che dalla versione 4 (ottobre 2025) sono disponibili anche nelle varianti a 15 minuti. ### Prezzo al dettaglio @@ -88,25 +88,34 @@ Prezzo zonale di oggi e domani {%- set prezzo_prossimo = state_attr('sensor.pun_prezzo_zonale', orario_prossimo | string) -%} Prezzo zonale prossimo = {{ "%.6f" % prezzo_prossimo }} €/kWh ({{ orario_prossimo }}) + +{# Esempio di recupero del prossimo prezzo zonale da 15 minuti #} +{%- set orario_prossimo_15min = (utcnow() + timedelta(minutes=15)).astimezone(now().tzinfo) -%} +{%- set orario_prossimo_15min = orario_prossimo_15min.replace(minute=(orario_prossimo_15min.minute // 15) * 15, second=0, microsecond=0) -%} +{%- set prezzo_prossimo_15min = state_attr('sensor.pun_prezzo_zonale_15min', orario_prossimo_15min | string) -%} +Prezzo zonale prossimo da 15 minuti = {{ "%.6f" % prezzo_prossimo_15min }} €/kWh +({{ orario_prossimo_15min }}) ``` -I dati sono visibili anche in _Home Assistant > Strumenti per sviluppatori > Stati_ filtrando `sensor.pun_prezzo_zonale` come entità e attivando la casella di controllo _Attributi_. +I dati sono visibili anche in _Home Assistant > Strumenti per sviluppatori > Stati_ filtrando `sensor.pun_prezzo_zonale` come entità e attivando la casella di controllo _Attributi_. Lo stesso vale per i prezzi a 15 minuti, filtrando `sensor.pun_prezzo_zonale_15min`. -### PUN orario +### PUN orario e PUN 15 minuti -In maniera simile al prezzo zonale, anche il valore del PUN orario (nome sensore: `sensor.pun_orario`) ha gli attributi con i prezzi di oggi e domani, se disponibili. +In maniera simile al prezzo zonale, anche i valori del PUN orario (nome sensore: `sensor.pun_orario`) e PUN 15 minuti (nome sensore: `pun_15min`) hanno gli attributi con i prezzi di oggi e domani, se disponibili. ### In caso di problemi -È possibile abilitare la registrazione dei log tramite l'interfaccia grafica in **Impostazioni > Dispositivi e servizi > Prezzi PUN del mese** e cliccando sul pulsante **Abilita la registrazione di debug**. +È possibile abilitare la registrazione dei log tramite l'interfaccia grafica in **Impostazioni > Dispositivi e servizi > Prezzi PUN del mese** e cliccando sul pulsante **⋮ > Abilita la registrazione di debug**. ![Abilitazione log di debug](screenshot_debug_1.png "Abilitazione log di debug") -Il tasto verrà modificato come nell'immagine qui sotto: +Il tasto verrà modificato come nell'immagine qui sotto; dopo che si verifica il problema, premere su **Disabilita**. + +![Disabilitazione log di debug](screenshot_debug_2.png "Disabilitazione log di debug") -![Estrazione log di debug](screenshot_debug_2.png "Estrazione log di debug") +In questo modo verrà scaricato un file di log con le informazioni da allegare alle [Issue](https://github.com/virtualdj/pun_sensor/issues). -Dopo che si verifica il problema, premerlo nuovamente: in questo modo verrà scaricato un file di log con le informazioni da allegare alle [Issue](https://github.com/virtualdj/pun_sensor/issues). +![Download del file di log](screenshot_debug_3.png "Download del file di log") ## Note di sviluppo diff --git a/custom_components/pun_sensor/__init__.py b/custom_components/pun_sensor/__init__.py index dfef9f2..5e1185b 100644 --- a/custom_components/pun_sensor/__init__.py +++ b/custom_components/pun_sensor/__init__.py @@ -50,6 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: # Aggiorna immediatamente il prezzo zonale corrente await coordinator.update_prezzo_zonale() + # Aggiorna immediatamente il prezzo zonale a 15 minuti corrente + await coordinator.update_prezzo_zonale_15min() + # Crea i sensori con la configurazione specificata await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) diff --git a/custom_components/pun_sensor/const.py b/custom_components/pun_sensor/const.py index ccf6e77..3d793e1 100644 --- a/custom_components/pun_sensor/const.py +++ b/custom_components/pun_sensor/const.py @@ -18,6 +18,7 @@ EVENT_UPDATE_FASCIA: str = "event_update_fascia" EVENT_UPDATE_PUN: str = "event_update_pun" EVENT_UPDATE_PREZZO_ZONALE: str = "event_update_prezzo_zonale" +EVENT_UPDATE_PREZZO_ZONALE_15MIN: str = "event_update_prezzo_zonale_15min" # Parametri configurabili da configuration.yaml CONF_SCAN_HOUR: str = "scan_hour" diff --git a/custom_components/pun_sensor/coordinator.py b/custom_components/pun_sensor/coordinator.py index 6797394..b06ab4a 100644 --- a/custom_components/pun_sensor/coordinator.py +++ b/custom_components/pun_sensor/coordinator.py @@ -28,6 +28,7 @@ DOMAIN, EVENT_UPDATE_FASCIA, EVENT_UPDATE_PREZZO_ZONALE, + EVENT_UPDATE_PREZZO_ZONALE_15MIN, EVENT_UPDATE_PUN, WEB_RETRIES_MINUTES, ) @@ -35,6 +36,7 @@ from .utils import ( add_timedelta_via_utc, extract_xml, + get_15min_datetime, get_fascia, get_hour_datetime, get_next_date, @@ -140,6 +142,9 @@ async def async_restore_default_zona() -> None: self.prossimo_cambio_fascia: datetime | None = None self.termine_prossima_fascia: datetime | None = None self.orario_prezzo: datetime = get_hour_datetime(dt_util.now(time_zone=tz_pun)) + self.orario_prezzo_15min: datetime = get_15min_datetime( + dt_util.now(time_zone=tz_pun) + ) _LOGGER.debug( "Coordinator inizializzato (con 'usa dati reali' = %s).", @@ -428,8 +433,25 @@ async def update_prezzo_zonale(self, now=None) -> None: # Schedula la prossima esecuzione all'ora successiva # (tenendo conto del cambio ora legale/solare) next_update_prezzo_zonale: datetime = add_timedelta_via_utc( - dt=dt_util.now(time_zone=tz_pun), hours=1 - ).replace(minute=0, second=0, microsecond=0) + dt=self.orario_prezzo, hours=1 + ) async_track_point_in_time( self.hass, self.update_prezzo_zonale, next_update_prezzo_zonale ) + + async def update_prezzo_zonale_15min(self, now=None) -> None: + """Aggiorna il prezzo zonale a 15 minuti corrente.""" + + # Aggiorna il nuovo orario + self.orario_prezzo_15min = get_15min_datetime(dt_util.now(time_zone=tz_pun)) + + # Notifica che i dati sono stati aggiornati (orario prezzo zonale a 15 minuti) + self.async_set_updated_data({COORD_EVENT: EVENT_UPDATE_PREZZO_ZONALE_15MIN}) + + # Schedula la prossima esecuzione ai 15 minuti successivi ("spaccati") + next_update_prezzo_zonale_15min: datetime = add_timedelta_via_utc( + dt=self.orario_prezzo_15min, minutes=15 + ) + async_track_point_in_time( + self.hass, self.update_prezzo_zonale_15min, next_update_prezzo_zonale_15min + ) diff --git a/custom_components/pun_sensor/interfaces.py b/custom_components/pun_sensor/interfaces.py index 84a90f8..8e0d902 100644 --- a/custom_components/pun_sensor/interfaces.py +++ b/custom_components/pun_sensor/interfaces.py @@ -17,10 +17,17 @@ def __init__(self) -> None: Fascia.F23: [], } + # Nome della zona per i prezzi zonali self.zona: Zona = DEFAULT_ZONA + + # Prezzi zonali e PUN orari self.prezzi_zonali: dict[str, float | None] = {} self.pun_orari: dict[str, float | None] = {} + # Prezzi zonali e PUN a 15 minuti + self.prezzi_zonali_15min: dict[str, float | None] = {} + self.pun_15min: dict[str, float | None] = {} + class Fascia(Enum): """Enumerazione con i tipi di fascia oraria.""" diff --git a/custom_components/pun_sensor/sensor.py b/custom_components/pun_sensor/sensor.py index b23c61a..fa8a8b1 100644 --- a/custom_components/pun_sensor/sensor.py +++ b/custom_components/pun_sensor/sensor.py @@ -34,13 +34,16 @@ DOMAIN, EVENT_UPDATE_FASCIA, EVENT_UPDATE_PREZZO_ZONALE, + EVENT_UPDATE_PREZZO_ZONALE_15MIN, EVENT_UPDATE_PUN, ) from .interfaces import Fascia, PunValues from .utils import ( add_timedelta_via_utc, get_datetime_from_ordinal_hour, + get_datetime_from_periodo_15min, get_ordinal_hour, + get_periodo_15min, get_total_hours, ) @@ -69,7 +72,9 @@ async def async_setup_entry( entities.append(FasciaPUNSensorEntity(coordinator)) entities.append(PrezzoFasciaPUNSensorEntity(coordinator)) entities.append(PrezzoZonaleSensorEntity(coordinator)) + entities.append(PrezzoZonale15MinSensorEntity(coordinator)) entities.append(PUNOrarioSensorEntity(coordinator)) + entities.append(PUN15MinSensorEntity(coordinator)) # Aggiunge i sensori ma non aggiorna automaticamente via web # per lasciare il tempo ad Home Assistant di avviarsi @@ -607,6 +612,238 @@ def extra_state_attributes(self) -> dict[str, Any]: return attributes +class PrezzoZonale15MinSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): + """Sensore del prezzo zonale aggiornato ogni 15 minuti.""" + + # Non memorizza gli attributi nel recoder + _unrecorded_attributes = frozenset({MATCH_ALL}) + + def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: + """Inizializza il sensore.""" + super().__init__(coordinator) + + # Inizializza coordinator e tipo + self.coordinator: PUNDataUpdateCoordinator = coordinator + + # ID univoco sensore basato su un nome fisso + self.entity_id = ENTITY_ID_FORMAT.format("pun_prezzo_zonale_15min") + 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_suggested_display_precision = 6 + self._available: bool = False + self._native_value: float = 0 + self._friendly_name: str = "Prezzo zonale 15 min" + self._prezzi_zonali_15min: dict[str, float | None] = {} + + def _handle_coordinator_update(self) -> None: + """Gestisce l'aggiornamento dei dati dal coordinator.""" + + # Identifica l'evento che ha scatenato l'aggiornamento + if self.coordinator.data is None: + return + if (coordinator_event := self.coordinator.data.get(COORD_EVENT)) is None: + return + + # Aggiornata la zona e/o i prezzi + if coordinator_event == EVENT_UPDATE_PUN: + if self.coordinator.pun_data.zona is not None: + # Imposta il nome della zona + self._friendly_name = ( + f"Prezzo zonale 15 min ({self.coordinator.pun_data.zona.value})" + ) + # Verifica che il coordinator abbia i prezzi + if self.coordinator.pun_data.prezzi_zonali_15min: + # Copia i dati dal coordinator in locale (per il backup) + self._prezzi_zonali_15min = dict( + self.coordinator.pun_data.prezzi_zonali_15min + ) + else: + # Nessuna zona impostata + self._friendly_name = "Prezzo zonale 15 min" + self._prezzi_zonali_15min = {} + self._available = False + self.async_write_ha_state() + return + + # Cambiato l'orario del prezzo + if coordinator_event in (EVENT_UPDATE_PUN, EVENT_UPDATE_PREZZO_ZONALE_15MIN): + if self.coordinator.pun_data.zona is not None: + # Controlla se il prezzo a 15 minuti esiste per il periodo corrente + _LOGGER.debug( + "Aggiornamento data prezzo zonale 15 min: %s (XML: %s)", + self.coordinator.orario_prezzo_15min, + get_periodo_15min(self.coordinator.orario_prezzo_15min), + ) + if ( + str(self.coordinator.orario_prezzo_15min) + in self._prezzi_zonali_15min + ): + # Aggiorna il valore al prezzo orario + if ( + valore := self._prezzi_zonali_15min[ + str(self.coordinator.orario_prezzo_15min) + ] + ) is not None: + self._native_value = valore + self._available = True + else: + # Prezzo non disponibile + self._available = False + else: + # Orario non disponibile + self._available = False + else: + # Nessuna zona impostata + 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.""" + + # Salva i dati per la prossima istanza + return RestoredExtraData( + { + "friendly_name": self._friendly_name if self._available else None, + "zona": self.coordinator.pun_data.zona.name + if self.coordinator.pun_data.zona is not None + else None, + "prezzi_zonali_15min": self._prezzi_zonali_15min, + } + ) + + async def async_added_to_hass(self) -> None: + """Entità aggiunta ad Home Assistant.""" + await super().async_added_to_hass() + + # Recupera lo stato precedente, se esiste + if (old_data := await self.async_get_last_extra_data()) is not None: + # Recupera il dizionario con i valori precedenti + old_data_dict = old_data.as_dict() + + # Zona geografica + if (old_zona_str := old_data_dict.get("zona")) is not None: + # Verifica che la zona attuale sia disponibile + # (se non lo è, c'è un errore nella configurazione) + if self.coordinator.pun_data.zona is None: + _LOGGER.warning( + "La zona geografica memorizzata '%s' non sembra essere più valida.", + old_zona_str, + ) + self._available = False + return + + # Controlla se la zona memorizzata è diversa dall'attuale + if old_zona_str != self.coordinator.pun_data.zona.name: + _LOGGER.debug( + "Ignorati i dati precedenti, perché riferiti alla zona '%s' (anziché '%s').", + old_zona_str, + self.coordinator.pun_data.zona.name, + ) + self._available = False + return + + # Nome + if (old_friendly_name := old_data_dict.get("friendly_name")) is not None: + self._friendly_name = old_friendly_name + + # Valori delle fasce orarie + if ( + old_prezzi_zonali_15min := old_data_dict.get("prezzi_zonali_15min") + ) is not None: + self._prezzi_zonali_15min = old_prezzi_zonali_15min + + # Controlla se il prezzo a 15 minuti esiste per il periodo corrente + if ( + str(self.coordinator.orario_prezzo_15min) + in self._prezzi_zonali_15min + ): + # Aggiorna il valore al prezzo a 15 minuti + if ( + valore := self._prezzi_zonali_15min[ + str(self.coordinator.orario_prezzo_15min) + ] + ) is not None: + self._native_value = valore + self._available = True + else: + # Prezzo non disponibile + self._available = False + else: + # Imposta come non disponibile + self._available = False + + @property + def should_poll(self) -> bool: + """Determina l'aggiornamento automatico.""" + return False + + @property + def available(self) -> bool: + """Determina se il valore è disponibile.""" + return self._available + + @property + def native_value(self) -> float: + """Valore corrente del sensore.""" + return self._native_value + + @property + def native_unit_of_measurement(self) -> str: + """Unita' di misura.""" + return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" + + @property + def icon(self) -> str: + """Icona da usare nel frontend.""" + return "mdi:map-clock-outline" + + @property + def name(self) -> str | None: + """Restituisce il nome del sensore.""" + return self._friendly_name + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Restituisce gli attributi di stato.""" + + # Crea il dizionario degli attributi + attributes: dict[str, Any] = {} + + # Aggiunge i prezzi a 15 minuti negli attributi, periodo per periodo + if self.coordinator.pun_data.zona is not None: + # Prezzi di oggi + max_15min_oggi: int = 4 * get_total_hours( + self.coordinator.orario_prezzo_15min + ) + for p in range(max_15min_oggi): + data_ora_prezzo = get_datetime_from_periodo_15min( + self.coordinator.orario_prezzo_15min, (1 + p) + ) + attributes[str(data_ora_prezzo)] = self._prezzi_zonali_15min.get( + str(data_ora_prezzo) + ) + + # Prezzi di domani + domani = add_timedelta_via_utc( + dt=self.coordinator.orario_prezzo_15min, days=1 + ) + max_15min_domani: int = 4 * get_total_hours(domani) + for p in range(max_15min_domani): + data_ora_prezzo = get_datetime_from_periodo_15min(domani, (1 + p)) + attributes[str(data_ora_prezzo)] = self._prezzi_zonali_15min.get( + str(data_ora_prezzo) + ) + + # Restituisce gli attributi + return attributes + + class PUNOrarioSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): """Sensore del prezzo PUN aggiornato ogni ora.""" @@ -770,3 +1007,170 @@ def extra_state_attributes(self) -> dict[str, Any]: # Restituisce gli attributi return attributes + + +class PUN15MinSensorEntity(CoordinatorEntity, SensorEntity, RestoreEntity): + """Sensore del prezzo PUN aggiornato ogni 15 minuti.""" + + # Non memorizza gli attributi nel recoder + _unrecorded_attributes = frozenset({MATCH_ALL}) + + def __init__(self, coordinator: PUNDataUpdateCoordinator) -> None: + """Inizializza il sensore.""" + super().__init__(coordinator) + + # Inizializza coordinator e tipo + self.coordinator: PUNDataUpdateCoordinator = coordinator + + # ID univoco sensore basato su un nome fisso + self.entity_id = ENTITY_ID_FORMAT.format("pun_15min") + 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_suggested_display_precision = 6 + self._available: bool = False + self._native_value: float = 0 + self._friendly_name: str = "PUN 15 min" + self._pun_15min: dict[str, float | None] = {} + + def _handle_coordinator_update(self) -> None: + """Gestisce l'aggiornamento dei dati dal coordinator.""" + + # Identifica l'evento che ha scatenato l'aggiornamento + if self.coordinator.data is None: + return + if (coordinator_event := self.coordinator.data.get(COORD_EVENT)) is None: + return + + # Aggiornati i prezzi PUN + if coordinator_event == EVENT_UPDATE_PUN: + # Verifica che il coordinator abbia i prezzi + if self.coordinator.pun_data.pun_15min: + # Copia i dati dal coordinator in locale (per il backup) + self._pun_15min = dict(self.coordinator.pun_data.pun_15min) + + # Cambiato l'orario del prezzo + if coordinator_event in (EVENT_UPDATE_PUN, EVENT_UPDATE_PREZZO_ZONALE_15MIN): + # Controlla se il PUN a 15 minuti esiste per il periodo corrente + _LOGGER.debug( + "Aggiornamento data PUN 15 min: %s (XML: %s)", + self.coordinator.orario_prezzo_15min, + get_periodo_15min(self.coordinator.orario_prezzo_15min), + ) + if str(self.coordinator.orario_prezzo_15min) in self._pun_15min: + # Aggiorna il valore al prezzo orario + if ( + valore := self._pun_15min[str(self.coordinator.orario_prezzo_15min)] + ) is not None: + self._native_value = valore + self._available = True + else: + # Prezzo non disponibile + self._available = False + else: + # Orario 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.""" + + # Salva i dati per la prossima istanza + return RestoredExtraData( + { + "pun_15min": self._pun_15min, + } + ) + + async def async_added_to_hass(self) -> None: + """Entità aggiunta ad Home Assistant.""" + await super().async_added_to_hass() + + # Recupera lo stato precedente, se esiste + if (old_data := await self.async_get_last_extra_data()) is not None: + # Recupera il dizionario con i valori precedenti + old_data_dict = old_data.as_dict() + + # Valori dei prezzi a 15 minuti + if (old_pun_15min := old_data_dict.get("pun_15min")) is not None: + self._pun_15min = old_pun_15min + + # Controlla se il prezzo a 15 minuti esiste per il periodo corrente + if str(self.coordinator.orario_prezzo_15min) in self._pun_15min: + # Aggiorna il valore al prezzo a 15 minuti + if ( + valore := self._pun_15min[ + str(self.coordinator.orario_prezzo_15min) + ] + ) is not None: + self._native_value = valore + self._available = True + else: + # Prezzo non disponibile + self._available = False + else: + # Imposta come non disponibile + self._available = False + + @property + def should_poll(self) -> bool: + """Determina l'aggiornamento automatico.""" + return False + + @property + def available(self) -> bool: + """Determina se il valore è disponibile.""" + return self._available + + @property + def native_value(self) -> float: + """Valore corrente del sensore.""" + return self._native_value + + @property + def native_unit_of_measurement(self) -> str: + """Unita' di misura.""" + return f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" + + @property + def icon(self) -> str: + """Icona da usare nel frontend.""" + if AwesomeVersion(HA_VERSION) < AwesomeVersion("2024.1.0"): + return "mdi:receipt-clock-outline" + return "mdi:invoice-clock-outline" + + @property + def name(self) -> str | None: + """Restituisce il nome del sensore.""" + return self._friendly_name + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Restituisce gli attributi di stato.""" + + # Crea il dizionario degli attributi + attributes: dict[str, Any] = {} + + # Aggiunge i prezzi a 15 minuti negli attributi, periodo per periodo + # Prezzi di oggi + max_15min_oggi: int = 4 * get_total_hours(self.coordinator.orario_prezzo_15min) + for p in range(max_15min_oggi): + data_ora_prezzo = get_datetime_from_periodo_15min( + self.coordinator.orario_prezzo_15min, (1 + p) + ) + attributes[str(data_ora_prezzo)] = self._pun_15min.get(str(data_ora_prezzo)) + + # Prezzi di domani + domani = add_timedelta_via_utc(dt=self.coordinator.orario_prezzo_15min, days=1) + max_15min_domani: int = 4 * get_total_hours(domani) + for p in range(max_15min_domani): + data_ora_prezzo = get_datetime_from_periodo_15min(domani, (1 + p)) + attributes[str(data_ora_prezzo)] = self._pun_15min.get(str(data_ora_prezzo)) + + # Restituisce gli attributi + return attributes diff --git a/custom_components/pun_sensor/utils.py b/custom_components/pun_sensor/utils.py index b4c9ebc..0032c9a 100644 --- a/custom_components/pun_sensor/utils.py +++ b/custom_components/pun_sensor/utils.py @@ -202,7 +202,7 @@ def get_ordinal_hour(dt: datetime, ref_tz: ZoneInfo = ZoneInfo("Europe/Rome")) - # Converte l'ora passata in UTC dt_utc: datetime = dt.astimezone(timezone.utc) - # Calcola il numero di ore passate dal mezzanotte in UTC e somma 1 + # Calcola il numero di ore passate dalla mezzanotte in UTC e somma 1 return int((dt_utc - start_utc).total_seconds() // 3600) + 1 @@ -331,6 +331,95 @@ def get_datetime_from_ordinal_hour( return end_utc.astimezone(ref_tz) +def get_15min_datetime(dataora: datetime) -> datetime: + """Restituisce un datetime con solo la data, l'ora e i minuti arrotondati ai 15 precedenti. + + Args: + dataora (datetime): Data e ora di partenza. + + Returns: + datetime: La nuova data con solo giorno, ora e minuti a step di 15. + + """ + return datetime( + year=dataora.year, + month=dataora.month, + day=dataora.day, + hour=dataora.hour, + minute=(dataora.minute // 15) * 15, + second=0, + microsecond=0, + fold=dataora.fold, + tzinfo=dataora.tzinfo, + ) + + +def get_periodo_15min(dt: datetime, ref_tz: ZoneInfo = ZoneInfo("Europe/Rome")) -> int: + """Restituisce il periodo di 15 minuti della giornata (1-96 normalmente, 1-92 in primavera, 1-100 in autunno). + + Args: + dt: datetime con timezone di cui restituire il periodo di 15 minuti + ref_tz: timezone di riferimento per il calcolo (di default usa "Europe/Rome") + + Returns: + int: numero progressivo del periodo di 15 minuti (1-100) + + Raises: + ValueError: se dt non ha timezone + + """ + # Controllo presenza fuso orario negli argomenti + if dt.tzinfo is None: + raise ValueError( + "L'argomento dt deve essere timezone-aware (es. ZoneInfo('Europe/Rome'))." + ) + + # Calcola la mezzanotte locale + local_midnight: datetime = datetime(dt.year, dt.month, dt.day, 0, 0, tzinfo=ref_tz) + + # Converte la mezzanotte in UTC + start_utc: datetime = local_midnight.astimezone(timezone.utc) + + # Converte l'ora passata in UTC + dt_utc: datetime = dt.astimezone(timezone.utc) + + # Calcola il numero di quarti d'ora passati dalla mezzanotte in UTC e somma 1 + return int((dt_utc - start_utc).total_seconds() // 900) + 1 + + +def get_datetime_from_periodo_15min( + dt: datetime | date, periodo_15min: int, ref_tz: ZoneInfo = ZoneInfo("Europe/Rome") +) -> datetime: + """Restituisce il datetime corrispondente al periodo di 15 minuti del giorno `data`. + + Args: + dt: datetime o date di riferimento + periodo_15min: il numero del periodo di 15 minuti del giorno `dt` da convertire (1..100) + ref_tz: timezone di riferimento per il calcolo (di default usa "Europe/Rome") + + Raises: + ValueError: se periodo_15min non è compreso tra 1 e 100 + + Returns: + datetime: orario locale corrispondente all'ora progressiva specificata + + """ + if not (1 <= periodo_15min <= 100): + raise ValueError("periodo_15min deve essere compreso tra 1 e 100") + + # Calcola la mezzanotte locale + local_midnight: datetime = datetime(dt.year, dt.month, dt.day, 0, 0, tzinfo=ref_tz) + + # Converte la mezzanotte in UTC + start_utc: datetime = local_midnight.astimezone(timezone.utc) + + # Aggiunge i periodi di 15 minuti effettivi trascorsi dalla mezzanotte + end_utc: datetime = start_utc + timedelta(minutes=15 * (periodo_15min - 1)) + + # Ritorna l'orario locale corrispondente + return end_utc.astimezone(ref_tz) + + def extract_xml(archive: ZipFile, pun_data: PunData, today: date) -> PunData: """Estrae i valori del pun per ogni fascia da un archivio zip contenente un XML. @@ -358,12 +447,19 @@ def extract_xml(archive: ZipFile, pun_data: PunData, today: date) -> PunData: # Parsing dell'XML (1 file = 1 giorno) xml_root = xml_tree.getroot() - # Salta per ora i file con i prezzi a 15 minuti - if xml_root.find("Prezzi15"): - continue + # Prova a cercare i prezzi orari come primo elemento + prezzi_15min: bool = False + primo_elemento = xml_root.find("Prezzi") + if primo_elemento is None: + # Prova a vedere se sono prezzi ogni 15 minuti + prezzi_15min = True + primo_elemento = xml_root.find("Prezzi15") + if primo_elemento is None: + _LOGGER.debug("Nessun prezzo supportato trovato nel file XML: %s", fn) + continue # Estrae la data dal primo elemento (sarà identica per gli altri) - dat_string: str = xml_root.find("Prezzi").find("Data").text # YYYYMMDD + dat_string: str = primo_elemento.find("Data").text # YYYYMMDD # Converte la stringa giorno in data dat_date: date = date( @@ -372,72 +468,167 @@ def extract_xml(archive: ZipFile, pun_data: PunData, today: date) -> PunData: int(dat_string[6:8]), ) - # Ottiene il numero massimo di ora per la data specificata - max_ore: int = get_total_hours(dat_date) - # Verifica la festività festivo: bool = dat_date in it_holidays - # Estrae le rimanenti informazioni - for prezzi in xml_root.iter("Prezzi"): - # Estrae l'ora dall'XML - ora_xml: int = int(prezzi.find("Ora").text) - - # Valida l'orario XML - # 1..24 normalmente, ma anche 1..23 o 1..25 nei cambi ora - if not (1 <= ora_xml <= max_ore): - _LOGGER.warning( - "Orario %s non valido per %s (max: %s).", - ora_xml, - dat_string, - max_ore, - ) - - # Converte l'ora in un datetime - orario_prezzo: datetime = get_datetime_from_ordinal_hour(dat_date, ora_xml) + # Verifica se si tratta di prezzi ogni 15 minuti + if prezzi_15min: + # Ottiene il numero massimo di periodi di 15 minuti per la data specificata + max_periodi: int = 4 * get_total_hours(dat_date) - # Estrae il prezzo PUN dall'XML in un float - if (prezzo_xml := prezzi.find("PUN")) is not None: - prezzo_string: str = prezzo_xml.text.replace(".", "").replace(",", ".") - prezzo: float = float(prezzo_string) / 1000 + # Considera solo oggi e domani per i prezzi ogni 15 minuti + if dat_date >= today: + # Estrae le rimanenti informazioni + for prezzi in xml_root.iter("Prezzi15"): + # Verifica che il mercato sia corretto + if prezzi.find("Mercato").text != "MGP": + _LOGGER.warning( + "Mercato non supportato per i prezzi a 15 minuti nel file XML: %s.\n%s", + fn, + et.tostring(prezzi, encoding="unicode", method="xml"), + ) + break + + # Verifica che la granularità sia corretta + if prezzi.find("Granularity").text != "PT15": + _LOGGER.warning( + "Granularità non supportata per i prezzi a 15 minuti nel file XML: %s.\n%s", + fn, + et.tostring(prezzi, encoding="unicode", method="xml"), + ) + break + + # Estrae il periodo dall'XML + periodo_xml: int = int(prezzi.find("Periodo").text) + + # Valida il periodo XML + # 1 .. 96 normalmente, ma anche 1..92 o 1..100 nei cambi ora + if not (1 <= periodo_xml <= max_periodi): + _LOGGER.warning( + "Periodo %s non valido per %s (max: %s).", + periodo_xml, + dat_string, + max_periodi, + ) - # Per le medie mensili, considera solo i dati fino ad oggi - if dat_date <= today: - # Estrae la fascia oraria - fascia: Fascia = get_fascia_for_xml( - dat_date, festivo, orario_prezzo.hour + # Converte il periodo in un datetime + orario_prezzo_15min: datetime = get_datetime_from_periodo_15min( + dat_date, periodo_xml ) - # Calcola le statistiche - pun_data.pun[Fascia.MONO].append(prezzo) - pun_data.pun[fascia].append(prezzo) + # Estrae il prezzo PUN dall'XML in un float + if (prezzo_xml := prezzi.find("PUN")) is not None: + prezzo_string_15min: str = prezzo_xml.text.replace( + ".", "" + ).replace(",", ".") + prezzo_15min: float = float(prezzo_string_15min) / 1000 - # Per il PUN orario, considera solo oggi e domani - if dat_date >= today: - # Salva il prezzo per quell'orario - pun_data.pun_orari[str(orario_prezzo)] = prezzo - else: - # PUN non valido - _LOGGER.warning( - "PUN non specificato per %s ad orario: %s.", dat_string, ora_xml + # Salva il prezzo per quell'orario + pun_data.pun_15min[str(orario_prezzo_15min)] = prezzo_15min + else: + # PUN non valido + _LOGGER.warning( + "PUN non specificato per %s al periodo: %s.", + dat_string, + periodo_xml, + ) + + # Controlla che la zona del prezzo zonale sia impostata + if pun_data.zona is not None: + # Estrae il prezzo zonale dall'XML in un float + # basandosi sul nome dell'enum + if ( + prezzo_zonale_xml := prezzi.find(pun_data.zona.name) + ) is not None: + prezzo_zonale_string_15min: str = ( + prezzo_zonale_xml.text.replace(".", "").replace( + ",", "." + ) + ) + pun_data.prezzi_zonali_15min[str(orario_prezzo_15min)] = ( + float(prezzo_zonale_string_15min) / 1000 + ) + else: + pun_data.prezzi_zonali_15min[str(orario_prezzo_15min)] = ( + None + ) + else: + # Ottiene il numero massimo di ore per la data specificata + max_ore: int = get_total_hours(dat_date) + + # Estrae le rimanenti informazioni + for prezzi in xml_root.iter("Prezzi"): + # Verifica che il mercato sia corretto + if prezzi.find("Mercato").text != "MGP": + _LOGGER.warning( + "Mercato non supportato per i prezzi orari nel file XML: %s.\n%s", + fn, + et.tostring(prezzi, encoding="unicode", method="xml"), + ) + break + + # Estrae l'ora dall'XML + ora_xml: int = int(prezzi.find("Ora").text) + + # Valida l'orario XML + # 1..24 normalmente, ma anche 1..23 o 1..25 nei cambi ora + if not (1 <= ora_xml <= max_ore): + _LOGGER.warning( + "Orario %s non valido per %s (max: %s).", + ora_xml, + dat_string, + max_ore, + ) + + # Converte l'ora in un datetime + orario_prezzo: datetime = get_datetime_from_ordinal_hour( + dat_date, ora_xml ) - # Per i prezzi zonali, considera solo oggi e domani - if dat_date >= today: - # Controlla che la zona del prezzo zonale sia impostata - if pun_data.zona is not None: - # Estrae il prezzo zonale dall'XML in un float - # basandosi sul nome dell'enum - if ( - prezzo_zonale_xml := prezzi.find(pun_data.zona.name) - ) is not None: - prezzo_zonale_string: str = prezzo_zonale_xml.text.replace( - ".", "" - ).replace(",", ".") - pun_data.prezzi_zonali[str(orario_prezzo)] = ( - float(prezzo_zonale_string) / 1000 + # Estrae il prezzo PUN dall'XML in un float + if (prezzo_xml := prezzi.find("PUN")) is not None: + prezzo_string: str = prezzo_xml.text.replace(".", "").replace( + ",", "." + ) + prezzo: float = float(prezzo_string) / 1000 + + # Per le medie mensili, considera solo i dati fino ad oggi + if dat_date <= today: + # Estrae la fascia oraria + fascia: Fascia = get_fascia_for_xml( + dat_date, festivo, orario_prezzo.hour ) - else: - pun_data.prezzi_zonali[str(orario_prezzo)] = None + + # Calcola le statistiche + pun_data.pun[Fascia.MONO].append(prezzo) + pun_data.pun[fascia].append(prezzo) + + # Per il PUN orario, considera solo oggi e domani + if dat_date >= today: + # Salva il prezzo per quell'orario + pun_data.pun_orari[str(orario_prezzo)] = prezzo + else: + # PUN non valido + _LOGGER.warning( + "PUN non specificato per %s ad orario: %s.", dat_string, ora_xml + ) + + # Per i prezzi zonali, considera solo oggi e domani + if dat_date >= today: + # Controlla che la zona del prezzo zonale sia impostata + if pun_data.zona is not None: + # Estrae il prezzo zonale dall'XML in un float + # basandosi sul nome dell'enum + if ( + prezzo_zonale_xml := prezzi.find(pun_data.zona.name) + ) is not None: + prezzo_zonale_string: str = prezzo_zonale_xml.text.replace( + ".", "" + ).replace(",", ".") + pun_data.prezzi_zonali[str(orario_prezzo)] = ( + float(prezzo_zonale_string) / 1000 + ) + else: + pun_data.prezzi_zonali[str(orario_prezzo)] = None return pun_data diff --git a/screenshot_debug_1.png b/screenshot_debug_1.png index d4fb74f..eeb27ba 100644 Binary files a/screenshot_debug_1.png and b/screenshot_debug_1.png differ diff --git a/screenshot_debug_2.png b/screenshot_debug_2.png index 7e0043c..800a55e 100644 Binary files a/screenshot_debug_2.png and b/screenshot_debug_2.png differ diff --git a/screenshot_debug_3.png b/screenshot_debug_3.png new file mode 100644 index 0000000..897f3c7 Binary files /dev/null and b/screenshot_debug_3.png differ diff --git a/screenshots_main.png b/screenshots_main.png index 4869808..ad05a5d 100644 Binary files a/screenshots_main.png and b/screenshots_main.png differ