diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/ApiController.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/ApiController.java index 0201bba12f39c..62cad906690d7 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/ApiController.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/ApiController.java @@ -27,6 +27,7 @@ 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 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 ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, Da .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 ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, Da } } + private String getUserAgent() { + return "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString(); + } + private String sendRequest(Request request, Map properties) throws TimeoutException, ExecutionException, InterruptedException, DataServiceException { logger.trace("GET request for {}", request.getURI()); @@ -210,7 +215,7 @@ public Collection getDatahubPriceLists(GlobalLocationNum .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 CO2EmissionRecord[] getCo2Emissions(Dataset dataset, String priceArea, Da .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 index aba5973ef2561..0000000000000 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/CacheManager.java +++ /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 spotPriceMap = new ConcurrentHashMap<>(SPOT_PRICE_MAX_CACHE_SIZE); - - private Map> datahubRecordsMap = new HashMap<>(); - private Map> 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 datahubRecords = datahubRecordsMap.get(datahubTariff); - if (datahubRecords != null) { - datahubRecords.clear(); - } - - Map 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 records) { - Collection datahubRecords = datahubRecordsMap.get(datahubTariff); - if (datahubRecords == null) { - throw new IllegalStateException("Datahub records not initialized"); - } - putDatahubRecords(datahubRecords, records); - updateTariffs(datahubTariff); - } - - private void putDatahubRecords(Collection destination, - Collection 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 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 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 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 getTariffs(DatahubTariff datahubTariff) { - Map 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 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 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 tariffs : tariffsMap.values()) { - tariffs.entrySet().removeIf(entry -> entry.getKey().isBefore(firstHourStart)); - } - } - - private boolean isValidNextDay(Collection 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); - } -} diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/EnergiDataServiceBindingConstants.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/EnergiDataServiceBindingConstants.java index 82eeb8f58ced3..01112fc54558f 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/EnergiDataServiceBindingConstants.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/EnergiDataServiceBindingConstants.java @@ -17,6 +17,8 @@ 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 CO2_EMISSION_CHANNELS = Set.of(CHANNEL_CO2_EMISSION_PROGNOSIS, + CHANNEL_CO2_EMISSION_REALTIME); + + public static final Set 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"; diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/PriceListParser.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/PriceListParser.java index ef36fbdd11152..b25e307c8e612 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/PriceListParser.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/PriceListParser.java @@ -26,6 +26,8 @@ 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 PriceListParser(Clock clock) { } public Map toHourly(Collection 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 Map toHourly(Collection reco public Map toHourly(Collection records, Instant firstHourStart, Instant lastHourStart) { - Map totalMap = new ConcurrentHashMap<>(CacheManager.TARIFF_MAX_CACHE_SIZE); + Map totalMap = new ConcurrentHashMap<>(DatahubPriceSubscriptionCache.MAX_CACHE_SIZE); records.stream().map(record -> record.chargeTypeCode()).distinct().forEach(chargeTypeCode -> { Map currentMap = toHourly(records, chargeTypeCode, firstHourStart, lastHourStart); for (Entry current : currentMap.entrySet()) { @@ -74,7 +77,7 @@ public Map toHourly(Collection reco private Map toHourly(Collection records, String chargeTypeCode, Instant firstHourStart, Instant lastHourStart) { - Map tariffMap = new ConcurrentHashMap<>(CacheManager.TARIFF_MAX_CACHE_SIZE); + Map tariffMap = new ConcurrentHashMap<>(DatahubPriceSubscriptionCache.MAX_CACHE_SIZE); LocalDateTime previousValidFrom = LocalDateTime.MAX; LocalDateTime previousValidTo = LocalDateTime.MIN; diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/ChargeTypeCode.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/ChargeTypeCode.java index c6aed5c9041d9..a346c370a261b 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/ChargeTypeCode.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/ChargeTypeCode.java @@ -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 String toString() { 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); } diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DatahubTariffFilter.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DatahubTariffFilter.java index 09cff82b7801a..ce331b4a54e35 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DatahubTariffFilter.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DatahubTariffFilter.java @@ -13,9 +13,11 @@ 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 DateQueryParameter getStart() { 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; + } } diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DateQueryParameter.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DateQueryParameter.java index bca4d0586d337..58a3921eb0203 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DateQueryParameter.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/DateQueryParameter.java @@ -14,6 +14,7 @@ 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 String toString() { 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; } diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/GlobalLocationNumber.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/GlobalLocationNumber.java index 933e013b0e2f4..c83b9f8890964 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/GlobalLocationNumber.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/GlobalLocationNumber.java @@ -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 String toString() { 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; } diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/console/EnergiDataServiceCommandExtension.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/console/EnergiDataServiceCommandExtension.java index 6b32f35fe318d..04a2bfea4115b 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/console/EnergiDataServiceCommandExtension.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/console/EnergiDataServiceCommandExtension.java @@ -126,9 +126,9 @@ private class ParsedUpdateParameters { 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) { diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/factory/EnergiDataServiceHandlerFactory.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/factory/EnergiDataServiceHandlerFactory.java index 73ac59b31563e..29d90c5704d60 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/factory/EnergiDataServiceHandlerFactory.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/factory/EnergiDataServiceHandlerFactory.java @@ -20,6 +20,8 @@ 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 boolean supportsThingType(ThingTypeUID thingTypeUID) { 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; diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java index 785b655364651..930150de1fda4 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java @@ -19,50 +19,50 @@ 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 @@ * @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 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 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 void handleCommand(ChannelUID channelUID, Command command) { 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 void initialize() { 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 Collection> getServices() { @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 void channelLinked(ChannelUID channelUID) { 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 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 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 emissions) { + updateStatus(ThingStatus.ONLINE); + TimeSeries timeSeries = new TimeSeries(REPLACE); + for (Entry 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 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 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 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 @@ private GlobalLocationNumber getGlobalLocationNumber(DatahubTariff datahubTariff }; } - private Collection downloadPriceLists(GlobalLocationNumber globalLocationNumber, - DatahubTariffFilter filter) throws InterruptedException, DataServiceException { - Map 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 @@ private DatahubTariffFilter getGridTariffFilter() { 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 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 int updateSpotPriceTimeSeries(LocalDate startDate, LocalDate endDate) 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 int updateTariffTimeSeries(DatahubTariff datahubTariff, LocalDate startDa 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 downloadPriceLists(GlobalLocationNumber globalLocationNumber, + DatahubTariffFilter filter) throws InterruptedException, DataServiceException { + Map properties = editProperties(); + try { + return apiController.getDatahubPriceLists(globalLocationNumber, ChargeType.Tariff, filter, properties); + } finally { + updateProperties(properties); } } @@ -634,19 +551,7 @@ public Currency getCurrency() { * @return Map of future spot prices */ public Map 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 Map getSpotPrices() { * @return Map of future tariffs */ public Map getTariffs(DatahubTariff datahubTariff) { + return this.getPrices(DatahubPriceSubscription.of(datahubTariff, getGlobalLocationNumber(datahubTariff), + getDatahubTariffFilter(datahubTariff))); + } + + private Map 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 Map getTariffs(DatahubTariff datahubTariff) { 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 index 0000000000000..9b6ef14b2bbca --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/AbstractProvider.java @@ -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 { + + protected final Map> listenerToSubscriptions = new ConcurrentHashMap<>(); + protected final Map> subscriptionToListeners = new ConcurrentHashMap<>(); + + private final Logger logger = LoggerFactory.getLogger(AbstractProvider.class); + + protected boolean subscribeInternal(L listener, Subscription subscription) { + Set 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 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 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 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 listenerSubscriptions = listenerToSubscriptions.get(listener); + if (listenerSubscriptions == null) { + return; + } + for (Subscription subscription : listenerSubscriptions) { + unsubscribeInternal(listener, subscription); + } + } + + protected Set 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 index 0000000000000..67584af5947cc --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/Co2EmissionProvider.java @@ -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 { + + 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 properties = new HashMap<>(); + CO2EmissionRecord[] emissionRecords = apiController.getCo2Emissions(dataset, subscription.getPriceArea(), + dateQueryParameter, properties); + Set 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 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 index 0000000000000..44044341672b4 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java @@ -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 { + + private final Logger logger = LoggerFactory.getLogger(ElectricityPriceProvider.class); + private final TimeZoneProvider timeZoneProvider; + private final Scheduler scheduler; + private final ApiController apiController; + private final Map> 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 spotPricesUpdatedListeners = new HashSet<>(); + boolean spotPricesSubscribed = false; + long numberOfFutureSpotPrices = 0; + + for (Entry> subscriptionListener : subscriptionToListeners + .entrySet()) { + Subscription subscription = subscriptionListener.getKey(); + Set 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 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 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 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 downloadPriceLists(DatahubPriceSubscription subscription) + throws InterruptedException, DataServiceException { + Map 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 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 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 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 index 0000000000000..911c5d0375e27 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCache.java @@ -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> { + + public static final int MAX_CACHE_SIZE = 24 * 2 + NUMBER_OF_HISTORIC_HOURS; + + private final PriceListParser priceListParser = new PriceListParser(); + + private Collection 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 records) { + LocalDateTime localHourStart = LocalDateTime.now(clock.withZone(DATAHUB_TIMEZONE)) + .minus(NUMBER_OF_HISTORIC_HOURS, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS); + + List 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 index 0000000000000..c2ad4602a3e25 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java @@ -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 implements SubscriptionDataCache { + + public static final int NUMBER_OF_HISTORIC_HOURS = 24; + + protected final Map 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 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 index 0000000000000..ec3c1658e4e93 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCache.java @@ -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 { + + 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 index 0000000000000..ddd639dbd0546 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java @@ -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 { + + /** + * 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 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 index 0000000000000..7f0f1fe31415b --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/Co2EmissionListener.java @@ -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 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 index 0000000000000..c1d8210b95440 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/ElectricityPriceListener.java @@ -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 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 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 index 0000000000000..495ef47df7eae --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/listener/SubscriptionListener.java @@ -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 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 index 0000000000000..4fa7911056678 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Co2EmissionSubscription.java @@ -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 index 0000000000000..b5cc1c39ca9d6 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/DatahubPriceSubscription.java @@ -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 index 0000000000000..fcc3286789629 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/ElectricityPriceSubscription.java @@ -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 index 0000000000000..a29f2679f9899 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java @@ -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 index 0000000000000..e6241335f41cf --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/Subscription.java @@ -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 { +} diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/ExponentialBackoff.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/ExponentialBackoff.java index 5aa7065e34114..a6b442d9698c2 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/ExponentialBackoff.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/ExponentialBackoff.java @@ -82,10 +82,9 @@ public boolean equals(@Nullable Object o) { 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); diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/Linear.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/Linear.java index 07b92da6f8c1c..de6631a309b8b 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/Linear.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/retry/strategy/Linear.java @@ -75,10 +75,9 @@ public boolean equals(@Nullable Object o) { 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 index e5ea27182c33e..0000000000000 --- a/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/CacheManagerTest.java +++ /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 index 0000000000000..ff0f9cf93ffde --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProviderTest.java @@ -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 spotPrices, Currency currency) { + } + + @Override + public void onCurrentTariff(DatahubTariff datahubTariff, @Nullable BigDecimal tariff) { + } + + @Override + public void onTariffs(DatahubTariff datahubTariff, Map tariffs) { + } + + @Override + public void onPropertiesUpdated(Map 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 index 0000000000000..23d8915eb79e3 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/DatahubPriceSubscriptionCacheTest.java @@ -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(); + 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(); + 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(); + 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 index 0000000000000..d4f3925fb3b77 --- /dev/null +++ b/bundles/org.openhab.binding.energidataservice/src/test/java/org/openhab/binding/energidataservice/internal/provider/cache/SpotPriceSubscriptionCacheTest.java @@ -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); + } +}