package tariff import ( "archive/zip" "bytes" "encoding/xml" "errors" "fmt" "net/http" "net/http/cookiejar" "slices" "strconv" "strings" "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" ) // ErrPunDataNotAvailable indicates that GME has not yet published prices for the requested day. var ErrPunDataNotAvailable = errors.New("PUN data not available") // romeLocation is resolved once at package init to avoid repeated filesystem lookups. var romeLocation *time.Location type Pun struct { *embed zone string log *util.Logger data *util.Monitor[api.Rates] } type NewDataSet struct { XMLName xml.Name `xml:"NewDataSet"` Prezzi []Prezzo `xml:"Prezzi"` } type Zone struct { XMLName xml.Name Value string `xml:",chardata"` } type Prezzo struct { Data string `xml:"Data"` Ora string `xml:"Ora"` Zones []Zone `xml:",any"` } type Rate struct { Start time.Time `json:"start"` End time.Time `json:"end"` Price float64 `json:"price"` } var _ api.Tariff = (*Pun)(nil) func init() { registry.Add("pun", NewPunFromConfig) romeLocation, _ = time.LoadLocation("Europe/Rome") } func NewPunFromConfig(other map[string]any) (api.Tariff, error) { var cc struct { embed `mapstructure:",squash"` Zone string } logger := util.NewLogger("pun") if err := util.DecodeOther(other, &cc); err != nil { return nil, err } if err := cc.init(); err != nil { return nil, err } var zone = strings.ToUpper(strings.TrimSpace(cc.Zone)) if cc.Zone == "" { zone = "PUN" } t := &Pun{ log: logger, zone: zone, embed: &cc.embed, data: util.NewMonitor[api.Rates](2 * time.Hour), } return runOrError(t) } func (t *Pun) run(done chan error) { var once sync.Once for tick := time.Tick(time.Hour); ; <-tick { // get today data today, err := backoff.RetryWithData(func() (api.Rates, error) { res, err := t.getData(time.Now()) return res, backoffPermanentError(err) }, bo()) if err != nil { if reportError(&once, done, err) { return } t.log.ERROR.Println(err) continue } // get tomorrow data (may not be available before ~13:00 CET) res, err := backoff.RetryWithData(func() (api.Rates, error) { res, err := t.getData(time.Now().AddDate(0, 0, 1)) if errors.Is(err, ErrPunDataNotAvailable) { return res, backoff.Permanent(err) } return res, backoffPermanentError(err) }, bo()) if err != nil && !errors.Is(err, ErrPunDataNotAvailable) { if reportError(&once, done, err) { return } t.log.ERROR.Println(err) continue } // merge today and tomorrow data data := append(today, res...) mergeRates(t.data, data) once.Do(func() { close(done) }) } } // Rates implements the api.Tariff interface func (t *Pun) Rates() (api.Rates, error) { var res api.Rates err := t.data.GetFunc(func(val api.Rates) { res = slices.Clone(val) }) return res, err } // Type implements the api.Tariff interface func (t *Pun) Type() api.TariffType { return api.TariffTypePriceForecast } func (t *Pun) priceForZone(p Prezzo) (float64, error) { for _, z := range p.Zones { if strings.ToUpper(strings.TrimSpace(z.XMLName.Local)) == t.zone { price, err := strconv.ParseFloat(strings.ReplaceAll(z.Value, ",", "."), 64) return price, err } } return 0, fmt.Errorf("zone %s not found for hour %s", t.zone, p.Ora) } func (t *Pun) getData(day time.Time) (api.Rates, error) { client := request.NewClient(t.log) client.Jar, _ = cookiejar.New(nil) // Request the ZIP file uri := "https://gme.mercatoelettrico.org/DesktopModules/GmeDownload/API/ExcelDownload/downloadzipfile?DataInizio=" + day.Format("20060102") + "&DataFine=" + day.Format("20060102") + "&Date=" + day.Format("20060102") + "&Mercato=MGP&Settore=Prezzi&FiltroDate=InizioFine" req, _ := http.NewRequest("GET", uri, nil) req.Header = http.Header{ "Referer": {"https://gme.mercatoelettrico.org/en-us/Home/Results/Electricity/MGP/Download?valore=Prezzi"}, "moduleid": {"12103"}, "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"}, } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("%w: %s", ErrPunDataNotAvailable, day.Format("2006-01-02")) } body, err := request.ReadBody(resp) if err != nil { return nil, err } zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) if err != nil { return nil, err } var tariffFile *zip.File for _, file := range zipReader.File { if strings.HasSuffix(file.Name, "Prezzi.xml") { tariffFile = file break } } if tariffFile == nil { return nil, fmt.Errorf("tariff file not found in downloaded ZIP archive") } f, err := tariffFile.Open() if err != nil { return nil, err } defer f.Close() // Process the received data var dataSet NewDataSet if err := xml.NewDecoder(f).Decode(&dataSet); err != nil { return nil, err } data := make(api.Rates, 0, len(dataSet.Prezzi)) for _, p := range dataSet.Prezzi { date, err := time.Parse("20060102", p.Data) if err != nil { return nil, fmt.Errorf("parse date: %w", err) } hour, err := strconv.Atoi(p.Ora) if err != nil { return nil, fmt.Errorf("parse hour: %w", err) } // Adjust hour to handle edge case where p.Ora is "00" if hour == 0 { hour = 24 date = date.AddDate(0, 0, -1) } price, err := t.priceForZone(p) if err != nil { return nil, fmt.Errorf("parse price: %w", err) } ts := time.Date(date.Year(), date.Month(), date.Day(), hour-1, 0, 0, 0, romeLocation) ar := api.Rate{ Start: ts, End: ts.Add(time.Hour), Value: t.totalPrice(price/1e3, ts), } data = append(data, ar) } data.Sort() return data, nil }