]> git.basschouten.com Git - openhab-addons.git/commitdiff
[energidataservice] Introduce subscription-based providers (#17456)
authorJacob Laursen <jacob-github@vindvejr.dk>
Fri, 11 Oct 2024 14:14:58 +0000 (16:14 +0200)
committerGitHub <noreply@github.com>
Fri, 11 Oct 2024 14:14:58 +0000 (16:14 +0200)
* Introduce subscription-based providers

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
32 files changed:
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/ApiController.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/CacheManager.java [deleted file]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/EnergiDataServiceBindingConstants.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/PriceListParser.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/ChargeTypeCode.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DatahubTariffFilter.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DateQueryParameter.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/GlobalLocationNumber.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/console/EnergiDataServiceCommandExtension.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/factory/EnergiDataServiceHandlerFactory.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/AbstractProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/Co2EmissionProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCache.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCache.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/Co2EmissionListener.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/ElectricityPriceListener.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/SubscriptionListener.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Co2EmissionSubscription.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/DatahubPriceSubscription.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/ElectricityPriceSubscription.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Subscription.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/ExponentialBackoff.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/Linear.java
bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/CacheManagerTest.java [deleted file]
bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProviderTest.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCacheTest.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCacheTest.java [new file with mode: 0644]

index 0201bba12f39ccd10c0bf3af5761672a36029092..62cad906690d705a4725cdd6d2b53ede0823a4a4 100644 (file)
@@ -27,6 +27,7 @@ import java.util.Objects;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -86,12 +87,12 @@ public class ApiController {
             .create();
     private final HttpClient httpClient;
     private final TimeZoneProvider timeZoneProvider;
-    private final String userAgent;
+    private final Supplier<String> userAgentSupplier;
 
     public ApiController(HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
         this.httpClient = httpClient;
         this.timeZoneProvider = timeZoneProvider;
-        userAgent = "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
+        this.userAgentSupplier = this::getUserAgent;
     }
 
     /**
@@ -116,7 +117,7 @@ public class ApiController {
                 .param("start", start.toString()) //
                 .param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
                 .param("columns", "HourUTC,SpotPrice" + currency) //
-                .agent(userAgent) //
+                .agent(userAgentSupplier.get()) //
                 .method(HttpMethod.GET);
 
         if (!end.isEmpty()) {
@@ -138,6 +139,10 @@ public class ApiController {
         }
     }
 
+    private String getUserAgent() {
+        return "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
+    }
+
     private String sendRequest(Request request, Map<String, String> properties)
             throws TimeoutException, ExecutionException, InterruptedException, DataServiceException {
         logger.trace("GET request for {}", request.getURI());
@@ -210,7 +215,7 @@ public class ApiController {
                 .timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) //
                 .param("filter", mapToFilter(filterMap)) //
                 .param("columns", columns) //
-                .agent(userAgent) //
+                .agent(userAgentSupplier.get()) //
                 .method(HttpMethod.GET);
 
         DateQueryParameter start = tariffFilter.getStart();
@@ -277,7 +282,7 @@ public class ApiController {
                 .param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
                 .param("columns", "Minutes5UTC,CO2Emission") //
                 .param("sort", "Minutes5UTC DESC") //
-                .agent(userAgent) //
+                .agent(userAgentSupplier.get()) //
                 .method(HttpMethod.GET);
 
         try {
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/CacheManager.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/CacheManager.java
deleted file mode 100644 (file)
index aba5973..0000000
+++ /dev/null
@@ -1,297 +0,0 @@
-/**
- * Copyright (c) 2010-2024 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.energidataservice.internal;
-
-import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
-
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.time.ZonedDateTime;
-import java.time.temporal.ChronoUnit;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Currency;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
-import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
-
-/**
- * The {@link CacheManager} is responsible for maintaining a cache of received
- * data from Energi Data Service.
- *
- * @author Jacob Laursen - Initial contribution
- */
-@NonNullByDefault
-public class CacheManager {
-
-    public static final int NUMBER_OF_HISTORIC_HOURS = 24;
-    public static final int SPOT_PRICE_MAX_CACHE_SIZE = 24 + 11 + NUMBER_OF_HISTORIC_HOURS;
-    public static final int TARIFF_MAX_CACHE_SIZE = 24 * 2 + NUMBER_OF_HISTORIC_HOURS;
-
-    private final Clock clock;
-    private final PriceListParser priceListParser = new PriceListParser();
-
-    private Map<Instant, BigDecimal> spotPriceMap = new ConcurrentHashMap<>(SPOT_PRICE_MAX_CACHE_SIZE);
-
-    private Map<DatahubTariff, Collection<DatahubPricelistRecord>> datahubRecordsMap = new HashMap<>();
-    private Map<DatahubTariff, Map<Instant, BigDecimal>> tariffsMap = new ConcurrentHashMap<>();
-
-    public CacheManager() {
-        this(Clock.systemDefaultZone());
-    }
-
-    public CacheManager(Clock clock) {
-        this.clock = clock.withZone(NORD_POOL_TIMEZONE);
-
-        for (DatahubTariff datahubTariff : DatahubTariff.values()) {
-            datahubRecordsMap.put(datahubTariff, new ArrayList<>());
-            tariffsMap.put(datahubTariff, new ConcurrentHashMap<>(TARIFF_MAX_CACHE_SIZE));
-        }
-    }
-
-    /**
-     * Clear all cached data.
-     */
-    public void clear() {
-        spotPriceMap.clear();
-
-        for (DatahubTariff datahubTariff : DatahubTariff.values()) {
-            Collection<DatahubPricelistRecord> datahubRecords = datahubRecordsMap.get(datahubTariff);
-            if (datahubRecords != null) {
-                datahubRecords.clear();
-            }
-
-            Map<Instant, BigDecimal> tariffs = tariffsMap.get(datahubTariff);
-            if (tariffs != null) {
-                tariffs.clear();
-            }
-        }
-    }
-
-    /**
-     * Convert and cache the supplied {@link ElspotpriceRecord}s.
-     * 
-     * @param records The records as received from Energi Data Service.
-     * @param currency The currency in which the records were requested.
-     */
-    public void putSpotPrices(ElspotpriceRecord[] records, Currency currency) {
-        boolean isDKK = EnergiDataServiceBindingConstants.CURRENCY_DKK.equals(currency);
-        for (ElspotpriceRecord record : records) {
-            spotPriceMap.put(record.hour(),
-                    (isDKK ? record.spotPriceDKK() : record.spotPriceEUR()).divide(BigDecimal.valueOf(1000)));
-        }
-        cleanup();
-    }
-
-    /**
-     * Replace current "raw"/unprocessed tariff records in cache.
-     * Map of hourly tariffs will be updated automatically.
-     *
-     * @param records to cache
-     */
-    public void putTariffs(DatahubTariff datahubTariff, Collection<DatahubPricelistRecord> records) {
-        Collection<DatahubPricelistRecord> datahubRecords = datahubRecordsMap.get(datahubTariff);
-        if (datahubRecords == null) {
-            throw new IllegalStateException("Datahub records not initialized");
-        }
-        putDatahubRecords(datahubRecords, records);
-        updateTariffs(datahubTariff);
-    }
-
-    private void putDatahubRecords(Collection<DatahubPricelistRecord> destination,
-            Collection<DatahubPricelistRecord> source) {
-        LocalDateTime localHourStart = LocalDateTime.now(clock.withZone(DATAHUB_TIMEZONE))
-                .minus(NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS);
-
-        destination.clear();
-        destination.addAll(source.stream().filter(r -> !r.validTo().isBefore(localHourStart)).toList());
-    }
-
-    /**
-     * Update map of hourly tariffs from internal cache.
-     */
-    public void updateTariffs(DatahubTariff datahubTariff) {
-        Collection<DatahubPricelistRecord> datahubRecords = datahubRecordsMap.get(datahubTariff);
-        if (datahubRecords == null) {
-            throw new IllegalStateException("Datahub records not initialized");
-        }
-        tariffsMap.put(datahubTariff, priceListParser.toHourly(datahubRecords));
-        cleanup();
-    }
-
-    /**
-     * Get current spot price.
-     *
-     * @return spot price currently valid
-     */
-    public @Nullable BigDecimal getSpotPrice() {
-        return getSpotPrice(Instant.now(clock));
-    }
-
-    /**
-     * Get spot price valid at provided instant.
-     *
-     * @param time {@link Instant} for which to get the spot price
-     * @return spot price at given time or null if not available
-     */
-    public @Nullable BigDecimal getSpotPrice(Instant time) {
-        return spotPriceMap.get(getHourStart(time));
-    }
-
-    /**
-     * Get map of all cached spot prices.
-     *
-     * @return spot prices currently available, {@link #NUMBER_OF_HISTORIC_HOURS} back
-     */
-    public Map<Instant, BigDecimal> getSpotPrices() {
-        return new HashMap<>(spotPriceMap);
-    }
-
-    /**
-     * Get current tariff.
-     *
-     * @return tariff currently valid
-     */
-    public @Nullable BigDecimal getTariff(DatahubTariff datahubTariff) {
-        return getTariff(datahubTariff, Instant.now(clock));
-    }
-
-    /**
-     * Get tariff valid at provided instant.
-     *
-     * @param time {@link Instant} for which to get the tariff
-     * @return tariff at given time or null if not available
-     */
-    public @Nullable BigDecimal getTariff(DatahubTariff datahubTariff, Instant time) {
-        Map<Instant, BigDecimal> tariffs = tariffsMap.get(datahubTariff);
-        if (tariffs == null) {
-            throw new IllegalStateException("Tariffs not initialized");
-        }
-        return tariffs.get(getHourStart(time));
-    }
-
-    /**
-     * Get map of all cached tariffs.
-     *
-     * @return tariffs currently available, {@link #NUMBER_OF_HISTORIC_HOURS} back
-     */
-    public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
-        Map<Instant, BigDecimal> tariffs = tariffsMap.get(datahubTariff);
-        if (tariffs == null) {
-            throw new IllegalStateException("Tariffs not initialized");
-        }
-        return new HashMap<>(tariffs);
-    }
-
-    /**
-     * Get number of future spot prices including current hour.
-     * 
-     * @return number of future spot prices
-     */
-    public long getNumberOfFutureSpotPrices() {
-        Instant currentHourStart = getCurrentHourStart();
-
-        return spotPriceMap.entrySet().stream().filter(p -> !p.getKey().isBefore(currentHourStart)).count();
-    }
-
-    /**
-     * Check if historic spot prices ({@link #NUMBER_OF_HISTORIC_HOURS}) are cached.
-     * 
-     * @return true if historic spot prices are cached
-     */
-    public boolean areHistoricSpotPricesCached() {
-        return arePricesCached(spotPriceMap, getCurrentHourStart().minus(1, ChronoUnit.HOURS));
-    }
-
-    /**
-     * Check if all current spot prices are cached taking into consideration that next day's spot prices
-     * should be available at 13:00 CET.
-     *
-     * @return true if spot prices are fully cached
-     */
-    public boolean areSpotPricesFullyCached() {
-        Instant end = ZonedDateTime.of(LocalDate.now(clock), LocalTime.of(23, 0), NORD_POOL_TIMEZONE).toInstant();
-        LocalTime now = LocalTime.now(clock);
-        if (now.isAfter(DAILY_REFRESH_TIME_CET)) {
-            end = end.plus(24, ChronoUnit.HOURS);
-        }
-
-        return arePricesCached(spotPriceMap, end);
-    }
-
-    private boolean arePricesCached(Map<Instant, BigDecimal> priceMap, Instant end) {
-        for (Instant hourStart = getFirstHourStart(); hourStart.compareTo(end) <= 0; hourStart = hourStart.plus(1,
-                ChronoUnit.HOURS)) {
-            if (priceMap.get(hourStart) == null) {
-                return false;
-            }
-        }
-
-        return true;
-    }
-
-    /**
-     * Check if we have "raw" tariff records cached which are valid tomorrow.
-     * 
-     * @return true if tariff records for tomorrow are cached
-     */
-    public boolean areTariffsValidTomorrow(DatahubTariff datahubTariff) {
-        Collection<DatahubPricelistRecord> datahubRecords = datahubRecordsMap.get(datahubTariff);
-        if (datahubRecords == null) {
-            throw new IllegalStateException("Datahub records not initialized");
-        }
-        return isValidNextDay(datahubRecords);
-    }
-
-    /**
-     * Remove historic prices.
-     */
-    public void cleanup() {
-        Instant firstHourStart = getFirstHourStart();
-
-        spotPriceMap.entrySet().removeIf(entry -> entry.getKey().isBefore(firstHourStart));
-
-        for (Map<Instant, BigDecimal> tariffs : tariffsMap.values()) {
-            tariffs.entrySet().removeIf(entry -> entry.getKey().isBefore(firstHourStart));
-        }
-    }
-
-    private boolean isValidNextDay(Collection<DatahubPricelistRecord> records) {
-        LocalDateTime localHourStart = LocalDateTime.now(EnergiDataServiceBindingConstants.DATAHUB_TIMEZONE)
-                .truncatedTo(ChronoUnit.HOURS);
-        LocalDateTime localMidnight = localHourStart.plusDays(1).truncatedTo(ChronoUnit.DAYS);
-
-        return records.stream().anyMatch(r -> r.validTo().isAfter(localMidnight));
-    }
-
-    private Instant getCurrentHourStart() {
-        return getHourStart(Instant.now(clock));
-    }
-
-    private Instant getFirstHourStart() {
-        return getHourStart(Instant.now(clock).minus(NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS));
-    }
-
-    private Instant getHourStart(Instant instant) {
-        return instant.truncatedTo(ChronoUnit.HOURS);
-    }
-}
index 82eeb8f58ced3a3e3ce0874d9859b36eb65b3c13..01112fc54558f6c0866e35843d8a3eda26a2e81c 100644 (file)
@@ -17,6 +17,8 @@ import java.time.LocalTime;
 import java.time.ZoneId;
 import java.util.Currency;
 import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.openhab.core.thing.ChannelUID;
@@ -62,6 +64,12 @@ public class EnergiDataServiceBindingConstants {
             CHANNEL_SYSTEM_TARIFF, CHANNEL_TRANSMISSION_GRID_TARIFF, CHANNEL_ELECTRICITY_TAX,
             CHANNEL_REDUCED_ELECTRICITY_TAX);
 
+    public static final Set<String> CO2_EMISSION_CHANNELS = Set.of(CHANNEL_CO2_EMISSION_PROGNOSIS,
+            CHANNEL_CO2_EMISSION_REALTIME);
+
+    public static final Set<String> SUBSCRIPTION_CHANNELS = Stream
+            .concat(ELECTRICITY_CHANNELS.stream(), CO2_EMISSION_CHANNELS.stream()).collect(Collectors.toSet());
+
     // List of all properties
     public static final String PROPERTY_REMAINING_CALLS = "remainingCalls";
     public static final String PROPERTY_TOTAL_CALLS = "totalCalls";
index ef36fbdd11152daa585a69dcdbd7bdfd8dca24a9..b25e307c8e612155c0ffefd9545448b3e82c3c49 100644 (file)
@@ -26,6 +26,8 @@ import java.util.concurrent.ConcurrentHashMap;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
+import org.openhab.binding.energidataservice.internal.provider.cache.DatahubPriceSubscriptionCache;
+import org.openhab.binding.energidataservice.internal.provider.cache.ElectricityPriceSubscriptionCache;
 
 /**
  * Parses results from {@link org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecords}
@@ -47,7 +49,8 @@ public class PriceListParser {
     }
 
     public Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> records) {
-        Instant firstHourStart = Instant.now(clock).minus(CacheManager.NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS)
+        Instant firstHourStart = Instant.now(clock)
+                .minus(ElectricityPriceSubscriptionCache.NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS)
                 .truncatedTo(ChronoUnit.HOURS);
         Instant lastHourStart = Instant.now(clock).truncatedTo(ChronoUnit.HOURS).plus(2, ChronoUnit.DAYS)
                 .truncatedTo(ChronoUnit.DAYS);
@@ -57,7 +60,7 @@ public class PriceListParser {
 
     public Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> records, Instant firstHourStart,
             Instant lastHourStart) {
-        Map<Instant, BigDecimal> totalMap = new ConcurrentHashMap<>(CacheManager.TARIFF_MAX_CACHE_SIZE);
+        Map<Instant, BigDecimal> totalMap = new ConcurrentHashMap<>(DatahubPriceSubscriptionCache.MAX_CACHE_SIZE);
         records.stream().map(record -> record.chargeTypeCode()).distinct().forEach(chargeTypeCode -> {
             Map<Instant, BigDecimal> currentMap = toHourly(records, chargeTypeCode, firstHourStart, lastHourStart);
             for (Entry<Instant, BigDecimal> current : currentMap.entrySet()) {
@@ -74,7 +77,7 @@ public class PriceListParser {
 
     private Map<Instant, BigDecimal> toHourly(Collection<DatahubPricelistRecord> records, String chargeTypeCode,
             Instant firstHourStart, Instant lastHourStart) {
-        Map<Instant, BigDecimal> tariffMap = new ConcurrentHashMap<>(CacheManager.TARIFF_MAX_CACHE_SIZE);
+        Map<Instant, BigDecimal> tariffMap = new ConcurrentHashMap<>(DatahubPriceSubscriptionCache.MAX_CACHE_SIZE);
 
         LocalDateTime previousValidFrom = LocalDateTime.MAX;
         LocalDateTime previousValidTo = LocalDateTime.MIN;
index c6aed5c9041d973d76b791ecab75a03dd5b86c96..a346c370a261b443df9b3942b36f9dd4f609b86e 100644 (file)
@@ -13,6 +13,7 @@
 package org.openhab.binding.energidataservice.internal.api;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
 
 /**
  * Charge type code for DatahubPricelist dataset.
@@ -43,6 +44,16 @@ public class ChargeTypeCode {
         return code;
     }
 
+    @Override
+    public boolean equals(@Nullable Object o) {
+        return o == this || (o instanceof ChargeTypeCode other && code.equals(other.code));
+    }
+
+    @Override
+    public int hashCode() {
+        return code.hashCode();
+    }
+
     public static ChargeTypeCode of(String code) {
         return new ChargeTypeCode(code);
     }
index 09cff82b7801ab3b22713fe764219dcb04f4090f..ce331b4a54e357011a6c7e0291f99cd32402a018 100644 (file)
 package org.openhab.binding.energidataservice.internal.api;
 
 import java.util.Collection;
+import java.util.Objects;
 import java.util.Set;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
 
 /**
  * Filter for the DatahubPricelist dataset.
@@ -69,4 +71,27 @@ public class DatahubTariffFilter {
     public DateQueryParameter getEnd() {
         return end;
     }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof DatahubTariffFilter other)) {
+            return false;
+        }
+
+        return chargeTypeCodes.equals(other.chargeTypeCodes) && notes.equals(other.notes) && start.equals(other.start)
+                && end.equals(other.end);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(chargeTypeCodes, notes, start, end);
+    }
+
+    @Override
+    public String toString() {
+        return chargeTypeCodes.toString() + "," + notes.toString() + "," + start + "," + end;
+    }
 }
index bca4d0586d3376f2e207a178eff65603413d6b86..58a3921eb0203cfffe4f98eab8b6005c17d39f81 100644 (file)
@@ -14,6 +14,7 @@ package org.openhab.binding.energidataservice.internal.api;
 
 import java.time.Duration;
 import java.time.LocalDate;
+import java.util.Objects;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
@@ -36,16 +37,16 @@ public class DateQueryParameter {
     private DateQueryParameter() {
     }
 
-    public DateQueryParameter(LocalDate date) {
+    private DateQueryParameter(LocalDate date) {
         this.date = date;
     }
 
-    public DateQueryParameter(DateQueryParameterType dateType, Duration offset) {
+    private DateQueryParameter(DateQueryParameterType dateType, Duration offset) {
         this.dateType = dateType;
         this.offset = offset;
     }
 
-    public DateQueryParameter(DateQueryParameterType dateType) {
+    private DateQueryParameter(DateQueryParameterType dateType) {
         this.dateType = dateType;
     }
 
@@ -68,6 +69,24 @@ public class DateQueryParameter {
         return "null";
     }
 
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof DateQueryParameter other)) {
+            return false;
+        }
+
+        return Objects.equals(date, other.date) && Objects.equals(offset, other.offset)
+                && Objects.equals(dateType, other.dateType);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(date, offset, dateType);
+    }
+
     public boolean isEmpty() {
         return this == EMPTY;
     }
index 933e013b0e2f4639c35b1ed93451b2de58378c70..c83b9f88909643a050a2a7c8a2012635af34c826 100644 (file)
@@ -13,6 +13,7 @@
 package org.openhab.binding.energidataservice.internal.api;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
 
 /**
  * Global Location Number.
@@ -43,6 +44,16 @@ public class GlobalLocationNumber {
         return gln;
     }
 
+    @Override
+    public boolean equals(@Nullable Object o) {
+        return o == this || (o instanceof GlobalLocationNumber other && gln.equals(other.gln));
+    }
+
+    @Override
+    public int hashCode() {
+        return gln.hashCode();
+    }
+
     public boolean isEmpty() {
         return this == EMPTY;
     }
index 6b32f35fe318de7c2ab734f658db303d4d15b132..04a2bfea4115b764d62feddb7c831d4065f6faea 100644 (file)
@@ -126,9 +126,9 @@ public class EnergiDataServiceCommandExtension extends AbstractConsoleCommandExt
         LocalDate startDate;
         LocalDate endDate;
 
-        private int ARGUMENT_POSITION_PRICE_COMPONENT = 1;
-        private int ARGUMENT_POSITION_START_DATE = 2;
-        private int ARGUMENT_POSITION_END_DATE = 3;
+        private static final int ARGUMENT_POSITION_PRICE_COMPONENT = 1;
+        private static final int ARGUMENT_POSITION_START_DATE = 2;
+        private static final int ARGUMENT_POSITION_END_DATE = 3;
 
         ParsedUpdateParameters(String[] args) {
             if (args.length < 3 || args.length > 4) {
index 73ac59b31563e265160f06ea9dd611031a106709..29d90c5704d609a466b15198e5c484e7c252d6b5 100644 (file)
@@ -20,6 +20,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.eclipse.jetty.client.HttpClient;
 import org.openhab.binding.energidataservice.internal.handler.EnergiDataServiceHandler;
+import org.openhab.binding.energidataservice.internal.provider.Co2EmissionProvider;
+import org.openhab.binding.energidataservice.internal.provider.ElectricityPriceProvider;
 import org.openhab.core.i18n.TimeZoneProvider;
 import org.openhab.core.io.net.http.HttpClientFactory;
 import org.openhab.core.thing.Thing;
@@ -46,13 +48,19 @@ public class EnergiDataServiceHandlerFactory extends BaseThingHandlerFactory {
 
     private final HttpClient httpClient;
     private final TimeZoneProvider timeZoneProvider;
+    private final ElectricityPriceProvider electricityPriceProvider;
+    private final Co2EmissionProvider co2EmissionProvider;
 
     @Activate
     public EnergiDataServiceHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
-            final @Reference TimeZoneProvider timeZoneProvider, ComponentContext componentContext) {
+            final @Reference TimeZoneProvider timeZoneProvider,
+            final @Reference ElectricityPriceProvider electricityPriceProvider,
+            final @Reference Co2EmissionProvider co2EmissionProvider, ComponentContext componentContext) {
         super.activate(componentContext);
         this.httpClient = httpClientFactory.getCommonHttpClient();
         this.timeZoneProvider = timeZoneProvider;
+        this.electricityPriceProvider = electricityPriceProvider;
+        this.co2EmissionProvider = co2EmissionProvider;
     }
 
     @Override
@@ -65,7 +73,8 @@ public class EnergiDataServiceHandlerFactory extends BaseThingHandlerFactory {
         ThingTypeUID thingTypeUID = thing.getThingTypeUID();
 
         if (THING_TYPE_SERVICE.equals(thingTypeUID)) {
-            return new EnergiDataServiceHandler(thing, httpClient, timeZoneProvider);
+            return new EnergiDataServiceHandler(thing, httpClient, timeZoneProvider, electricityPriceProvider,
+                    co2EmissionProvider);
         }
 
         return null;
index 785b655364651a5b72b9b8b3ee73f5049978cddc..930150de1fda49fe59d9cbd14746f9702648073d 100644 (file)
@@ -19,50 +19,50 @@ import java.math.BigDecimal;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
 import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.time.temporal.ChronoUnit;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Comparator;
 import java.util.Currency;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 
 import javax.measure.Unit;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.http.HttpStatus;
 import org.openhab.binding.energidataservice.internal.ApiController;
-import org.openhab.binding.energidataservice.internal.CacheManager;
 import org.openhab.binding.energidataservice.internal.DatahubTariff;
-import org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants;
 import org.openhab.binding.energidataservice.internal.PriceListParser;
 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
 import org.openhab.binding.energidataservice.internal.api.ChargeType;
 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
-import org.openhab.binding.energidataservice.internal.api.Dataset;
 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
-import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecord;
 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
-import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
-import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
+import org.openhab.binding.energidataservice.internal.provider.Co2EmissionProvider;
+import org.openhab.binding.energidataservice.internal.provider.ElectricityPriceProvider;
+import org.openhab.binding.energidataservice.internal.provider.cache.ElectricityPriceSubscriptionCache;
+import org.openhab.binding.energidataservice.internal.provider.listener.Co2EmissionListener;
+import org.openhab.binding.energidataservice.internal.provider.listener.ElectricityPriceListener;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Co2EmissionSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.DatahubPriceSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.ElectricityPriceSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.SpotPriceSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Subscription;
 import org.openhab.core.i18n.TimeZoneProvider;
 import org.openhab.core.library.types.DecimalType;
 import org.openhab.core.library.types.QuantityType;
@@ -90,29 +90,29 @@ import org.slf4j.LoggerFactory;
  * @author Jacob Laursen - Initial contribution
  */
 @NonNullByDefault
-public class EnergiDataServiceHandler extends BaseThingHandler {
+public class EnergiDataServiceHandler extends BaseThingHandler
+        implements ElectricityPriceListener, Co2EmissionListener {
 
-    private static final Duration emissionPrognosisJobInterval = Duration.ofMinutes(15);
-    private static final Duration emissionRealtimeJobInterval = Duration.ofMinutes(5);
+    private static final Map<String, DatahubTariff> CHANNEL_ID_TO_DATAHUB_TARIFF = Arrays.stream(DatahubTariff.values())
+            .collect(Collectors.toMap(DatahubTariff::getChannelId, Function.identity()));
 
     private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
     private final TimeZoneProvider timeZoneProvider;
     private final ApiController apiController;
-    private final CacheManager cacheManager;
+    private final ElectricityPriceProvider electricityPriceProvider;
+    private final Co2EmissionProvider co2EmissionProvider;
+    private final Set<Subscription> activeSubscriptions = new HashSet<>();
 
     private EnergiDataServiceConfiguration config;
-    private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
-    private boolean realtimeEmissionsFetchedFirstTime = false;
-    private @Nullable ScheduledFuture<?> refreshPriceFuture;
-    private @Nullable ScheduledFuture<?> refreshEmissionPrognosisFuture;
-    private @Nullable ScheduledFuture<?> refreshEmissionRealtimeFuture;
-    private @Nullable ScheduledFuture<?> priceUpdateFuture;
-
-    public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
+
+    public EnergiDataServiceHandler(final Thing thing, final HttpClient httpClient,
+            final TimeZoneProvider timeZoneProvider, final ElectricityPriceProvider electricityPriceProvider,
+            final Co2EmissionProvider co2EmissionProvider) {
         super(thing);
         this.timeZoneProvider = timeZoneProvider;
         this.apiController = new ApiController(httpClient, timeZoneProvider);
-        this.cacheManager = new CacheManager();
+        this.electricityPriceProvider = electricityPriceProvider;
+        this.co2EmissionProvider = co2EmissionProvider;
 
         // Default configuration
         this.config = new EnergiDataServiceConfiguration();
@@ -126,12 +126,15 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
 
         String channelId = channelUID.getId();
         if (ELECTRICITY_CHANNELS.contains(channelId)) {
-            refreshElectricityPrices();
-        } else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) {
-            rescheduleEmissionPrognosisJob();
-        } else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) {
-            realtimeEmissionsFetchedFirstTime = false;
-            rescheduleEmissionRealtimeJob();
+            if (!electricityPriceProvider.forceRefreshPrices(getChannelSubscription(channelId))) {
+                // All subscriptions are automatically notified upon actual changes after download.
+                // If cached values are the same, we will update the requested channel directly.
+                updateChannelFromCache(getChannelSubscription(channelId), channelId);
+            }
+        } else if (CO2_EMISSION_CHANNELS.contains(channelId)) {
+            Subscription subscription = getChannelSubscription(channelId);
+            unsubscribe(subscription);
+            subscribe(subscription);
         }
     }
 
@@ -157,42 +160,19 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
             return;
         }
 
-        updateStatus(ThingStatus.UNKNOWN);
-
-        refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
-
-        if (isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
-            rescheduleEmissionPrognosisJob();
-        }
-        if (isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
-            rescheduleEmissionRealtimeJob();
+        if (SUBSCRIPTION_CHANNELS.stream().anyMatch(this::isLinked)) {
+            updateStatus(ThingStatus.UNKNOWN);
+            subscribeLinkedChannels();
+        } else {
+            updateStatus(ThingStatus.ONLINE);
         }
     }
 
     @Override
     public void dispose() {
-        ScheduledFuture<?> refreshPriceFuture = this.refreshPriceFuture;
-        if (refreshPriceFuture != null) {
-            refreshPriceFuture.cancel(true);
-            this.refreshPriceFuture = null;
-        }
-        ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
-        if (refreshEmissionPrognosisFuture != null) {
-            refreshEmissionPrognosisFuture.cancel(true);
-            this.refreshEmissionPrognosisFuture = null;
-        }
-        ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
-        if (refreshEmissionRealtimeFuture != null) {
-            refreshEmissionRealtimeFuture.cancel(true);
-            this.refreshEmissionRealtimeFuture = null;
-        }
-        ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
-        if (priceUpdateFuture != null) {
-            priceUpdateFuture.cancel(true);
-            this.priceUpdateFuture = null;
-        }
-
-        cacheManager.clear();
+        electricityPriceProvider.unsubscribe(this);
+        co2EmissionProvider.unsubscribe(this);
+        activeSubscriptions.clear();
     }
 
     @Override
@@ -202,13 +182,30 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
 
     @Override
     public void channelLinked(ChannelUID channelUID) {
-        super.channelLinked(channelUID);
+        String channelId = channelUID.getId();
+        if (!SUBSCRIPTION_CHANNELS.contains(channelId)) {
+            // Do not trigger REFRESH command for subscription-based channels, we will trigger
+            // a state update ourselves through relevant provider.
+            super.channelLinked(channelUID);
+        }
 
-        if (!"DK1".equals(config.priceArea) && !"DK2".equals(config.priceArea)
-                && (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelUID.getId())
-                        || CHANNEL_CO2_EMISSION_REALTIME.contains(channelUID.getId()))) {
-            logger.warn("Item linked to channel '{}', but price area {} is not supported for this channel",
-                    channelUID.getId(), config.priceArea);
+        if (ELECTRICITY_CHANNELS.contains(channelId)) {
+            Subscription subscription = getChannelSubscription(channelId);
+            if (subscribe(subscription)) {
+                logger.debug("First item linked to channel '{}', starting {}", channelId, subscription);
+            } else {
+                updateChannelFromCache(subscription, channelId);
+            }
+        } else if (CO2_EMISSION_CHANNELS.contains(channelId)) {
+            if ("DK1".equals(config.priceArea) || "DK2".equals(config.priceArea)) {
+                Subscription subscription = getChannelSubscription(channelId);
+                if (subscribe(subscription)) {
+                    logger.debug("First item linked to channel '{}', starting {}", channelId, subscription);
+                }
+            } else {
+                logger.warn("Item linked to channel '{}', but price area {} is not supported for this channel",
+                        channelId, config.priceArea);
+            }
         }
     }
 
@@ -216,115 +213,146 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
     public void channelUnlinked(ChannelUID channelUID) {
         super.channelUnlinked(channelUID);
 
-        if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelUID.getId()) && !isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
-            logger.debug("No more items linked to channel '{}', stopping emission prognosis refresh job",
-                    channelUID.getId());
-            ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
-            if (refreshEmissionPrognosisFuture != null) {
-                refreshEmissionPrognosisFuture.cancel(true);
-                this.refreshEmissionPrognosisFuture = null;
-            }
-        } else if (CHANNEL_CO2_EMISSION_REALTIME.contains(channelUID.getId())
-                && !isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
-            logger.debug("No more items linked to channel '{}', stopping realtime emission refresh job",
-                    channelUID.getId());
-            ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
-            if (refreshEmissionRealtimeFuture != null) {
-                refreshEmissionRealtimeFuture.cancel(true);
-                this.refreshEmissionRealtimeFuture = null;
-            }
+        String channelId = channelUID.getId();
+        if (SUBSCRIPTION_CHANNELS.contains(channelId) && !isLinked(channelId)) {
+            Subscription subscription = getChannelSubscription(channelId);
+            logger.debug("No more items linked to channel '{}', stopping {}", channelId, subscription);
+            unsubscribe(getChannelSubscription(channelId));
         }
     }
 
-    private void refreshElectricityPrices() {
-        RetryStrategy retryPolicy;
-        try {
-            boolean spotPricesDownloaded = false;
-            if (isLinked(CHANNEL_SPOT_PRICE)) {
-                spotPricesDownloaded = downloadSpotPrices();
-            }
+    @Override
+    public void onDayAheadAvailable() {
+        triggerChannel(CHANNEL_EVENT, EVENT_DAY_AHEAD_AVAILABLE);
+    }
 
-            for (DatahubTariff datahubTariff : DatahubTariff.values()) {
-                if (isLinked(datahubTariff.getChannelId())) {
-                    downloadTariffs(datahubTariff);
-                }
-            }
+    @Override
+    public void onCurrentSpotPrice(@Nullable BigDecimal price, Currency currency) {
+        updateStatus(ThingStatus.ONLINE);
+        updatePriceState(CHANNEL_SPOT_PRICE, price, currency);
+    }
 
-            updateStatus(ThingStatus.ONLINE);
-            updatePrices();
-            updateElectricityTimeSeriesFromCache();
-
-            if (isLinked(CHANNEL_SPOT_PRICE)) {
-                long numberOfFutureSpotPrices = cacheManager.getNumberOfFutureSpotPrices();
-                LocalTime now = LocalTime.now(NORD_POOL_TIMEZONE);
-
-                if (numberOfFutureSpotPrices >= 13 || (numberOfFutureSpotPrices == 12
-                        && now.isAfter(DAILY_REFRESH_TIME_CET.minusHours(1)) && now.isBefore(DAILY_REFRESH_TIME_CET))) {
-                    if (spotPricesDownloaded) {
-                        triggerChannel(CHANNEL_EVENT, EVENT_DAY_AHEAD_AVAILABLE);
-                    }
-                    retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
-                } else {
-                    logger.warn("Spot prices are not available, retry scheduled (see details in Thing properties)");
-                    retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing();
-                }
-            } else {
-                retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
-            }
-        } catch (DataServiceException e) {
-            if (e.getHttpStatus() != 0) {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
-                        HttpStatus.getCode(e.getHttpStatus()).getMessage());
-            } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
-            }
-            if (e.getCause() != null) {
-                logger.debug("Error retrieving prices", e);
-            }
-            retryPolicy = RetryPolicyFactory.fromThrowable(e);
-        } catch (InterruptedException e) {
-            logger.debug("Refresh job interrupted");
-            Thread.currentThread().interrupt();
-            return;
+    @Override
+    public void onSpotPrices(Map<Instant, BigDecimal> spotPrices, Currency currency) {
+        updateStatus(ThingStatus.ONLINE);
+        updatePriceTimeSeries(CHANNEL_SPOT_PRICE, spotPrices, currency, false);
+    }
+
+    @Override
+    public void onCurrentTariff(DatahubTariff datahubTariff, @Nullable BigDecimal tariff) {
+        updateStatus(ThingStatus.ONLINE);
+        updatePriceState(datahubTariff.getChannelId(), tariff, CURRENCY_DKK);
+    }
+
+    @Override
+    public void onTariffs(DatahubTariff datahubTariff, Map<Instant, BigDecimal> tariffs) {
+        updateStatus(ThingStatus.ONLINE);
+        updatePriceTimeSeries(datahubTariff.getChannelId(), tariffs, CURRENCY_DKK, true);
+    }
+
+    @Override
+    public void onCurrentEmission(Co2EmissionSubscription.Type type, BigDecimal emission) {
+        updateStatus(ThingStatus.ONLINE);
+        updateState(type == Co2EmissionSubscription.Type.Prognosis ? CHANNEL_CO2_EMISSION_PROGNOSIS
+                : CHANNEL_CO2_EMISSION_REALTIME, new QuantityType<>(emission, Units.GRAM_PER_KILOWATT_HOUR));
+    }
+
+    @Override
+    public void onEmissions(Co2EmissionSubscription.Type type, Map<Instant, BigDecimal> emissions) {
+        updateStatus(ThingStatus.ONLINE);
+        TimeSeries timeSeries = new TimeSeries(REPLACE);
+        for (Entry<Instant, BigDecimal> emission : emissions.entrySet()) {
+            timeSeries.add(emission.getKey(), new QuantityType<>(emission.getValue(), Units.GRAM_PER_KILOWATT_HOUR));
         }
+        sendTimeSeries(type == Co2EmissionSubscription.Type.Prognosis ? CHANNEL_CO2_EMISSION_PROGNOSIS
+                : CHANNEL_CO2_EMISSION_REALTIME, timeSeries);
+    }
 
-        reschedulePriceRefreshJob(retryPolicy);
+    @Override
+    public void onPropertiesUpdated(Map<String, String> properties) {
+        updateProperties(properties);
     }
 
-    private boolean downloadSpotPrices() throws InterruptedException, DataServiceException {
-        if (cacheManager.areSpotPricesFullyCached()) {
-            logger.debug("Cached spot prices still valid, skipping download.");
-            return false;
+    @Override
+    public void onCommunicationError(@Nullable String description) {
+        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, description);
+    }
+
+    @Override
+    public String toString() {
+        return this.thing.getUID().getAsString();
+    }
+
+    private void subscribeLinkedChannels() {
+        if (isLinked(CHANNEL_SPOT_PRICE)) {
+            subscribe(getChannelSubscription(CHANNEL_SPOT_PRICE));
         }
-        DateQueryParameter start;
-        if (cacheManager.areHistoricSpotPricesCached()) {
-            start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
-        } else {
-            start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
-                    Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
+
+        Arrays.stream(DatahubTariff.values()).filter(tariff -> isLinked(tariff.getChannelId()))
+                .map(tariff -> DatahubPriceSubscription.of(tariff, getGlobalLocationNumber(tariff),
+                        getDatahubTariffFilter(tariff)))
+                .forEach(this::subscribe);
+
+        if ("DK1".equals(config.priceArea) || "DK2".equals(config.priceArea)) {
+            CO2_EMISSION_CHANNELS.stream().filter(this::isLinked)
+                    .forEach(channelId -> subscribe(getChannelSubscription(channelId)));
         }
-        Map<String, String> properties = editProperties();
-        try {
-            ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
-                    start, DateQueryParameter.EMPTY, properties);
-            cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
-        } finally {
-            updateProperties(properties);
+    }
+
+    private boolean subscribe(Subscription subscription) {
+        if (activeSubscriptions.add(subscription)) {
+            if (subscription instanceof ElectricityPriceSubscription) {
+                electricityPriceProvider.subscribe(this, subscription);
+            } else if (subscription instanceof Co2EmissionSubscription) {
+                co2EmissionProvider.subscribe(this, subscription);
+            } else {
+                throw new IllegalArgumentException(subscription.getClass().getName() + " is not supported");
+            }
+            return true;
+        } else {
+            return false;
         }
-        return true;
     }
 
-    private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
-        GlobalLocationNumber globalLocationNumber = getGlobalLocationNumber(datahubTariff);
-        if (globalLocationNumber.isEmpty()) {
-            return;
+    private void unsubscribe(Subscription subscription) {
+        if (activeSubscriptions.remove(subscription)) {
+            if (subscription instanceof ElectricityPriceSubscription) {
+                electricityPriceProvider.unsubscribe(this, subscription);
+            } else if (subscription instanceof Co2EmissionSubscription) {
+                co2EmissionProvider.unsubscribe(this, subscription);
+            } else {
+                throw new IllegalArgumentException(subscription.getClass().getName() + " is not supported");
+            }
         }
-        if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
-            logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
-            cacheManager.updateTariffs(datahubTariff);
+    }
+
+    private Subscription getChannelSubscription(String channelId) {
+        if (CHANNEL_SPOT_PRICE.equals(channelId)) {
+            return SpotPriceSubscription.of(config.priceArea, config.getCurrency());
+        } else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) {
+            return Co2EmissionSubscription.of(config.priceArea, Co2EmissionSubscription.Type.Prognosis);
+        } else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) {
+            return Co2EmissionSubscription.of(config.priceArea, Co2EmissionSubscription.Type.Realtime);
         } else {
-            DatahubTariffFilter filter = getDatahubTariffFilter(datahubTariff);
-            cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
+            DatahubTariff tariff = CHANNEL_ID_TO_DATAHUB_TARIFF.get(channelId);
+
+            if (tariff != null) {
+                return DatahubPriceSubscription.of(tariff, getGlobalLocationNumber(tariff),
+                        getDatahubTariffFilter(tariff));
+            }
+        }
+        throw new IllegalArgumentException("Could not create subscription for channel id " + channelId);
+    }
+
+    private void updateChannelFromCache(Subscription subscription, String channelId) {
+        BigDecimal currentPrice = electricityPriceProvider.getCurrentPriceIfCached(subscription);
+        Map<Instant, BigDecimal> prices = electricityPriceProvider.getPricesIfCached(subscription);
+        if (subscription instanceof SpotPriceSubscription) {
+            updatePriceState(channelId, currentPrice, config.getCurrency());
+            updatePriceTimeSeries(channelId, prices, config.getCurrency(), false);
+        } else if (subscription instanceof DatahubPriceSubscription) {
+            updatePriceState(channelId, currentPrice, CURRENCY_DKK);
+            updatePriceTimeSeries(channelId, prices, CURRENCY_DKK, true);
         }
     }
 
@@ -345,16 +373,6 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
         };
     }
 
-    private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
-            DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
-        Map<String, String> properties = editProperties();
-        try {
-            return apiController.getDatahubPriceLists(globalLocationNumber, ChargeType.Tariff, filter, properties);
-        } finally {
-            updateProperties(properties);
-        }
-    }
-
     private DatahubTariffFilter getGridTariffFilter() {
         Channel channel = getThing().getChannel(CHANNEL_GRID_TARIFF);
         if (channel == null) {
@@ -387,110 +405,8 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
                     start);
         }
 
-        return new DatahubTariffFilter(filter,
-                DateQueryParameter.of(filter.getStart(), Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
-    }
-
-    private void refreshCo2EmissionPrognosis() {
-        try {
-            updateCo2Emissions(Dataset.CO2EmissionPrognosis, CHANNEL_CO2_EMISSION_PROGNOSIS,
-                    DateQueryParameter.of(DateQueryParameterType.UTC_NOW, Duration.ofMinutes(-5)));
-            updateStatus(ThingStatus.ONLINE);
-        } catch (DataServiceException e) {
-            if (e.getHttpStatus() != 0) {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
-                        HttpStatus.getCode(e.getHttpStatus()).getMessage());
-            } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
-            }
-            if (e.getCause() != null) {
-                logger.debug("Error retrieving CO2 emission prognosis", e);
-            }
-        } catch (InterruptedException e) {
-            logger.debug("Emission prognosis refresh job interrupted");
-            Thread.currentThread().interrupt();
-            return;
-        }
-    }
-
-    private void refreshCo2EmissionRealtime() {
-        try {
-            updateCo2Emissions(Dataset.CO2Emission, CHANNEL_CO2_EMISSION_REALTIME,
-                    DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
-                            realtimeEmissionsFetchedFirstTime ? Duration.ofMinutes(-5) : Duration.ofHours(-24)));
-            realtimeEmissionsFetchedFirstTime = true;
-            updateStatus(ThingStatus.ONLINE);
-        } catch (DataServiceException e) {
-            if (e.getHttpStatus() != 0) {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
-                        HttpStatus.getCode(e.getHttpStatus()).getMessage());
-            } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
-            }
-            if (e.getCause() != null) {
-                logger.debug("Error retrieving CO2 realtime emissions", e);
-            }
-        } catch (InterruptedException e) {
-            logger.debug("Emission realtime refresh job interrupted");
-            Thread.currentThread().interrupt();
-            return;
-        }
-    }
-
-    private void updateCo2Emissions(Dataset dataset, String channelId, DateQueryParameter dateQueryParameter)
-            throws InterruptedException, DataServiceException {
-        if (!"DK1".equals(config.priceArea) && !"DK2".equals(config.priceArea)) {
-            // Dataset is only for Denmark.
-            return;
-        }
-        Map<String, String> properties = editProperties();
-        CO2EmissionRecord[] emissionRecords = apiController.getCo2Emissions(dataset, config.priceArea,
-                dateQueryParameter, properties);
-        updateProperties(properties);
-
-        TimeSeries timeSeries = new TimeSeries(REPLACE);
-        Instant now = Instant.now();
-
-        if (dataset == Dataset.CO2Emission && emissionRecords.length > 0) {
-            // Records are sorted descending, first record is current.
-            updateState(channelId, new QuantityType<>(emissionRecords[0].emission(), Units.GRAM_PER_KILOWATT_HOUR));
-        }
-
-        for (CO2EmissionRecord emissionRecord : emissionRecords) {
-            State state = new QuantityType<>(emissionRecord.emission(), Units.GRAM_PER_KILOWATT_HOUR);
-            timeSeries.add(emissionRecord.start(), state);
-
-            if (dataset == Dataset.CO2EmissionPrognosis && now.compareTo(emissionRecord.start()) >= 0
-                    && now.compareTo(emissionRecord.end()) < 0) {
-                updateState(channelId, state);
-            }
-        }
-        sendTimeSeries(channelId, timeSeries);
-    }
-
-    private void updatePrices() {
-        cacheManager.cleanup();
-
-        updateCurrentSpotPrice();
-        Arrays.stream(DatahubTariff.values())
-                .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
-
-        reschedulePriceUpdateJob();
-    }
-
-    private void updateCurrentSpotPrice() {
-        if (!isLinked(CHANNEL_SPOT_PRICE)) {
-            return;
-        }
-        BigDecimal spotPrice = cacheManager.getSpotPrice();
-        updatePriceState(CHANNEL_SPOT_PRICE, spotPrice, config.getCurrency());
-    }
-
-    private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
-        if (!isLinked(channelId)) {
-            return;
-        }
-        updatePriceState(channelId, tariff, CURRENCY_DKK);
+        return new DatahubTariffFilter(filter, DateQueryParameter.of(filter.getStart(),
+                Duration.ofHours(-ElectricityPriceSubscriptionCache.NUMBER_OF_HISTORIC_HOURS)));
     }
 
     private void updatePriceState(String channelID, @Nullable BigDecimal price, Currency currency) {
@@ -529,7 +445,7 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
             Currency currency = config.getCurrency();
             ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, currency,
                     DateQueryParameter.of(startDate), DateQueryParameter.of(endDate.plusDays(1)), properties);
-            boolean isDKK = EnergiDataServiceBindingConstants.CURRENCY_DKK.equals(currency);
+            boolean isDKK = CURRENCY_DKK.equals(currency);
             TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
             if (spotPriceRecords.length == 0) {
                 return 0;
@@ -584,12 +500,13 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
         return updatePriceTimeSeries(datahubTariff.getChannelId(), tariffMap, CURRENCY_DKK, true);
     }
 
-    private void updateElectricityTimeSeriesFromCache() {
-        updatePriceTimeSeries(CHANNEL_SPOT_PRICE, cacheManager.getSpotPrices(), config.getCurrency(), false);
-
-        for (DatahubTariff datahubTariff : DatahubTariff.values()) {
-            String channelId = datahubTariff.getChannelId();
-            updatePriceTimeSeries(channelId, cacheManager.getTariffs(datahubTariff), CURRENCY_DKK, true);
+    private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
+            DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
+        Map<String, String> properties = editProperties();
+        try {
+            return apiController.getDatahubPriceLists(globalLocationNumber, ChargeType.Tariff, filter, properties);
+        } finally {
+            updateProperties(properties);
         }
     }
 
@@ -634,19 +551,7 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
      * @return Map of future spot prices
      */
     public Map<Instant, BigDecimal> getSpotPrices() {
-        try {
-            downloadSpotPrices();
-        } catch (DataServiceException e) {
-            if (logger.isDebugEnabled()) {
-                logger.warn("Error retrieving spot prices", e);
-            } else {
-                logger.warn("Error retrieving spot prices: {}", e.getMessage());
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-        }
-
-        return cacheManager.getSpotPrices();
+        return this.getPrices(getChannelSubscription(CHANNEL_SPOT_PRICE));
     }
 
     /**
@@ -656,19 +561,20 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
      * @return Map of future tariffs
      */
     public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
+        return this.getPrices(DatahubPriceSubscription.of(datahubTariff, getGlobalLocationNumber(datahubTariff),
+                getDatahubTariffFilter(datahubTariff)));
+    }
+
+    private Map<Instant, BigDecimal> getPrices(Subscription subscription) {
         try {
-            downloadTariffs(datahubTariff);
+            return electricityPriceProvider.getPrices(subscription);
         } catch (DataServiceException e) {
-            if (logger.isDebugEnabled()) {
-                logger.warn("Error retrieving tariffs", e);
-            } else {
-                logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
-            }
+            logger.warn("Error retrieving prices for subscription {}: {}", subscription, e.getMessage());
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
         }
 
-        return cacheManager.getTariffs(datahubTariff);
+        return Map.of();
     }
 
     /**
@@ -679,66 +585,4 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
     public boolean isReducedElectricityTax() {
         return config.reducedElectricityTax;
     }
-
-    private void reschedulePriceUpdateJob() {
-        ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
-        if (priceUpdateJob != null) {
-            // Do not interrupt ourselves.
-            priceUpdateJob.cancel(false);
-            this.priceUpdateFuture = null;
-        }
-
-        Instant now = Instant.now();
-        long millisUntilNextClockHour = Duration
-                .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
-        this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
-                TimeUnit.MILLISECONDS);
-        logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
-    }
-
-    private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
-        // Preserve state of previous retry policy when configuration is the same.
-        if (!retryPolicy.equals(this.retryPolicy)) {
-            this.retryPolicy = retryPolicy;
-        }
-
-        ScheduledFuture<?> refreshJob = this.refreshPriceFuture;
-
-        long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
-        Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
-        this.refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
-                TimeUnit.SECONDS);
-        logger.debug("Price refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
-        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
-        updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
-                .truncatedTo(ChronoUnit.SECONDS).format(formatter));
-
-        if (refreshJob != null) {
-            refreshJob.cancel(true);
-        }
-    }
-
-    private void rescheduleEmissionPrognosisJob() {
-        logger.debug("Scheduling emission prognosis refresh job now and every {}", emissionPrognosisJobInterval);
-
-        ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
-        if (refreshEmissionPrognosisFuture != null) {
-            refreshEmissionPrognosisFuture.cancel(true);
-        }
-
-        this.refreshEmissionPrognosisFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionPrognosis, 0,
-                emissionPrognosisJobInterval.toSeconds(), TimeUnit.SECONDS);
-    }
-
-    private void rescheduleEmissionRealtimeJob() {
-        logger.debug("Scheduling emission realtime refresh job now and every {}", emissionRealtimeJobInterval);
-
-        ScheduledFuture<?> refreshEmissionFuture = this.refreshEmissionRealtimeFuture;
-        if (refreshEmissionFuture != null) {
-            refreshEmissionFuture.cancel(true);
-        }
-
-        this.refreshEmissionRealtimeFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionRealtime, 0,
-                emissionRealtimeJobInterval.toSeconds(), TimeUnit.SECONDS);
-    }
 }
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/AbstractProvider.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/AbstractProvider.java
new file mode 100644 (file)
index 0000000..9b6ef14
--- /dev/null
@@ -0,0 +1,108 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.energidataservice.internal.provider.listener.SubscriptionListener;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Subscription;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link AbstractProvider} is responsible for managing subscriptions.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public abstract class AbstractProvider<L extends SubscriptionListener> {
+
+    protected final Map<L, Set<Subscription>> listenerToSubscriptions = new ConcurrentHashMap<>();
+    protected final Map<Subscription, Set<L>> subscriptionToListeners = new ConcurrentHashMap<>();
+
+    private final Logger logger = LoggerFactory.getLogger(AbstractProvider.class);
+
+    protected boolean subscribeInternal(L listener, Subscription subscription) {
+        Set<Subscription> subscriptionsForListener = Objects
+                .requireNonNull(listenerToSubscriptions.computeIfAbsent(listener, k -> ConcurrentHashMap.newKeySet()));
+
+        if (subscriptionsForListener.contains(subscription)) {
+            throw new IllegalArgumentException(
+                    "Duplicate listener registration for " + listener.getClass().getName() + ": " + subscription);
+        }
+
+        subscriptionsForListener.add(subscription);
+
+        Set<L> listenersForSubscription = subscriptionToListeners.get(subscription);
+        boolean isFirstDistinctSubscription = false;
+        if (listenersForSubscription == null) {
+            isFirstDistinctSubscription = true;
+            listenersForSubscription = ConcurrentHashMap.newKeySet();
+            subscriptionToListeners.put(subscription, listenersForSubscription);
+        }
+
+        listenersForSubscription.add(listener);
+        logger.debug("Listener {} started {}", listener, subscription);
+
+        return isFirstDistinctSubscription;
+    }
+
+    protected boolean unsubscribeInternal(L listener, Subscription subscription) {
+        Set<Subscription> listenerSubscriptions = listenerToSubscriptions.get(listener);
+
+        if (listenerSubscriptions == null || !listenerSubscriptions.contains(subscription)) {
+            throw new IllegalArgumentException(
+                    "Listener is not subscribed to the specified subscription: " + subscription);
+        }
+
+        listenerSubscriptions.remove(subscription);
+
+        if (listenerSubscriptions.isEmpty()) {
+            listenerToSubscriptions.remove(listener);
+        }
+
+        Set<L> listenersForSubscription = subscriptionToListeners.get(subscription);
+
+        boolean isLastDistinctSubscription = false;
+        if (listenersForSubscription != null) {
+            listenersForSubscription.remove(listener);
+
+            if (listenersForSubscription.isEmpty()) {
+                subscriptionToListeners.remove(subscription);
+                isLastDistinctSubscription = true;
+            }
+        }
+
+        logger.debug("Listener {} stopped {}", listener, subscription);
+
+        return isLastDistinctSubscription;
+    }
+
+    public void unsubscribe(L listener) {
+        Set<Subscription> listenerSubscriptions = listenerToSubscriptions.get(listener);
+        if (listenerSubscriptions == null) {
+            return;
+        }
+        for (Subscription subscription : listenerSubscriptions) {
+            unsubscribeInternal(listener, subscription);
+        }
+    }
+
+    protected Set<L> getListeners(Subscription subscription) {
+        return subscriptionToListeners.getOrDefault(subscription, ConcurrentHashMap.newKeySet());
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/Co2EmissionProvider.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/Co2EmissionProvider.java
new file mode 100644 (file)
index 0000000..67584af
--- /dev/null
@@ -0,0 +1,223 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ScheduledFuture;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.energidataservice.internal.ApiController;
+import org.openhab.binding.energidataservice.internal.api.Dataset;
+import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
+import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
+import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecord;
+import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
+import org.openhab.binding.energidataservice.internal.provider.listener.Co2EmissionListener;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Co2EmissionSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Subscription;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.scheduler.PeriodicScheduler;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link Co2EmissionProvider} is responsible for fetching CO2 emission
+ * data and providing it to subscribed listeners.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = Co2EmissionProvider.class)
+public class Co2EmissionProvider extends AbstractProvider<Co2EmissionListener> {
+
+    private static final Duration EMISSION_PROGNOSIS_JOB_INTERVAL = Duration.ofMinutes(15);
+    private static final Duration EMISSION_REALTIME_JOB_INTERVAL = Duration.ofMinutes(5);
+
+    private final Logger logger = LoggerFactory.getLogger(Co2EmissionProvider.class);
+    private final PeriodicScheduler scheduler;
+    private final ApiController apiController;
+
+    private boolean realtimeEmissionsFetchedFirstTime = false;
+    private @Nullable ScheduledFuture<?> refreshEmissionPrognosisFuture;
+    private @Nullable ScheduledFuture<?> refreshEmissionRealtimeFuture;
+
+    @Activate
+    public Co2EmissionProvider(final @Reference PeriodicScheduler scheduler,
+            final @Reference HttpClientFactory httpClientFactory, final @Reference TimeZoneProvider timeZoneProvider) {
+        this.scheduler = scheduler;
+        this.apiController = new ApiController(httpClientFactory.getCommonHttpClient(), timeZoneProvider);
+    }
+
+    @Deactivate
+    public void deactivate() {
+        stopJobs();
+    }
+
+    public void subscribe(Co2EmissionListener listener, Subscription subscription) {
+        if (!(subscription instanceof Co2EmissionSubscription co2EmissionSubscription)) {
+            throw new IllegalArgumentException(subscription.getClass().getName() + " is not supported");
+        }
+        if (!"DK1".equals(co2EmissionSubscription.getPriceArea())
+                && !"DK2".equals(co2EmissionSubscription.getPriceArea())) {
+            // Dataset is only for Denmark.
+            throw new IllegalArgumentException("Only price areas DK1 and DK2 are supported");
+        }
+        subscribeInternal(listener, subscription);
+
+        if (Co2EmissionSubscription.Type.Prognosis == co2EmissionSubscription.getType()) {
+            rescheduleEmissionPrognosisJob();
+        } else if (Co2EmissionSubscription.Type.Realtime == co2EmissionSubscription.getType()) {
+            rescheduleEmissionRealtimeJob();
+        }
+    }
+
+    public void unsubscribe(Co2EmissionListener listener, Subscription subscription) {
+        unsubscribeInternal(listener, subscription);
+
+        if (!subscriptionToListeners.keySet().stream().filter(Co2EmissionSubscription.class::isInstance)
+                .map(Co2EmissionSubscription.class::cast)
+                .anyMatch(s -> s.getType() == Co2EmissionSubscription.Type.Prognosis)) {
+            logger.trace("Last prognosis subscriber, stop job");
+            stopPrognosisJob();
+        }
+        if (!subscriptionToListeners.keySet().stream().filter(Co2EmissionSubscription.class::isInstance)
+                .map(Co2EmissionSubscription.class::cast)
+                .anyMatch(s -> s.getType() == Co2EmissionSubscription.Type.Realtime)) {
+            logger.trace("Last realtime subscriber, stop job");
+            stopRealtimeJob();
+            realtimeEmissionsFetchedFirstTime = false;
+        }
+    }
+
+    private void stopJobs() {
+        stopPrognosisJob();
+        stopRealtimeJob();
+    }
+
+    private void stopPrognosisJob() {
+        ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
+        if (refreshEmissionPrognosisFuture != null) {
+            refreshEmissionPrognosisFuture.cancel(true);
+            this.refreshEmissionPrognosisFuture = null;
+        }
+    }
+
+    private void stopRealtimeJob() {
+        ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
+        if (refreshEmissionRealtimeFuture != null) {
+            refreshEmissionRealtimeFuture.cancel(true);
+            this.refreshEmissionRealtimeFuture = null;
+        }
+    }
+
+    private void rescheduleEmissionPrognosisJob() {
+        logger.debug("Scheduling emission prognosis refresh job now and every {}", EMISSION_PROGNOSIS_JOB_INTERVAL);
+
+        stopPrognosisJob();
+        refreshEmissionPrognosisFuture = scheduler.schedule(this::refreshCo2EmissionPrognosis, Duration.ZERO,
+                EMISSION_PROGNOSIS_JOB_INTERVAL);
+    }
+
+    private void rescheduleEmissionRealtimeJob() {
+        logger.debug("Scheduling emission realtime refresh job now and every {}", EMISSION_REALTIME_JOB_INTERVAL);
+
+        stopRealtimeJob();
+        refreshEmissionRealtimeFuture = scheduler.schedule(this::refreshCo2EmissionRealtime, Duration.ZERO,
+                EMISSION_REALTIME_JOB_INTERVAL);
+    }
+
+    private void refreshCo2EmissionPrognosis() {
+        refreshCo2Emission(Co2EmissionSubscription.Type.Prognosis);
+    }
+
+    private void refreshCo2EmissionRealtime() {
+        refreshCo2Emission(Co2EmissionSubscription.Type.Realtime);
+    }
+
+    private void refreshCo2Emission(Co2EmissionSubscription.Type type) {
+        try {
+            for (Subscription subscription : subscriptionToListeners.keySet()) {
+                if (!(subscription instanceof Co2EmissionSubscription co2EmissionSubscription)
+                        || co2EmissionSubscription.getType() != type) {
+                    continue;
+                }
+
+                updateCo2Emissions(co2EmissionSubscription,
+                        DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
+                                realtimeEmissionsFetchedFirstTime || type == Co2EmissionSubscription.Type.Prognosis
+                                        ? Duration.ofMinutes(-5)
+                                        : Duration.ofHours(-24)));
+
+                if (type == Co2EmissionSubscription.Type.Realtime) {
+                    realtimeEmissionsFetchedFirstTime = true;
+                }
+            }
+        } catch (DataServiceException e) {
+            if (e.getHttpStatus() != 0) {
+                listenerToSubscriptions.keySet().forEach(
+                        listener -> listener.onCommunicationError(HttpStatus.getCode(e.getHttpStatus()).getMessage()));
+            } else {
+                listenerToSubscriptions.keySet().forEach(listener -> listener.onCommunicationError(e.getMessage()));
+            }
+            if (e.getCause() != null) {
+                logger.debug("Error retrieving CO2 emissions", e);
+            }
+        } catch (InterruptedException e) {
+            logger.debug("Emission refresh job {} interrupted", type);
+            Thread.currentThread().interrupt();
+            return;
+        }
+    }
+
+    private void updateCo2Emissions(Co2EmissionSubscription subscription, DateQueryParameter dateQueryParameter)
+            throws InterruptedException, DataServiceException {
+        Dataset dataset = subscription.getType().getDataset();
+        Map<String, String> properties = new HashMap<>();
+        CO2EmissionRecord[] emissionRecords = apiController.getCo2Emissions(dataset, subscription.getPriceArea(),
+                dateQueryParameter, properties);
+        Set<Co2EmissionListener> listeners = getListeners(subscription);
+        listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(properties));
+
+        Instant now = Instant.now();
+
+        if (dataset == Dataset.CO2Emission && emissionRecords.length > 0) {
+            // Records are sorted descending, first record is current.
+            listeners.forEach(
+                    listener -> listener.onCurrentEmission(subscription.getType(), emissionRecords[0].emission()));
+        }
+
+        Map<Instant, BigDecimal> emissions = new HashMap<>();
+        for (CO2EmissionRecord emissionRecord : emissionRecords) {
+            emissions.put(emissionRecord.start(), emissionRecord.emission());
+
+            if (dataset == Dataset.CO2EmissionPrognosis && now.compareTo(emissionRecord.start()) >= 0
+                    && now.compareTo(emissionRecord.end()) < 0) {
+                listeners.forEach(
+                        listener -> listener.onCurrentEmission(subscription.getType(), emissionRecord.emission()));
+            }
+        }
+        listeners.forEach(listener -> listener.onEmissions(subscription.getType(), emissions));
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java
new file mode 100644 (file)
index 0000000..4404434
--- /dev/null
@@ -0,0 +1,442 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider;
+
+import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.energidataservice.internal.ApiController;
+import org.openhab.binding.energidataservice.internal.api.ChargeType;
+import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
+import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
+import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
+import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
+import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
+import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
+import org.openhab.binding.energidataservice.internal.provider.cache.DatahubPriceSubscriptionCache;
+import org.openhab.binding.energidataservice.internal.provider.cache.ElectricityPriceSubscriptionCache;
+import org.openhab.binding.energidataservice.internal.provider.cache.SpotPriceSubscriptionCache;
+import org.openhab.binding.energidataservice.internal.provider.cache.SubscriptionDataCache;
+import org.openhab.binding.energidataservice.internal.provider.listener.ElectricityPriceListener;
+import org.openhab.binding.energidataservice.internal.provider.subscription.DatahubPriceSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.ElectricityPriceSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.SpotPriceSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Subscription;
+import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
+import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.scheduler.Scheduler;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ElectricityPriceProvider} is responsible for fetching electricity
+ * prices and providing them to subscribed listeners.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ElectricityPriceProvider.class)
+public class ElectricityPriceProvider extends AbstractProvider<ElectricityPriceListener> {
+
+    private final Logger logger = LoggerFactory.getLogger(ElectricityPriceProvider.class);
+    private final TimeZoneProvider timeZoneProvider;
+    private final Scheduler scheduler;
+    private final ApiController apiController;
+    private final Map<Subscription, SubscriptionDataCache<?>> subscriptionDataCaches = new ConcurrentHashMap<>();
+
+    private @Nullable ScheduledFuture<?> refreshFuture;
+    private @Nullable ScheduledFuture<?> priceUpdateFuture;
+    private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
+
+    @Activate
+    public ElectricityPriceProvider(final @Reference Scheduler scheduler,
+            final @Reference HttpClientFactory httpClientFactory, final @Reference TimeZoneProvider timeZoneProvider) {
+        this.scheduler = scheduler;
+        this.timeZoneProvider = timeZoneProvider;
+        this.apiController = new ApiController(httpClientFactory.getCommonHttpClient(), timeZoneProvider);
+    }
+
+    @Deactivate
+    public void deactivate() {
+        stopJobs();
+    }
+
+    public void subscribe(ElectricityPriceListener listener, Subscription subscription) {
+        if (!(subscription instanceof ElectricityPriceSubscription)) {
+            throw new IllegalArgumentException(subscription.getClass().getName() + " is not supported");
+        }
+        boolean isFirstDistinctSubscription = subscribeInternal(listener, subscription);
+
+        if (isFirstDistinctSubscription) {
+            ScheduledFuture<?> refreshFuture = this.refreshFuture;
+            if (refreshFuture != null) {
+                refreshFuture.cancel(true);
+            }
+            this.refreshFuture = scheduler.at(this::refreshElectricityPrices, Instant.now());
+        } else {
+            publishCurrentPriceFromCache(subscription, Set.of(listener));
+            publishPricesFromCache(subscription, Set.of(listener));
+        }
+    }
+
+    public void unsubscribe(ElectricityPriceListener listener, Subscription subscription) {
+        boolean isLastDistinctSubscription = unsubscribeInternal(listener, subscription);
+        if (isLastDistinctSubscription) {
+            subscriptionDataCaches.remove(subscription);
+        }
+
+        if (subscriptionToListeners.isEmpty()) {
+            logger.trace("Last subscriber, stop jobs");
+            stopJobs();
+        }
+    }
+
+    private void stopJobs() {
+        ScheduledFuture<?> refreshFuture = this.refreshFuture;
+        if (refreshFuture != null) {
+            refreshFuture.cancel(true);
+            this.refreshFuture = null;
+        }
+
+        ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
+        if (priceUpdateFuture != null) {
+            priceUpdateFuture.cancel(true);
+            this.priceUpdateFuture = null;
+        }
+    }
+
+    private void refreshElectricityPrices() {
+        RetryStrategy retryPolicy;
+        try {
+            Set<ElectricityPriceListener> spotPricesUpdatedListeners = new HashSet<>();
+            boolean spotPricesSubscribed = false;
+            long numberOfFutureSpotPrices = 0;
+
+            for (Entry<Subscription, Set<ElectricityPriceListener>> subscriptionListener : subscriptionToListeners
+                    .entrySet()) {
+                Subscription subscription = subscriptionListener.getKey();
+                Set<ElectricityPriceListener> listeners = subscriptionListener.getValue();
+
+                boolean pricesUpdated = downloadPrices(subscription, false);
+                if (subscription instanceof SpotPriceSubscription) {
+                    spotPricesSubscribed = true;
+                    if (pricesUpdated) {
+                        spotPricesUpdatedListeners.addAll(listeners);
+                    }
+                    long numberOfFutureSpotPricesForSubscription = getSpotPriceSubscriptionDataCache(subscription)
+                            .getNumberOfFuturePrices();
+                    if (numberOfFutureSpotPrices == 0
+                            || numberOfFutureSpotPricesForSubscription < numberOfFutureSpotPrices) {
+                        numberOfFutureSpotPrices = numberOfFutureSpotPricesForSubscription;
+                    }
+                }
+                updateCurrentPrices(subscription);
+                publishPricesFromCache(subscription, listeners);
+            }
+
+            reschedulePriceUpdateJob();
+
+            if (spotPricesSubscribed) {
+                LocalTime now = LocalTime.now(NORD_POOL_TIMEZONE);
+
+                if (numberOfFutureSpotPrices >= 13 || (numberOfFutureSpotPrices == 12
+                        && now.isAfter(DAILY_REFRESH_TIME_CET.minusHours(1)) && now.isBefore(DAILY_REFRESH_TIME_CET))) {
+                    spotPricesUpdatedListeners.forEach(listener -> listener.onDayAheadAvailable());
+                    retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
+                } else {
+                    logger.warn("Spot prices are not available, retry scheduled (see details in Thing properties)");
+                    retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing();
+                }
+            } else {
+                retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
+            }
+        } catch (DataServiceException e) {
+            if (e.getHttpStatus() != 0) {
+                listenerToSubscriptions.keySet().forEach(
+                        listener -> listener.onCommunicationError(HttpStatus.getCode(e.getHttpStatus()).getMessage()));
+            } else {
+                listenerToSubscriptions.keySet().forEach(listener -> listener.onCommunicationError(e.getMessage()));
+            }
+            if (e.getCause() != null) {
+                logger.debug("Error retrieving prices", e);
+            }
+            retryPolicy = RetryPolicyFactory.fromThrowable(e);
+        } catch (InterruptedException e) {
+            logger.debug("Refresh job interrupted");
+            Thread.currentThread().interrupt();
+            return;
+        }
+
+        reschedulePriceRefreshJob(retryPolicy);
+    }
+
+    /**
+     * Get current price if cached, otherwise null.
+     *
+     * @param subscription
+     * @return current price
+     */
+    public @Nullable BigDecimal getCurrentPriceIfCached(Subscription subscription) {
+        return getSubscriptionDataCache(subscription).get(Instant.now());
+    }
+
+    /**
+     * Get prices if cached, otherwise null.
+     *
+     * @param subscription
+     * @return Map of prices
+     */
+    public Map<Instant, BigDecimal> getPricesIfCached(Subscription subscription) {
+        return getSubscriptionDataCache(subscription).get();
+    }
+
+    /**
+     * Force refresh prices for {@link Subscription} even if already cached.
+     * The prices are not returned, but will be stored in the cache and can
+     * be obtained by {@link #getCurrentPriceIfCached(Subscription)}
+     * or {@link #getPricesIfCached(Subscription)}.
+     *
+     * @return true if cached values were changed as a result of the refresh
+     */
+    public boolean forceRefreshPrices(Subscription subscription) {
+        try {
+            return downloadPrices(subscription, true);
+        } catch (DataServiceException e) {
+            logger.debug("Error force retrieving prices", e);
+            return false;
+        } catch (InterruptedException e) {
+            logger.debug("Force refresh interrupted");
+            Thread.currentThread().interrupt();
+            return false;
+        }
+    }
+
+    /**
+     * Get all prices for given {@link Subscription}.
+     * If the prices are not already cached, they will be fetched
+     * from the service.
+     *
+     * @param subscription Subscription for which to get prices
+     * @return Map of available prices
+     * @throws InterruptedException
+     * @throws DataServiceException
+     */
+    public Map<Instant, BigDecimal> getPrices(Subscription subscription)
+            throws InterruptedException, DataServiceException {
+        downloadPrices(subscription, false);
+
+        return getSubscriptionDataCache(subscription).get();
+    }
+
+    private boolean downloadPrices(Subscription subscription, boolean force)
+            throws InterruptedException, DataServiceException {
+        if (subscription instanceof SpotPriceSubscription spotPriceSubscription) {
+            return downloadSpotPrices(spotPriceSubscription, false);
+        } else if (subscription instanceof DatahubPriceSubscription datahubPriceSubscription) {
+            return downloadTariffs(datahubPriceSubscription, false);
+        }
+        throw new IllegalArgumentException("Subscription " + subscription + " is not supported");
+    }
+
+    private boolean downloadSpotPrices(SpotPriceSubscription subscription, boolean force)
+            throws InterruptedException, DataServiceException {
+        SpotPriceSubscriptionCache cache = getSpotPriceSubscriptionDataCache(subscription);
+
+        if (!force && cache.arePricesFullyCached()) {
+            logger.debug("Cached spot prices still valid, skipping download.");
+            return false;
+        }
+        DateQueryParameter start;
+        if (!force && cache.areHistoricPricesCached()) {
+            start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
+        } else {
+            start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
+                    Duration.ofHours(-ElectricityPriceSubscriptionCache.NUMBER_OF_HISTORIC_HOURS));
+        }
+        Map<String, String> properties = new HashMap<>();
+        boolean isUpdated = false;
+        try {
+            ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(subscription.getPriceArea(),
+                    subscription.getCurrency(), start, DateQueryParameter.EMPTY, properties);
+            isUpdated = cache.put(spotPriceRecords);
+        } finally {
+            listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(properties));
+        }
+        return isUpdated;
+    }
+
+    private boolean downloadTariffs(DatahubPriceSubscription subscription, boolean force)
+            throws InterruptedException, DataServiceException {
+        GlobalLocationNumber globalLocationNumber = subscription.getGlobalLocationNumber();
+        if (globalLocationNumber.isEmpty()) {
+            return false;
+        }
+        DatahubPriceSubscriptionCache cache = getDatahubPriceSubscriptionDataCache(subscription);
+        if (!force && cache.areTariffsValidTomorrow()) {
+            logger.debug("Cached tariffs of type {} still valid, skipping download.", subscription.getDatahubTariff());
+            cache.update();
+            return false;
+        } else {
+            return cache.put(downloadPriceLists(subscription));
+        }
+    }
+
+    private Collection<DatahubPricelistRecord> downloadPriceLists(DatahubPriceSubscription subscription)
+            throws InterruptedException, DataServiceException {
+        Map<String, String> properties = new HashMap<>();
+        try {
+            return apiController.getDatahubPriceLists(subscription.getGlobalLocationNumber(), ChargeType.Tariff,
+                    subscription.getFilter(), properties);
+        } finally {
+            listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(properties));
+        }
+    }
+
+    private SpotPriceSubscriptionCache getSpotPriceSubscriptionDataCache(Subscription subscription) {
+        if (!(subscription instanceof SpotPriceSubscription)) {
+            throw new IllegalArgumentException("Invalid cache requested for subscription " + subscription);
+        }
+        SubscriptionDataCache<?> dataCache = getSubscriptionDataCache(subscription);
+        if (dataCache instanceof SpotPriceSubscriptionCache spotPriceSubscriptionCache) {
+            return spotPriceSubscriptionCache;
+        }
+
+        throw new IllegalArgumentException("Unexpected cache for subscription " + subscription);
+    }
+
+    private DatahubPriceSubscriptionCache getDatahubPriceSubscriptionDataCache(Subscription subscription) {
+        if (!(subscription instanceof DatahubPriceSubscription)) {
+            throw new IllegalArgumentException("Invalid cache requested for subscription " + subscription);
+        }
+        SubscriptionDataCache<?> dataCache = getSubscriptionDataCache(subscription);
+        if (dataCache instanceof DatahubPriceSubscriptionCache datahubPriceSubscriptionCache) {
+            return datahubPriceSubscriptionCache;
+        }
+
+        throw new IllegalArgumentException("Unexpected cache for subscription " + subscription);
+    }
+
+    private SubscriptionDataCache<?> getSubscriptionDataCache(Subscription subscription) {
+        SubscriptionDataCache<?> dataCache = subscriptionDataCaches.get(subscription);
+        if (dataCache != null) {
+            return dataCache;
+        }
+        if (subscription instanceof SpotPriceSubscription spotPriceSubscription) {
+            dataCache = new SpotPriceSubscriptionCache(spotPriceSubscription);
+        } else if (subscription instanceof DatahubPriceSubscription) {
+            dataCache = new DatahubPriceSubscriptionCache();
+        } else {
+            throw new IllegalArgumentException("No supported cache for subscription " + subscription);
+        }
+        subscriptionDataCaches.put(subscription, dataCache);
+
+        return dataCache;
+    }
+
+    private void publishPricesFromCache(Subscription subscription, Set<ElectricityPriceListener> listeners) {
+        if (subscription instanceof SpotPriceSubscription spotPriceSubscription) {
+            SpotPriceSubscriptionCache cache = getSpotPriceSubscriptionDataCache(subscription);
+            listeners.forEach(listener -> listener.onSpotPrices(cache.get(), spotPriceSubscription.getCurrency()));
+        } else if (subscription instanceof DatahubPriceSubscription datahubPriceSubscription) {
+            DatahubPriceSubscriptionCache cache = getDatahubPriceSubscriptionDataCache(subscription);
+            listeners.forEach(listener -> listener.onTariffs(datahubPriceSubscription.getDatahubTariff(), cache.get()));
+        }
+    }
+
+    private void updatePricesForAllSubscriptions() {
+        subscriptionToListeners.keySet().stream().forEach(this::updateCurrentPrices);
+
+        // Clean up caches not directly related to listener subscriptions, e.g. from Thing
+        // actions when having no linked channels.
+        subscriptionDataCaches.entrySet().stream().filter(entry -> !subscriptionToListeners.containsKey(entry.getKey()))
+                .forEach(entry -> entry.getValue().flush());
+
+        reschedulePriceUpdateJob();
+    }
+
+    private void updateCurrentPrices(Subscription subscription) {
+        getSubscriptionDataCache(subscription).flush();
+        publishCurrentPriceFromCache(subscription, getListeners(subscription));
+    }
+
+    private void publishCurrentPriceFromCache(Subscription subscription, Set<ElectricityPriceListener> listeners) {
+        BigDecimal currentPrice = getSubscriptionDataCache(subscription).get(Instant.now());
+        if (subscription instanceof SpotPriceSubscription spotPriceSubscription) {
+            listeners.forEach(
+                    listener -> listener.onCurrentSpotPrice(currentPrice, spotPriceSubscription.getCurrency()));
+        } else if (subscription instanceof DatahubPriceSubscription datahubPriceSubscription) {
+            listeners.forEach(
+                    listener -> listener.onCurrentTariff(datahubPriceSubscription.getDatahubTariff(), currentPrice));
+        }
+    }
+
+    private void reschedulePriceUpdateJob() {
+        ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
+        if (priceUpdateJob != null) {
+            // Do not interrupt ourselves.
+            priceUpdateJob.cancel(false);
+            this.priceUpdateFuture = null;
+        }
+
+        Instant nextUpdate = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS);
+        this.priceUpdateFuture = scheduler.at(this::updatePricesForAllSubscriptions, nextUpdate);
+        logger.debug("Price update job rescheduled at {}", nextUpdate);
+    }
+
+    private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
+        // Preserve state of previous retry policy when configuration is the same.
+        if (!retryPolicy.equals(this.retryPolicy)) {
+            this.retryPolicy = retryPolicy;
+        }
+
+        ScheduledFuture<?> refreshJob = this.refreshFuture;
+
+        long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
+        Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
+        this.refreshFuture = scheduler.at(this::refreshElectricityPrices, timeOfNextRefresh);
+        logger.debug("Price refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
+        String nextCall = LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
+                .truncatedTo(ChronoUnit.SECONDS).format(formatter);
+        Map<String, String> propertyMap = Map.of(PROPERTY_NEXT_CALL, nextCall);
+        listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(propertyMap));
+
+        if (refreshJob != null) {
+            refreshJob.cancel(true);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCache.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCache.java
new file mode 100644 (file)
index 0000000..911c5d0
--- /dev/null
@@ -0,0 +1,91 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.cache;
+
+import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
+
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.energidataservice.internal.PriceListParser;
+import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
+
+/**
+ * Datahub price (tariff) specific {@link ElectricityPriceSubscriptionCache} implementation.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class DatahubPriceSubscriptionCache
+        extends ElectricityPriceSubscriptionCache<Collection<DatahubPricelistRecord>> {
+
+    public static final int MAX_CACHE_SIZE = 24 * 2 + NUMBER_OF_HISTORIC_HOURS;
+
+    private final PriceListParser priceListParser = new PriceListParser();
+
+    private Collection<DatahubPricelistRecord> datahubRecords = new CopyOnWriteArrayList<>();
+
+    public DatahubPriceSubscriptionCache() {
+        this(Clock.systemDefaultZone());
+    }
+
+    public DatahubPriceSubscriptionCache(Clock clock) {
+        super(clock, MAX_CACHE_SIZE);
+    }
+
+    /**
+     * Replace current "raw"/unprocessed tariff records in cache.
+     * Map of hourly tariffs will be updated automatically.
+     * 
+     * @param records The records as received from Energi Data Service.
+     */
+    @Override
+    public boolean put(Collection<DatahubPricelistRecord> records) {
+        LocalDateTime localHourStart = LocalDateTime.now(clock.withZone(DATAHUB_TIMEZONE))
+                .minus(NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS);
+
+        List<DatahubPricelistRecord> newRecords = records.stream().filter(r -> !r.validTo().isBefore(localHourStart))
+                .toList();
+        boolean recordsAreEqual = datahubRecords.containsAll(newRecords) && newRecords.containsAll(datahubRecords);
+        datahubRecords = new CopyOnWriteArrayList<>(newRecords);
+        update();
+
+        return !recordsAreEqual;
+    }
+
+    /**
+     * Update map of hourly tariffs from internal cache.
+     */
+    public void update() {
+        priceMap.putAll(priceListParser.toHourly(datahubRecords));
+        flush();
+    }
+
+    /**
+     * Check if we have "raw" tariff records cached which are valid tomorrow.
+     * 
+     * @return true if tariff records for tomorrow are cached
+     */
+    public boolean areTariffsValidTomorrow() {
+        LocalDateTime localHourStart = LocalDateTime.now(clock.withZone(DATAHUB_TIMEZONE))
+                .truncatedTo(ChronoUnit.HOURS);
+        LocalDateTime localMidnight = localHourStart.plusDays(1).truncatedTo(ChronoUnit.DAYS);
+
+        return datahubRecords.stream().anyMatch(r -> r.validTo().isAfter(localMidnight));
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java
new file mode 100644 (file)
index 0000000..c2ad460
--- /dev/null
@@ -0,0 +1,118 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.cache;
+
+import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.NORD_POOL_TIMEZONE;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Electricity price specific {@link SubscriptionDataCache} implementation.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public abstract class ElectricityPriceSubscriptionCache<R> implements SubscriptionDataCache<R> {
+
+    public static final int NUMBER_OF_HISTORIC_HOURS = 24;
+
+    protected final Map<Instant, BigDecimal> priceMap;
+
+    protected final Clock clock;
+
+    protected ElectricityPriceSubscriptionCache(Clock clock, int initialCapacity) {
+        this.clock = clock.withZone(NORD_POOL_TIMEZONE);
+        this.priceMap = new ConcurrentHashMap<>(initialCapacity);
+    }
+
+    @Override
+    public void flush() {
+        Instant firstHourStart = getFirstHourStart();
+        priceMap.entrySet().removeIf(entry -> entry.getKey().isBefore(firstHourStart));
+    }
+
+    /**
+     * Get map of all cached prices.
+     *
+     * @return prices currently available, {@link #NUMBER_OF_HISTORIC_HOURS} back
+     */
+    @Override
+    public Map<Instant, BigDecimal> get() {
+        return new HashMap<>(priceMap);
+    }
+
+    /**
+     * Get price valid at provided instant.
+     *
+     * @param time {@link Instant} for which to get the price
+     * @return price at given time or null if not available
+     */
+    @Override
+    public @Nullable BigDecimal get(Instant time) {
+        return priceMap.get(getHourStart(time));
+    }
+
+    /**
+     * Get number of future prices including current hour.
+     * 
+     * @return number of future prices
+     */
+    @Override
+    public long getNumberOfFuturePrices() {
+        Instant currentHourStart = getCurrentHourStart();
+
+        return priceMap.entrySet().stream().filter(p -> !p.getKey().isBefore(currentHourStart)).count();
+    }
+
+    /**
+     * Check if historic prices ({@link #NUMBER_OF_HISTORIC_HOURS}) are cached.
+     * 
+     * @return true if historic prices are cached
+     */
+    @Override
+    public boolean areHistoricPricesCached() {
+        return arePricesCached(getCurrentHourStart().minus(1, ChronoUnit.HOURS));
+    }
+
+    protected boolean arePricesCached(Instant end) {
+        for (Instant hourStart = getFirstHourStart(); hourStart.compareTo(end) <= 0; hourStart = hourStart.plus(1,
+                ChronoUnit.HOURS)) {
+            if (priceMap.get(hourStart) == null) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    protected Instant getCurrentHourStart() {
+        return getHourStart(Instant.now(clock));
+    }
+
+    protected Instant getFirstHourStart() {
+        return getHourStart(Instant.now(clock).minus(NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS));
+    }
+
+    protected Instant getHourStart(Instant instant) {
+        return instant.truncatedTo(ChronoUnit.HOURS);
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCache.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCache.java
new file mode 100644 (file)
index 0000000..ec3c165
--- /dev/null
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.cache;
+
+import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
+import org.openhab.binding.energidataservice.internal.provider.subscription.SpotPriceSubscription;
+
+/**
+ * Spot price specific {@link ElectricityPriceSubscriptionCache} implementation.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class SpotPriceSubscriptionCache extends ElectricityPriceSubscriptionCache<ElspotpriceRecord[]> {
+
+    private static final int MAX_CACHE_SIZE = 24 + 11 + NUMBER_OF_HISTORIC_HOURS;
+
+    private final SpotPriceSubscription subscription;
+
+    public SpotPriceSubscriptionCache(SpotPriceSubscription subscription) {
+        this(subscription, Clock.systemDefaultZone());
+    }
+
+    public SpotPriceSubscriptionCache(SpotPriceSubscription subscription, Clock clock) {
+        super(clock, MAX_CACHE_SIZE);
+        this.subscription = subscription;
+    }
+
+    /**
+     * Convert and cache the supplied {@link ElspotpriceRecord}s.
+     * 
+     * @param records The records as received from Energi Data Service.
+     * @return true if the provided records resulted in any cache changes
+     */
+    @Override
+    public boolean put(ElspotpriceRecord[] records) {
+        boolean isDKK = CURRENCY_DKK.equals(subscription.getCurrency());
+        boolean anyChanges = false;
+        int oldSize = priceMap.size();
+        for (ElspotpriceRecord record : records) {
+            BigDecimal newValue = (isDKK ? record.spotPriceDKK() : record.spotPriceEUR())
+                    .divide(BigDecimal.valueOf(1000));
+            BigDecimal oldValue = priceMap.put(record.hour(), newValue);
+            if (oldValue == null || newValue.compareTo(oldValue) != 0) {
+                anyChanges = true;
+            }
+        }
+        anyChanges |= oldSize != priceMap.size();
+        flush();
+
+        return anyChanges;
+    }
+
+    /**
+     * Check if all current spot prices are cached taking into consideration that next day's spot prices
+     * should be available at 13:00 CET.
+     *
+     * @return true if spot prices are fully cached
+     */
+    public boolean arePricesFullyCached() {
+        Instant end = ZonedDateTime.of(LocalDate.now(clock), LocalTime.of(23, 0), NORD_POOL_TIMEZONE).toInstant();
+        LocalTime now = LocalTime.now(clock);
+        if (now.isAfter(DAILY_REFRESH_TIME_CET)) {
+            end = end.plus(24, ChronoUnit.HOURS);
+        }
+
+        return arePricesCached(end);
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java
new file mode 100644 (file)
index 0000000..ddd639d
--- /dev/null
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.cache;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Generic interface for caching prices related to subscription.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public interface SubscriptionDataCache<R> {
+
+    /**
+     * Add records to cache.
+     *
+     * @param records Records to add to cache
+     * @return true if the provided records resulted in any cache changes
+     */
+    boolean put(R records);
+
+    /**
+     * Get cached prices.
+     *
+     * @return Map of cached key/value pairs
+     */
+    Map<Instant, BigDecimal> get();
+
+    /**
+     * Get cached price for specific {@link Instant}.
+     *
+     * @param time Get cached value at this time
+     * @return Price at given time
+     */
+    @Nullable
+    BigDecimal get(Instant time);
+
+    /**
+     * Flush expired cached values.
+     */
+    void flush();
+
+    /**
+     * Get the number of future prices in the cache.
+     *
+     * @return number of cached future prices
+     */
+    long getNumberOfFuturePrices();
+
+    /**
+     * Check if all required historic values are cached, considering
+     * {@link ElectricityPriceSubscriptionCache#NUMBER_OF_HISTORIC_HOURS}.
+     *
+     * @return true if historic values are fully cached
+     */
+    boolean areHistoricPricesCached();
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/Co2EmissionListener.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/Co2EmissionListener.java
new file mode 100644 (file)
index 0000000..7f0f1fe
--- /dev/null
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.listener;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Co2EmissionSubscription;
+
+/**
+ * {@link Co2EmissionListener} provides an interface for receiving
+ * CO2 emission data.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public interface Co2EmissionListener extends SubscriptionListener {
+    /**
+     * Current realtime emission or prognosis has been updated.
+     *
+     * @param type The type (either {@link Co2EmissionSubscription.Type#Realtime} or
+     *            {@link Co2EmissionSubscription.Type#Prognosis})
+     * @param emission Emission in g/kWh
+     */
+    void onCurrentEmission(Co2EmissionSubscription.Type type, BigDecimal emission);
+
+    /**
+     * Realtime emissions or prognosis have changed.
+     * Can be used to update time series.
+     *
+     * @param type The type (either {@link Co2EmissionSubscription.Type#Realtime} or
+     *            {@link Co2EmissionSubscription.Type#Prognosis})
+     * @param emissions Emissions in g/kWh
+     */
+    void onEmissions(Co2EmissionSubscription.Type type, Map<Instant, BigDecimal> emissions);
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/ElectricityPriceListener.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/ElectricityPriceListener.java
new file mode 100644 (file)
index 0000000..c1d8210
--- /dev/null
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.listener;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.Currency;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.energidataservice.internal.DatahubTariff;
+
+/**
+ * {@link ElectricityPriceListener} provides an interface for receiving
+ * electricity price data.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public interface ElectricityPriceListener extends SubscriptionListener {
+    /**
+     * New day-ahead spot prices are available.
+     */
+    void onDayAheadAvailable();
+
+    /**
+     * Current spot price has been updated (every hour).
+     *
+     * @param price New current price
+     * @param currency Currency
+     */
+    void onCurrentSpotPrice(@Nullable BigDecimal price, Currency currency);
+
+    /**
+     * Spot prices have changed.
+     * Can be used to update time series.
+     *
+     * @param spotPrices New spot prices
+     * @param currency Currency
+     */
+    void onSpotPrices(Map<Instant, BigDecimal> spotPrices, Currency currency);
+
+    /**
+     * Current tariff has been updated.
+     *
+     * @param datahubTariff Tariff type that was updated
+     * @param tariff New tariff
+     */
+    void onCurrentTariff(DatahubTariff datahubTariff, @Nullable BigDecimal tariff);
+
+    /**
+     * Tariffs have changed.
+     * Can be used to update time series.
+     *
+     * @param datahubTariff Tariff type that was updated
+     * @param tariffs New tariffs
+     */
+    void onTariffs(DatahubTariff datahubTariff, Map<Instant, BigDecimal> tariffs);
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/SubscriptionListener.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/SubscriptionListener.java
new file mode 100644 (file)
index 0000000..495ef47
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.listener;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * {@link SubscriptionListener} provides a generic interface for receiving data
+ * from different providers.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public interface SubscriptionListener {
+    /**
+     * Properties (such as lastCall and nextCall) have been updated.
+     *
+     * @param properties
+     */
+    void onPropertiesUpdated(Map<String, String> properties);
+
+    /**
+     * A communication error has occurred when calling the service.
+     *
+     * @param description Error description
+     */
+    void onCommunicationError(@Nullable String description);
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Co2EmissionSubscription.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Co2EmissionSubscription.java
new file mode 100644 (file)
index 0000000..4fa7911
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.subscription;
+
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.energidataservice.internal.api.Dataset;
+
+/**
+ * Class for CO2 emission subscription.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class Co2EmissionSubscription implements Subscription {
+    private final String priceArea;
+    private final Type type;
+
+    public enum Type {
+        Prognosis(Dataset.CO2EmissionPrognosis),
+        Realtime(Dataset.CO2Emission);
+
+        private final Dataset dataset;
+
+        Type(Dataset dataset) {
+            this.dataset = dataset;
+        }
+
+        public Dataset getDataset() {
+            return dataset;
+        }
+    }
+
+    private Co2EmissionSubscription(String priceArea, Type type) {
+        this.priceArea = priceArea;
+        this.type = type;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof Co2EmissionSubscription other)) {
+            return false;
+        }
+
+        return this.priceArea.equals(other.priceArea) && this.type.equals((other.type));
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(priceArea, type);
+    }
+
+    @Override
+    public String toString() {
+        return "Co2EmissionSubscription: PriceArea=" + priceArea + ", Type=" + type;
+    }
+
+    public String getPriceArea() {
+        return priceArea;
+    }
+
+    public Type getType() {
+        return type;
+    }
+
+    public static Co2EmissionSubscription of(String priceArea, Type type) {
+        return new Co2EmissionSubscription(priceArea, type);
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/DatahubPriceSubscription.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/DatahubPriceSubscription.java
new file mode 100644 (file)
index 0000000..b5cc1c3
--- /dev/null
@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.subscription;
+
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.energidataservice.internal.DatahubTariff;
+import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
+import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
+
+/**
+ * Class for datahub price subscription.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class DatahubPriceSubscription implements ElectricityPriceSubscription {
+    private final DatahubTariff datahubTariff;
+    private final GlobalLocationNumber globalLocationNumber;
+    private final DatahubTariffFilter filter;
+
+    private DatahubPriceSubscription(DatahubTariff datahubTariff, GlobalLocationNumber globalLocationNumber,
+            DatahubTariffFilter filter) {
+        this.datahubTariff = datahubTariff;
+        this.globalLocationNumber = globalLocationNumber;
+        this.filter = filter;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof DatahubPriceSubscription other)) {
+            return false;
+        }
+
+        return this.globalLocationNumber.equals(other.globalLocationNumber) && this.filter.equals(other.filter);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(globalLocationNumber, filter);
+    }
+
+    @Override
+    public String toString() {
+        return "DatahubPriceSubscription: GLN=" + globalLocationNumber + ", Filter=" + filter;
+    }
+
+    public DatahubTariff getDatahubTariff() {
+        return datahubTariff;
+    }
+
+    public GlobalLocationNumber getGlobalLocationNumber() {
+        return globalLocationNumber;
+    }
+
+    public DatahubTariffFilter getFilter() {
+        return filter;
+    }
+
+    public static DatahubPriceSubscription of(DatahubTariff datahubTariff, GlobalLocationNumber globalLocationNumber,
+            DatahubTariffFilter filter) {
+        return new DatahubPriceSubscription(datahubTariff, globalLocationNumber, filter);
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/ElectricityPriceSubscription.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/ElectricityPriceSubscription.java
new file mode 100644 (file)
index 0000000..fcc3286
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.subscription;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Super interface for electricity price subscription.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public interface ElectricityPriceSubscription extends Subscription {
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java
new file mode 100644 (file)
index 0000000..a29f267
--- /dev/null
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.subscription;
+
+import java.util.Currency;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Class for spot price subscription.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class SpotPriceSubscription implements ElectricityPriceSubscription {
+    private final String priceArea;
+    private final Currency currency;
+
+    private SpotPriceSubscription(String priceArea, Currency currency) {
+        this.priceArea = priceArea;
+        this.currency = currency;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof SpotPriceSubscription other)) {
+            return false;
+        }
+
+        return this.priceArea.equals(other.priceArea) && this.currency.equals(other.currency);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(priceArea, currency);
+    }
+
+    @Override
+    public String toString() {
+        return "SpotPriceSubscription: PriceArea=" + priceArea + ", Currency=" + currency;
+    }
+
+    public String getPriceArea() {
+        return priceArea;
+    }
+
+    public Currency getCurrency() {
+        return currency;
+    }
+
+    public static SpotPriceSubscription of(String priceArea, Currency currency) {
+        return new SpotPriceSubscription(priceArea, currency);
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Subscription.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Subscription.java
new file mode 100644 (file)
index 0000000..e624133
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.subscription;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Generic interface for subscription.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public interface Subscription {
+}
index 5aa7065e34114f35883db36d15ca9300a153e776..a6b442d9698c2bf9ef016d4a2123fd75c8384c1e 100644 (file)
@@ -82,10 +82,9 @@ public class ExponentialBackoff implements RetryStrategy {
         if (o == this) {
             return true;
         }
-        if (!(o instanceof ExponentialBackoff)) {
+        if (!(o instanceof ExponentialBackoff other)) {
             return false;
         }
-        ExponentialBackoff other = (ExponentialBackoff) o;
 
         return this.factor == other.factor && this.jitter == other.jitter && this.minimum.equals(other.minimum)
                 && this.maximum.equals(other.maximum);
index 07b92da6f8c1c8540f9089214d2bde7200c6b148..de6631a309b8b32d891c146a00f4f004db5bfc4d 100644 (file)
@@ -75,10 +75,9 @@ public class Linear implements RetryStrategy {
         if (o == this) {
             return true;
         }
-        if (!(o instanceof Linear)) {
+        if (!(o instanceof Linear other)) {
             return false;
         }
-        Linear other = (Linear) o;
 
         return this.jitter == other.jitter && this.minimum.equals(other.minimum) && this.maximum.equals(other.maximum);
     }
diff --git a/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/CacheManagerTest.java b/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/CacheManagerTest.java
deleted file mode 100644 (file)
index e5ea271..0000000
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * Copyright (c) 2010-2024 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.energidataservice.internal;
-
-import static org.hamcrest.CoreMatchers.*;
-import static org.hamcrest.MatcherAssert.assertThat;
-
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.temporal.ChronoUnit;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
-
-/**
- * Tests for {@link CacheManager}.
- * 
- * @author Jacob Laursen - Initial contribution
- */
-@NonNullByDefault
-@ExtendWith(MockitoExtension.class)
-public class CacheManagerTest {
-
-    @Test
-    void areSpotPricesFullyCachedToday() {
-        Instant now = Instant.parse("2023-02-07T08:38:47Z");
-        Instant first = Instant.parse("2023-02-06T08:00:00Z");
-        Instant last = Instant.parse("2023-02-07T22:00:00Z");
-        Clock clock = Clock.fixed(now, EnergiDataServiceBindingConstants.NORD_POOL_TIMEZONE);
-        CacheManager cacheManager = new CacheManager(clock);
-        populateWithSpotPrices(cacheManager, first, last);
-        assertThat(cacheManager.areSpotPricesFullyCached(), is(true));
-    }
-
-    @Test
-    void areSpotPricesFullyCachedTodayMissingAtStart() {
-        Instant now = Instant.parse("2023-02-07T08:38:47Z");
-        Instant first = Instant.parse("2023-02-06T21:00:00Z");
-        Instant last = Instant.parse("2023-02-07T22:00:00Z");
-        Clock clock = Clock.fixed(now, EnergiDataServiceBindingConstants.NORD_POOL_TIMEZONE);
-        CacheManager cacheManager = new CacheManager(clock);
-        populateWithSpotPrices(cacheManager, first, last);
-        assertThat(cacheManager.areSpotPricesFullyCached(), is(false));
-    }
-
-    @Test
-    void areSpotPricesFullyCachedTodayMissingAtEnd() {
-        Instant now = Instant.parse("2023-02-07T08:38:47Z");
-        Instant first = Instant.parse("2023-02-06T20:00:00Z");
-        Instant last = Instant.parse("2023-02-07T21:00:00Z");
-        Clock clock = Clock.fixed(now, EnergiDataServiceBindingConstants.NORD_POOL_TIMEZONE);
-        CacheManager cacheManager = new CacheManager(clock);
-        populateWithSpotPrices(cacheManager, first, last);
-        assertThat(cacheManager.areSpotPricesFullyCached(), is(false));
-    }
-
-    @Test
-    void areSpotPricesFullyCachedTodayOtherTimezoneIsIgnored() {
-        Instant now = Instant.parse("2023-02-07T08:38:47Z");
-        Instant first = Instant.parse("2023-02-06T08:00:00Z");
-        Instant last = Instant.parse("2023-02-07T22:00:00Z");
-        Clock clock = Clock.fixed(now, ZoneId.of("Asia/Tokyo"));
-        CacheManager cacheManager = new CacheManager(clock);
-        populateWithSpotPrices(cacheManager, first, last);
-        assertThat(cacheManager.areSpotPricesFullyCached(), is(true));
-    }
-
-    @Test
-    void areSpotPricesFullyCachedTomorrow() {
-        Instant now = Instant.parse("2023-02-07T12:00:00Z");
-        Instant first = Instant.parse("2023-02-06T12:00:00Z");
-        Instant last = Instant.parse("2023-02-08T22:00:00Z");
-        Clock clock = Clock.fixed(now, EnergiDataServiceBindingConstants.NORD_POOL_TIMEZONE);
-        CacheManager cacheManager = new CacheManager(clock);
-        populateWithSpotPrices(cacheManager, first, last);
-        assertThat(cacheManager.areSpotPricesFullyCached(), is(true));
-    }
-
-    @Test
-    void areHistoricSpotPricesCached() {
-        Instant now = Instant.parse("2023-02-07T08:38:47Z");
-        Instant first = Instant.parse("2023-02-06T08:00:00Z");
-        Instant last = Instant.parse("2023-02-07T07:00:00Z");
-        Clock clock = Clock.fixed(now, EnergiDataServiceBindingConstants.NORD_POOL_TIMEZONE);
-        CacheManager cacheManager = new CacheManager(clock);
-        populateWithSpotPrices(cacheManager, first, last);
-        assertThat(cacheManager.areHistoricSpotPricesCached(), is(true));
-    }
-
-    @Test
-    void areHistoricSpotPricesCachedFirstHourMissing() {
-        Instant now = Instant.parse("2023-02-07T08:38:47Z");
-        Instant first = Instant.parse("2023-02-06T21:00:00Z");
-        Instant last = Instant.parse("2023-02-07T08:00:00Z");
-        Clock clock = Clock.fixed(now, EnergiDataServiceBindingConstants.NORD_POOL_TIMEZONE);
-        CacheManager cacheManager = new CacheManager(clock);
-        populateWithSpotPrices(cacheManager, first, last);
-        assertThat(cacheManager.areHistoricSpotPricesCached(), is(false));
-    }
-
-    private void populateWithSpotPrices(CacheManager cacheManager, Instant first, Instant last) {
-        int size = (int) Duration.between(first, last).getSeconds() / 60 / 60 + 1;
-        ElspotpriceRecord[] records = new ElspotpriceRecord[size];
-        int i = 0;
-        for (Instant hourStart = first; !hourStart.isAfter(last); hourStart = hourStart.plus(1, ChronoUnit.HOURS)) {
-            records[i++] = new ElspotpriceRecord(hourStart, BigDecimal.ONE, BigDecimal.ZERO);
-        }
-        cacheManager.putSpotPrices(records, EnergiDataServiceBindingConstants.CURRENCY_DKK);
-    }
-}
diff --git a/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProviderTest.java b/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProviderTest.java
new file mode 100644 (file)
index 0000000..ff0f9cf
--- /dev/null
@@ -0,0 +1,161 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.Currency;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.binding.energidataservice.internal.DatahubTariff;
+import org.openhab.binding.energidataservice.internal.provider.listener.ElectricityPriceListener;
+import org.openhab.binding.energidataservice.internal.provider.subscription.SpotPriceSubscription;
+import org.openhab.binding.energidataservice.internal.provider.subscription.Subscription;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.scheduler.ScheduledCompletableFuture;
+import org.openhab.core.scheduler.Scheduler;
+import org.openhab.core.scheduler.SchedulerRunnable;
+
+/**
+ * Tests for {@link ElectricityPriceProvider}.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class ElectricityPriceProviderTest {
+
+    private @NonNullByDefault({}) @Mock Scheduler scheduler;
+    private @NonNullByDefault({}) @Mock HttpClientFactory httpClientFactory;
+    private @NonNullByDefault({}) @Mock TimeZoneProvider timeZoneProvider;
+    private @NonNullByDefault({}) @Mock MockedListener listener1;
+    private @NonNullByDefault({}) @Mock MockedListener listener2;
+    private @NonNullByDefault({}) ElectricityPriceProvider provider;
+
+    @SuppressWarnings("unchecked")
+    @BeforeEach
+    void setUp() {
+        ScheduledCompletableFuture<@Nullable Void> futureMock = (ScheduledCompletableFuture<@Nullable Void>) mock(
+                ScheduledCompletableFuture.class);
+        when(scheduler.at(any(SchedulerRunnable.class), any(Instant.class))).thenReturn(futureMock);
+        provider = new ElectricityPriceProvider(scheduler, httpClientFactory, timeZoneProvider);
+    }
+
+    @AfterEach
+    void teardown() {
+        provider.unsubscribe(listener1);
+        provider.unsubscribe(listener2);
+    }
+
+    @Test
+    void subscribeDuplicateRegistrationThrowsIllegalArgumentException() {
+        provider.subscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            provider.subscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+        });
+    }
+
+    @Test
+    void subscribeFirstSubscriptionSchedulesRefreshJob() {
+        provider.subscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+        verify(scheduler, times(1)).at(any(SchedulerRunnable.class), any(Instant.class));
+    }
+
+    @Test
+    void subscribeSecondSubscriptionReschedulesRefreshJob() {
+        provider.subscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+        provider.subscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("EUR")));
+        verify(scheduler, times(2)).at(any(SchedulerRunnable.class), any(Instant.class));
+    }
+
+    @Test
+    void subscribeSecondSubscriptionFromOtherListenerReschedulesRefreshJob() {
+        provider.subscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+        provider.subscribe(listener2, SpotPriceSubscription.of("DK1", Currency.getInstance("EUR")));
+        verify(scheduler, times(2)).at(any(SchedulerRunnable.class), any(Instant.class));
+    }
+
+    @Test
+    void subscribeSecondSameSubscriptionFromOtherListenerDoesNotScheduleRefreshJob() {
+        provider.subscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+        provider.subscribe(listener2, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+        verify(scheduler, times(1)).at(any(SchedulerRunnable.class), any(Instant.class));
+    }
+
+    @Test
+    void subscribeAfterUnsubscribeSchedulesRefreshJobAgain() {
+        Subscription subscription = SpotPriceSubscription.of("DK1", Currency.getInstance("DKK"));
+        provider.subscribe(listener1, subscription);
+        provider.unsubscribe(listener1, subscription);
+        provider.subscribe(listener1, subscription);
+        verify(scheduler, times(2)).at(any(SchedulerRunnable.class), any(Instant.class));
+    }
+
+    @Test
+    void unsubscribeUnknownSubscriptionThrowsIllegalArgumentException() {
+        assertThrows(IllegalArgumentException.class, () -> {
+            provider.unsubscribe(listener1, SpotPriceSubscription.of("DK1", Currency.getInstance("DKK")));
+        });
+    }
+
+    private class MockedListener implements ElectricityPriceListener {
+        @Override
+        public void onDayAheadAvailable() {
+        }
+
+        @Override
+        public void onCurrentSpotPrice(@Nullable BigDecimal price, Currency currency) {
+        }
+
+        @Override
+        public void onSpotPrices(Map<Instant, BigDecimal> spotPrices, Currency currency) {
+        }
+
+        @Override
+        public void onCurrentTariff(DatahubTariff datahubTariff, @Nullable BigDecimal tariff) {
+        }
+
+        @Override
+        public void onTariffs(DatahubTariff datahubTariff, Map<Instant, BigDecimal> tariffs) {
+        }
+
+        @Override
+        public void onPropertiesUpdated(Map<String, String> properties) {
+        }
+
+        @Override
+        public void onCommunicationError(@Nullable String description) {
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCacheTest.java b/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCacheTest.java
new file mode 100644 (file)
index 0000000..23d8915
--- /dev/null
@@ -0,0 +1,138 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.cache;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
+
+/**
+ * Tests for {@link DatahubPriceSubscriptionCache}.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+@ExtendWith(MockitoExtension.class)
+public class DatahubPriceSubscriptionCacheTest {
+
+    @Test
+    void areTariffsValidTomorrowTwoDaysBeforeEnding() {
+        Instant now = Instant.parse("2024-09-29T09:22:00Z");
+        LocalDateTime from = LocalDateTime.parse("2024-08-01T00:00:00");
+        LocalDateTime to = LocalDateTime.parse("2024-10-01T00:00:00");
+        Clock clock = Clock.fixed(now, DATAHUB_TIMEZONE);
+        DatahubPriceSubscriptionCache cache = new DatahubPriceSubscriptionCache(clock);
+        populateWithDatahubPrices(cache, from, to);
+        assertThat(cache.areTariffsValidTomorrow(), is(true));
+    }
+
+    @Test
+    void areTariffsValidTomorrowOneDayBeforeEnding() {
+        Instant now = Instant.parse("2024-09-30T09:22:00Z");
+        LocalDateTime from = LocalDateTime.parse("2024-08-01T00:00:00");
+        LocalDateTime to = LocalDateTime.parse("2024-10-01T00:00:00");
+        Clock clock = Clock.fixed(now, DATAHUB_TIMEZONE);
+        DatahubPriceSubscriptionCache cache = new DatahubPriceSubscriptionCache(clock);
+        populateWithDatahubPrices(cache, from, to);
+        assertThat(cache.areTariffsValidTomorrow(), is(false));
+    }
+
+    @Test
+    void updateCacheIsNotChanged() {
+        Instant now = Instant.parse("2024-09-30T09:22:00Z");
+        LocalDateTime from = LocalDateTime.parse("2024-08-01T00:00:00");
+        LocalDateTime to = LocalDateTime.parse("2024-10-01T00:00:00");
+        Clock clock = Clock.fixed(now, DATAHUB_TIMEZONE);
+        DatahubPriceSubscriptionCache cache = new DatahubPriceSubscriptionCache(clock);
+        populateWithDatahubPrices(cache, from, to);
+        assertThat(populateWithDatahubPrices(cache, from, to), is(false));
+    }
+
+    @Test
+    void updateCacheIsNotChangedSameValue() {
+        Instant now = Instant.parse("2024-09-30T09:22:00Z");
+        LocalDateTime from = LocalDateTime.parse("2024-08-01T00:00:00");
+        LocalDateTime to = LocalDateTime.parse("2024-10-01T00:00:00");
+        Clock clock = Clock.fixed(now, DATAHUB_TIMEZONE);
+        DatahubPriceSubscriptionCache cache = new DatahubPriceSubscriptionCache(clock);
+        populateWithDatahubPrices(cache, from, to);
+
+        from = LocalDateTime.parse("2024-10-01T00:00:00");
+        to = LocalDateTime.parse("2024-11-01T00:00:00");
+        populateWithDatahubPrices(cache, from, to);
+
+        var changedRecords = new ArrayList<DatahubPricelistRecord>();
+        changedRecords.add(new DatahubPricelistRecord(from, to, "CD", BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
+        assertThat(cache.put(changedRecords), is(false));
+    }
+
+    @Test
+    void updateCacheIsChangedByOneValue() {
+        Instant now = Instant.parse("2024-09-30T09:22:00Z");
+        LocalDateTime from = LocalDateTime.parse("2024-08-01T00:00:00");
+        LocalDateTime to = LocalDateTime.parse("2024-10-01T00:00:00");
+        Clock clock = Clock.fixed(now, DATAHUB_TIMEZONE);
+        DatahubPriceSubscriptionCache cache = new DatahubPriceSubscriptionCache(clock);
+        populateWithDatahubPrices(cache, from, to);
+
+        from = LocalDateTime.parse("2024-10-01T00:00:00");
+        to = LocalDateTime.parse("2024-11-01T00:00:00");
+        populateWithDatahubPrices(cache, from, to);
+
+        var changedRecords = new ArrayList<DatahubPricelistRecord>();
+        changedRecords.add(new DatahubPricelistRecord(from, to, "CD", BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
+        assertThat(cache.put(changedRecords), is(true));
+    }
+
+    @Test
+    void updateCacheIsChangedByAdditionalKey() {
+        Instant now = Instant.parse("2024-09-30T09:22:00Z");
+        LocalDateTime from = LocalDateTime.parse("2024-08-01T00:00:00");
+        LocalDateTime to = LocalDateTime.parse("2024-10-01T00:00:00");
+        Clock clock = Clock.fixed(now, DATAHUB_TIMEZONE);
+        DatahubPriceSubscriptionCache cache = new DatahubPriceSubscriptionCache(clock);
+        populateWithDatahubPrices(cache, from, to);
+        assertThat(populateWithDatahubPrices(cache, to, to.plusMonths(1)), is(true));
+    }
+
+    private boolean populateWithDatahubPrices(DatahubPriceSubscriptionCache cache, LocalDateTime validFrom,
+            LocalDateTime validTo) {
+        var records = new ArrayList<DatahubPricelistRecord>();
+        records.add(new DatahubPricelistRecord(validFrom, validTo, "CD", BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
+                BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
+        return cache.put(records);
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCacheTest.java b/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCacheTest.java
new file mode 100644 (file)
index 0000000..d4f3925
--- /dev/null
@@ -0,0 +1,187 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.provider.cache;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
+import org.openhab.binding.energidataservice.internal.provider.subscription.SpotPriceSubscription;
+
+/**
+ * Tests for {@link SpotPriceSubscriptionCache}.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+@ExtendWith(MockitoExtension.class)
+public class SpotPriceSubscriptionCacheTest {
+
+    @Test
+    void areSpotPricesFullyCachedToday() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T08:00:00Z");
+        Instant last = Instant.parse("2023-02-07T22:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(cache.arePricesFullyCached(), is(true));
+    }
+
+    @Test
+    void areSpotPricesFullyCachedTodayMissingAtStart() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T21:00:00Z");
+        Instant last = Instant.parse("2023-02-07T22:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(cache.arePricesFullyCached(), is(false));
+    }
+
+    @Test
+    void areSpotPricesFullyCachedTodayMissingAtEnd() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T20:00:00Z");
+        Instant last = Instant.parse("2023-02-07T21:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(cache.arePricesFullyCached(), is(false));
+    }
+
+    @Test
+    void areSpotPricesFullyCachedTodayOtherTimezoneIsIgnored() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T08:00:00Z");
+        Instant last = Instant.parse("2023-02-07T22:00:00Z");
+        Clock clock = Clock.fixed(now, ZoneId.of("Asia/Tokyo"));
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(cache.arePricesFullyCached(), is(true));
+    }
+
+    @Test
+    void areSpotPricesFullyCachedTomorrow() {
+        Instant now = Instant.parse("2023-02-07T12:00:00Z");
+        Instant first = Instant.parse("2023-02-06T12:00:00Z");
+        Instant last = Instant.parse("2023-02-08T22:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(cache.arePricesFullyCached(), is(true));
+    }
+
+    @Test
+    void areHistoricSpotPricesCached() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T08:00:00Z");
+        Instant last = Instant.parse("2023-02-07T07:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(cache.areHistoricPricesCached(), is(true));
+    }
+
+    @Test
+    void areHistoricSpotPricesCachedFirstHourMissing() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T21:00:00Z");
+        Instant last = Instant.parse("2023-02-07T08:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(cache.areHistoricPricesCached(), is(false));
+    }
+
+    @Test
+    void updateCacheIsNotChanged() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T08:00:00Z");
+        Instant last = Instant.parse("2023-02-07T07:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(populateWithSpotPrices(cache, first, last), is(false));
+    }
+
+    @Test
+    void updateCacheIsNotChangedSameValue() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T08:00:00Z");
+        Instant last = Instant.parse("2023-02-07T07:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        ElspotpriceRecord[] changedRecords = new ElspotpriceRecord[1];
+        changedRecords[0] = new ElspotpriceRecord(last, BigDecimal.ONE, BigDecimal.ZERO);
+        assertThat(cache.put(changedRecords), is(false));
+    }
+
+    @Test
+    void updateCacheIsChangedByOneValue() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T08:00:00Z");
+        Instant last = Instant.parse("2023-02-07T07:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        ElspotpriceRecord[] changedRecords = new ElspotpriceRecord[1];
+        changedRecords[0] = new ElspotpriceRecord(last, BigDecimal.TEN, BigDecimal.ZERO);
+        assertThat(cache.put(changedRecords), is(true));
+    }
+
+    @Test
+    void updateCacheIsChangedByAdditionalKey() {
+        Instant now = Instant.parse("2023-02-07T08:38:47Z");
+        Instant first = Instant.parse("2023-02-06T08:00:00Z");
+        Instant last = Instant.parse("2023-02-07T07:00:00Z");
+        Clock clock = Clock.fixed(now, NORD_POOL_TIMEZONE);
+        SpotPriceSubscriptionCache cache = new SpotPriceSubscriptionCache(SpotPriceSubscription.of("DK1", CURRENCY_DKK),
+                clock);
+        populateWithSpotPrices(cache, first, last);
+        assertThat(populateWithSpotPrices(cache, first, last.plus(1, ChronoUnit.HOURS)), is(true));
+    }
+
+    private boolean populateWithSpotPrices(SpotPriceSubscriptionCache cache, Instant first, Instant last) {
+        int size = (int) Duration.between(first, last).getSeconds() / 60 / 60 + 1;
+        ElspotpriceRecord[] records = new ElspotpriceRecord[size];
+        int i = 0;
+        for (Instant hourStart = first; !hourStart.isAfter(last); hourStart = hourStart.plus(1, ChronoUnit.HOURS)) {
+            records[i++] = new ElspotpriceRecord(hourStart, BigDecimal.ONE, BigDecimal.ZERO);
+        }
+        return cache.put(records);
+    }
+}