diff --git a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/AwattarPrice.java b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/AwattarPrice.java index fc37dc6291309..912350987b1bb 100644 --- a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/AwattarPrice.java +++ b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/AwattarPrice.java @@ -20,6 +20,12 @@ * * @author Wolfgang Klimt - initial contribution * @author Jan N. Klug - Refactored to record + * + * @param netPrice the net price in €/kWh + * @param grossPrice the gross price in €/kWh + * @param netTotal the net total price in € + * @param grossTotal the gross total price in € + * @param timerange the time range of the price */ @NonNullByDefault public record AwattarPrice(double netPrice, double grossPrice, double netTotal, double grossTotal, diff --git a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/api/AwattarApi.java b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/api/AwattarApi.java new file mode 100644 index 0000000000000..5c2b6dae5e627 --- /dev/null +++ b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/api/AwattarApi.java @@ -0,0 +1,165 @@ +/** + * 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.awattar.internal.api; + +import static org.eclipse.jetty.http.HttpMethod.GET; +import static org.eclipse.jetty.http.HttpStatus.OK_200; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration; +import org.openhab.binding.awattar.internal.AwattarPrice; +import org.openhab.binding.awattar.internal.dto.AwattarApiData; +import org.openhab.binding.awattar.internal.dto.Datum; +import org.openhab.binding.awattar.internal.handler.TimeRange; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link AwattarApi} class is responsible for encapsulating the aWATTar API + * and providing the data to the bridge. + * + * @author Thomas Leber - Initial contribution + */ +@NonNullByDefault +public class AwattarApi { + private final Logger logger = LoggerFactory.getLogger(AwattarApi.class); + + private static final String URL_DE = "https://api.awattar.de/v1/marketdata"; + private static final String URL_AT = "https://api.awattar.at/v1/marketdata"; + private String url = URL_DE; + + private final HttpClient httpClient; + + private double vatFactor; + private double basePrice; + + private ZoneId zone; + + private Gson gson; + + /** + * Generic exception for the aWATTar API. + */ + public class AwattarApiException extends Exception { + private static final long serialVersionUID = 1L; + + public AwattarApiException(String message) { + super(message); + } + } + + /** + * Constructor for the aWATTar API. + * + * @param httpClient the HTTP client to use + * @param zone the time zone to use + */ + public AwattarApi(HttpClient httpClient, ZoneId zone, AwattarBridgeConfiguration config) { + this.zone = zone; + this.httpClient = httpClient; + + this.gson = new Gson(); + + vatFactor = 1 + (config.vatPercent / 100); + basePrice = config.basePrice; + + if (config.country.equals("DE")) { + this.url = URL_DE; + } else if (config.country.equals("AT")) { + this.url = URL_AT; + } else { + throw new IllegalArgumentException("Country code must be 'DE' or 'AT'"); + } + } + + /** + * Get the data from the aWATTar API. + * The data is returned as a sorted set of {@link AwattarPrice} objects. + * The data is requested from now minus one day to now plus three days. + * + * @return the data as a sorted set of {@link AwattarPrice} objects + * @throws AwattarApiException + * @throws InterruptedException if the thread is interrupted + * @throws TimeoutException if the request times out + * @throws ExecutionException if the request fails + * @throws EmptyDataResponseException if the response is empty + */ + public SortedSet getData() throws AwattarApiException { + try { + // we start one day in the past to cover ranges that already started yesterday + ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1); + long start = zdt.toInstant().toEpochMilli(); + // Starting from midnight yesterday we add three days so that the range covers + // the whole next day. + zdt = zdt.plusDays(3); + long end = zdt.toInstant().toEpochMilli(); + + StringBuilder request = new StringBuilder(url); + request.append("?start=").append(start).append("&end=").append(end); + + logger.trace("aWATTar API request: = '{}'", request); + ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET) + .timeout(10, TimeUnit.SECONDS).send(); + int httpStatus = contentResponse.getStatus(); + String content = contentResponse.getContentAsString(); + logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content); + + if (content == null) { + throw new AwattarApiException("@text/error.empty.data"); + } else if (httpStatus == OK_200) { + SortedSet result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange)); + + AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class); + + for (Datum d : apiData.data) { + // the API returns prices in €/MWh, we need €ct/kWh -> divide by 10 (100/1000) + double netMarket = d.marketprice / 10.0; + double grossMarket = netMarket * vatFactor; + double netTotal = netMarket + basePrice; + double grossTotal = netTotal * vatFactor; + + result.add(new AwattarPrice(netMarket, grossMarket, netTotal, grossTotal, + new TimeRange(d.startTimestamp, d.endTimestamp))); + } + + return result; + } else { + throw new AwattarApiException("@text/warn.awattar.statuscode" + httpStatus); + } + } catch (ExecutionException e) { + throw new AwattarApiException("@text/error.execution"); + } catch (JsonSyntaxException e) { + throw new AwattarApiException("@text/error.json"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AwattarApiException("@text/error.interrupted"); + } catch (TimeoutException e) { + throw new AwattarApiException("@text/error.timeout"); + } + } +} diff --git a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBestPriceHandler.java b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBestPriceHandler.java index 7645308313ece..e2fb8585c2c74 100644 --- a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBestPriceHandler.java +++ b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBestPriceHandler.java @@ -99,7 +99,7 @@ public void initialize() { * here */ thingRefresher = scheduler.scheduleAtFixedRate(this::refreshChannels, - getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL * 1000, + getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL * 1000L, TimeUnit.MILLISECONDS); } } diff --git a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandler.java b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandler.java index f33ce3a34dea4..b62909d740f26 100644 --- a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandler.java +++ b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandler.java @@ -12,21 +12,15 @@ */ package org.openhab.binding.awattar.internal.handler; -import static org.eclipse.jetty.http.HttpMethod.GET; -import static org.eclipse.jetty.http.HttpStatus.OK_200; -import static org.openhab.binding.awattar.internal.AwattarBindingConstants.*; +import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_MARKET_NET; +import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_TOTAL_NET; import java.time.Instant; -import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.Comparator; import java.util.SortedSet; -import java.util.TreeSet; -import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.function.Function; import javax.measure.Unit; @@ -34,11 +28,10 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration; import org.openhab.binding.awattar.internal.AwattarPrice; -import org.openhab.binding.awattar.internal.dto.AwattarApiData; -import org.openhab.binding.awattar.internal.dto.Datum; +import org.openhab.binding.awattar.internal.api.AwattarApi; +import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.unit.CurrencyUnits; @@ -54,13 +47,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; - /** - * The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API. + * The {@link AwattarBridgeHandler} is responsible for retrieving data from the + * aWATTar API via the {@link AwattarApi}. * - * The API provides hourly prices for the current day and, starting from 14:00, hourly prices for the next day. + * The API provides hourly prices for the current day and, starting from 14:00, + * hourly prices for the next day. * Check the documentation at * * @@ -73,25 +65,19 @@ public class AwattarBridgeHandler extends BaseBridgeHandler { private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class); private final HttpClient httpClient; + private @Nullable ScheduledFuture dataRefresher; private Instant lastRefresh = Instant.EPOCH; - private static final String URLDE = "https://api.awattar.de/v1/marketdata"; - private static final String URLAT = "https://api.awattar.at/v1/marketdata"; - private String url; - // This cache stores price data for up to two days private @Nullable SortedSet prices; - private double vatFactor = 0; - private double basePrice = 0; private ZoneId zone; - private final TimeZoneProvider timeZoneProvider; + + private @Nullable AwattarApi awattarApi; public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) { super(thing); this.httpClient = httpClient; - url = URLDE; - this.timeZoneProvider = timeZoneProvider; zone = timeZoneProvider.getTimeZone(); } @@ -99,24 +85,15 @@ public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvide public void initialize() { updateStatus(ThingStatus.UNKNOWN); AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class); - vatFactor = 1 + (config.vatPercent / 100); - basePrice = config.basePrice; - zone = timeZoneProvider.getTimeZone(); - switch (config.country) { - case "DE": - url = URLDE; - break; - case "AT": - url = URLAT; - break; - default: - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "@text/error.unsupported.country"); - return; - } - dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L, - TimeUnit.MILLISECONDS); + try { + awattarApi = new AwattarApi(httpClient, zone, config); + + dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L, + TimeUnit.MILLISECONDS); + } catch (IllegalArgumentException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.unsupported.country"); + } } @Override @@ -135,71 +112,36 @@ void refreshIfNeeded() { } } + /** + * Refresh the data from the API. + * + * + */ private void refresh() { try { - // we start one day in the past to cover ranges that already started yesterday - ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1); - long start = zdt.toInstant().toEpochMilli(); - // Starting from midnight yesterday we add three days so that the range covers the whole next day. - zdt = zdt.plusDays(3); - long end = zdt.toInstant().toEpochMilli(); - - StringBuilder request = new StringBuilder(url); - request.append("?start=").append(start).append("&end=").append(end); - - logger.trace("aWATTar API request: = '{}'", request); - ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET) - .timeout(10, TimeUnit.SECONDS).send(); - int httpStatus = contentResponse.getStatus(); - String content = contentResponse.getContentAsString(); - logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content); - - if (httpStatus == OK_200) { - Gson gson = new Gson(); - SortedSet result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange)); - AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class); - if (apiData != null) { - TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE); - TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE); - - Unit priceUnit = getPriceUnit(); - - for (Datum d : apiData.data) { - double netMarket = d.marketprice / 10.0; - double grossMarket = netMarket * vatFactor; - double netTotal = netMarket + basePrice; - double grossTotal = netTotal * vatFactor; - Instant timestamp = Instant.ofEpochMilli(d.startTimestamp); - - netMarketSeries.add(timestamp, new QuantityType<>(netMarket / 100.0, priceUnit)); - netTotalSeries.add(timestamp, new QuantityType<>(netTotal / 100.0, priceUnit)); - - result.add(new AwattarPrice(netMarket, grossMarket, netTotal, grossTotal, - new TimeRange(d.startTimestamp, d.endTimestamp))); - } - prices = result; - - // update channels - sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries); - sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries); - - updateStatus(ThingStatus.ONLINE); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/error.invalid.data"); - } - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/warn.awattar.statuscode"); + // Method is private and only called when dataRefresher is initialized. + // DataRefresher is initialized after successful creation of AwattarApi. + prices = awattarApi.getData(); + + TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE); + TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE); + + Unit priceUnit = getPriceUnit(); + + for (AwattarPrice price : prices) { + Instant timestamp = Instant.ofEpochMilli(price.timerange().start()); + + netMarketSeries.add(timestamp, new QuantityType<>(price.netPrice() / 100.0, priceUnit)); + netTotalSeries.add(timestamp, new QuantityType<>(price.netTotal() / 100.0, priceUnit)); } - } catch (JsonSyntaxException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.json"); - } catch (InterruptedException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.interrupted"); - } catch (ExecutionException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.execution"); - } catch (TimeoutException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.timeout"); + + // update channels + sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries); + sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries); + + updateStatus(ThingStatus.ONLINE); + } catch (AwattarApiException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } } @@ -213,13 +155,13 @@ private Unit getPriceUnit() { } private void createAndSendTimeSeries(String channelId, Function valueFunction) { - SortedSet prices = getPrices(); + SortedSet locPrices = getPrices(); Unit priceUnit = getPriceUnit(); - if (prices == null) { + if (locPrices == null) { return; } TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.REPLACE); - prices.forEach(p -> { + locPrices.forEach(p -> { timeSeries.add(Instant.ofEpochMilli(p.timerange().start()), new QuantityType<>(valueFunction.apply(p) / 100.0, priceUnit)); }); @@ -232,9 +174,12 @@ private void createAndSendTimeSeries(String channelId, Function 0 and < duration. warn.awattar.statuscode=aWATTar server did not respond with status code 200 error.start.value=Invalid start value diff --git a/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/api/AwattarApiTest.java b/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/api/AwattarApiTest.java new file mode 100644 index 0000000000000..f543757746d5a --- /dev/null +++ b/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/api/AwattarApiTest.java @@ -0,0 +1,155 @@ +/** + * 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.awattar.internal.api; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.util.Objects; +import java.util.SortedSet; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +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.awattar.internal.AwattarBridgeConfiguration; +import org.openhab.binding.awattar.internal.AwattarPrice; +import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException; +import org.openhab.binding.awattar.internal.handler.AwattarBridgeHandler; +import org.openhab.binding.awattar.internal.handler.AwattarBridgeHandlerTest; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.test.java.JavaTest; + +/** + * The {@link AwattarBridgeHandlerTest} contains tests for the + * {@link AwattarBridgeHandler} + * + * @author Jan N. Klug - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +class AwattarApiTest extends JavaTest { + // API Mocks + private @Mock @NonNullByDefault({}) HttpClient httpClientMock; + private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProviderMock; + private @Mock @NonNullByDefault({}) Request requestMock; + private @Mock @NonNullByDefault({}) ContentResponse contentResponseMock; + private @Mock @NonNullByDefault({}) AwattarBridgeConfiguration config; + + // sut + private @NonNullByDefault({}) AwattarApi api; + + @BeforeEach + public void setUp() throws IOException, ExecutionException, InterruptedException, TimeoutException { + try (InputStream inputStream = AwattarApiTest.class.getResourceAsStream("api_response.json")) { + if (inputStream == null) { + throw new IOException("inputstream is null"); + } + byte[] bytes = inputStream.readAllBytes(); + if (bytes == null) { + throw new IOException("Resulting byte-array empty"); + } + when(contentResponseMock.getContentAsString()).thenReturn(new String(bytes, StandardCharsets.UTF_8)); + } + when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200); + when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + when(requestMock.method(HttpMethod.GET)).thenReturn(requestMock); + when(requestMock.timeout(10, TimeUnit.SECONDS)).thenReturn(requestMock); + when(requestMock.send()).thenReturn(contentResponseMock); + + when(timeZoneProviderMock.getTimeZone()).thenReturn(ZoneId.of("GMT+2")); + + config.basePrice = 0.0; + config.vatPercent = 0.0; + config.country = "DE"; + + api = new AwattarApi(httpClientMock, ZoneId.of("GMT+2"), config); + } + + @Test + void testDeUrl() throws AwattarApiException { + api.getData(); + + assertThat(httpClientMock.newRequest("https://api.awattar.de/v1/marketdata"), is(requestMock)); + } + + @Test + void testAtUrl() throws AwattarApiException { + config.country = "AT"; + api = new AwattarApi(httpClientMock, ZoneId.of("GMT+2"), config); + + api.getData(); + + assertThat(httpClientMock.newRequest("https://api.awattar.at/v1/marketdata"), is(requestMock)); + } + + @Test + void testInvalidCountry() { + config.country = "CH"; + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> new AwattarApi(httpClientMock, ZoneId.of("GMT+2"), config)); + assertThat(thrown.getMessage(), is("Country code must be 'DE' or 'AT'")); + } + + @Test + void testPricesRetrieval() throws AwattarApiException { + SortedSet prices = api.getData(); + + assertThat(prices, hasSize(72)); + + Objects.requireNonNull(prices); + + // check if first and last element are correct + assertThat(prices.first().timerange().start(), is(1718316000000L)); + assertThat(prices.last().timerange().end(), is(1718575200000L)); + } + + @Test + void testPricesRetrievalEmptyResponse() { + when(contentResponseMock.getContentAsString()).thenReturn(null); + when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200); + + AwattarApiException thrown = assertThrows(AwattarApiException.class, () -> api.getData()); + assertThat(thrown.getMessage(), is("@text/error.empty.data")); + } + + @Test + void testPricesReturnNot200() { + when(contentResponseMock.getStatus()).thenReturn(HttpStatus.BAD_REQUEST_400); + + AwattarApiException thrown = assertThrows(AwattarApiException.class, () -> api.getData()); + assertThat(thrown.getMessage(), is("@text/warn.awattar.statuscode400")); + } +} diff --git a/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerRefreshTest.java b/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerRefreshTest.java index 7cb1ccc46dbbd..d7f67bda9897d 100644 --- a/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerRefreshTest.java +++ b/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerRefreshTest.java @@ -12,31 +12,30 @@ */ package org.openhab.binding.awattar.internal.handler; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.time.ZoneId; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.List; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.http.HttpMethod; -import org.eclipse.jetty.http.HttpStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.commons.support.ReflectionSupport; 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.awattar.internal.AwattarBindingConstants; +import org.openhab.binding.awattar.internal.api.AwattarApi; +import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.test.java.JavaTest; import org.openhab.core.thing.Bridge; @@ -48,14 +47,15 @@ import org.openhab.core.thing.binding.ThingHandlerCallback; /** - * The {@link AwattarBridgeHandlerRefreshTest} contains tests for the {@link AwattarBridgeHandler} refresh logic. + * The {@link AwattarBridgeHandlerRefreshTest} contains tests for the + * {@link AwattarBridgeHandler} refresh logic. * * @author Thomas Leber - Initial contribution */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @NonNullByDefault -public class AwattarBridgeHandlerRefreshTest extends JavaTest { +class AwattarBridgeHandlerRefreshTest extends JavaTest { public static final ThingUID BRIDGE_UID = new ThingUID(AwattarBindingConstants.THING_TYPE_BRIDGE, "testBridge"); // bridge mocks @@ -63,8 +63,7 @@ public class AwattarBridgeHandlerRefreshTest extends JavaTest { private @Mock @NonNullByDefault({}) ThingHandlerCallback bridgeCallbackMock; private @Mock @NonNullByDefault({}) HttpClient httpClientMock; private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProviderMock; - private @Mock @NonNullByDefault({}) Request requestMock; - private @Mock @NonNullByDefault({}) ContentResponse contentResponseMock; + private @Mock @NonNullByDefault({}) AwattarApi awattarApiMock; // best price handler mocks private @Mock @NonNullByDefault({}) Thing bestpriceMock; @@ -73,22 +72,7 @@ public class AwattarBridgeHandlerRefreshTest extends JavaTest { private @NonNullByDefault({}) AwattarBridgeHandler bridgeHandler; @BeforeEach - public void setUp() throws IOException, ExecutionException, InterruptedException, TimeoutException { - try (InputStream inputStream = AwattarBridgeHandlerRefreshTest.class.getResourceAsStream("api_response.json")) { - if (inputStream == null) { - throw new IOException("inputstream is null"); - } - byte[] bytes = inputStream.readAllBytes(); - if (bytes == null) { - throw new IOException("Resulting byte-array empty"); - } - when(contentResponseMock.getContentAsString()).thenReturn(new String(bytes, StandardCharsets.UTF_8)); - } - when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200); - when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); - when(requestMock.method(HttpMethod.GET)).thenReturn(requestMock); - when(requestMock.timeout(10, TimeUnit.SECONDS)).thenReturn(requestMock); - when(requestMock.send()).thenReturn(contentResponseMock); + public void setUp() throws IllegalArgumentException, IllegalAccessException { when(timeZoneProviderMock.getTimeZone()).thenReturn(ZoneId.of("GMT+2")); @@ -96,42 +80,79 @@ public void setUp() throws IOException, ExecutionException, InterruptedException bridgeHandler = new AwattarBridgeHandler(bridgeMock, httpClientMock, timeZoneProviderMock); bridgeHandler.setCallback(bridgeCallbackMock); - when(bridgeMock.getHandler()).thenReturn(bridgeHandler); - - // other mocks - when(bestpriceMock.getBridgeUID()).thenReturn(BRIDGE_UID); + List fields = ReflectionSupport.findFields(AwattarBridgeHandler.class, + field -> field.getName().equals("awattarApi"), HierarchyTraversalMode.BOTTOM_UP); - when(bestPriceCallbackMock.getBridge(any())).thenReturn(bridgeMock); - when(bestPriceCallbackMock.isChannelLinked(any())).thenReturn(true); + for (Field field : fields) { + field.setAccessible(true); + field.set(bridgeHandler, awattarApiMock); + } } /** * Test the refreshIfNeeded method with a bridge that is offline. * * @throws SecurityException + * @throws AwattarApiException */ @Test - void testRefreshIfNeeded_ThingOffline() throws SecurityException { + void testRefreshIfNeeded_ThingOffline() throws SecurityException, AwattarApiException { when(bridgeMock.getStatus()).thenReturn(ThingStatus.OFFLINE); bridgeHandler.refreshIfNeeded(); verify(bridgeCallbackMock).statusUpdated(bridgeMock, new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null)); + verify(awattarApiMock).getData(); } /** - * Test the refreshIfNeeded method with a bridge that is online and the data is empty. + * Test the refreshIfNeeded method with a bridge that is online and the data is + * empty. * * @throws SecurityException + * @throws AwattarApiException */ @Test - void testRefreshIfNeeded_DataEmptry() throws SecurityException { + void testRefreshIfNeeded_DataEmpty() throws SecurityException, AwattarApiException { when(bridgeMock.getStatus()).thenReturn(ThingStatus.ONLINE); bridgeHandler.refreshIfNeeded(); verify(bridgeCallbackMock).statusUpdated(bridgeMock, new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null)); + verify(awattarApiMock).getData(); + } + + @Test + void testNeedRefresh_ThingOffline() throws SecurityException { + when(bridgeMock.getStatus()).thenReturn(ThingStatus.OFFLINE); + + // get private method via reflection + Method method = ReflectionSupport.findMethod(AwattarBridgeHandler.class, "needRefresh", "").get(); + + boolean result = (boolean) ReflectionSupport.invokeMethod(method, bridgeHandler); + + assertThat(result, is(true)); + } + + @Test + void testNeedRefresh_DataEmpty() throws SecurityException, IllegalArgumentException, IllegalAccessException { + when(bridgeMock.getStatus()).thenReturn(ThingStatus.ONLINE); + + List fields = ReflectionSupport.findFields(AwattarBridgeHandler.class, + field -> field.getName().equals("prices"), HierarchyTraversalMode.BOTTOM_UP); + + for (Field field : fields) { + field.setAccessible(true); + field.set(bridgeHandler, null); + } + + // get private method via reflection + Method method = ReflectionSupport.findMethod(AwattarBridgeHandler.class, "needRefresh", "").get(); + + boolean result = (boolean) ReflectionSupport.invokeMethod(method, bridgeHandler); + + assertThat(result, is(true)); } } diff --git a/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerTest.java b/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerTest.java index b90d3f1ed0f5d..5c1ce6c705e8c 100644 --- a/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerTest.java +++ b/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerTest.java @@ -13,41 +13,48 @@ package org.openhab.binding.awattar.internal.handler; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.openhab.binding.awattar.internal.AwattarBindingConstants.*; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_END; +import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_HOURS; +import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_START; import java.io.IOException; import java.io.InputStream; -import java.nio.charset.StandardCharsets; +import java.lang.reflect.Field; import java.time.ZoneId; +import java.util.Comparator; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.SortedSet; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.TreeSet; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.http.HttpMethod; -import org.eclipse.jetty.http.HttpStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.commons.support.ReflectionSupport; 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.awattar.internal.AwattarBindingConstants; import org.openhab.binding.awattar.internal.AwattarPrice; +import org.openhab.binding.awattar.internal.api.AwattarApi; +import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException; +import org.openhab.binding.awattar.internal.dto.AwattarApiData; import org.openhab.core.config.core.Configuration; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; @@ -60,6 +67,8 @@ import org.openhab.core.thing.binding.ThingHandlerCallback; import org.openhab.core.types.State; +import com.google.gson.Gson; + /** * The {@link AwattarBridgeHandlerTest} contains tests for the {@link AwattarBridgeHandler} * @@ -76,8 +85,7 @@ public class AwattarBridgeHandlerTest extends JavaTest { private @Mock @NonNullByDefault({}) ThingHandlerCallback bridgeCallbackMock; private @Mock @NonNullByDefault({}) HttpClient httpClientMock; private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProviderMock; - private @Mock @NonNullByDefault({}) Request requestMock; - private @Mock @NonNullByDefault({}) ContentResponse contentResponseMock; + private @Mock @NonNullByDefault({}) AwattarApi awattarApiMock; // best price handler mocks private @Mock @NonNullByDefault({}) Thing bestpriceMock; @@ -86,69 +94,64 @@ public class AwattarBridgeHandlerTest extends JavaTest { private @NonNullByDefault({}) AwattarBridgeHandler bridgeHandler; @BeforeEach - public void setUp() throws IOException, ExecutionException, InterruptedException, TimeoutException { + public void setUp() throws IOException, IllegalArgumentException, IllegalAccessException, AwattarApiException { + + // mock the API response try (InputStream inputStream = AwattarBridgeHandlerTest.class.getResourceAsStream("api_response.json")) { - if (inputStream == null) { - throw new IOException("inputstream is null"); - } - byte[] bytes = inputStream.readAllBytes(); - if (bytes == null) { - throw new IOException("Resulting byte-array empty"); - } - when(contentResponseMock.getContentAsString()).thenReturn(new String(bytes, StandardCharsets.UTF_8)); + SortedSet result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange)); + Gson gson = new Gson(); + + String json = new String(inputStream.readAllBytes()); + + // read json file into sorted set of AwattarPrices + AwattarApiData apiData = gson.fromJson(json, AwattarApiData.class); + apiData.data.forEach(datum -> result.add(new AwattarPrice(datum.marketprice, datum.marketprice, + datum.marketprice, datum.marketprice, new TimeRange(datum.startTimestamp, datum.endTimestamp)))); + when(awattarApiMock.getData()).thenReturn(result); } - when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200); - when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); - when(requestMock.method(HttpMethod.GET)).thenReturn(requestMock); - when(requestMock.timeout(10, TimeUnit.SECONDS)).thenReturn(requestMock); - when(requestMock.send()).thenReturn(contentResponseMock); when(timeZoneProviderMock.getTimeZone()).thenReturn(ZoneId.of("GMT+2")); when(bridgeMock.getUID()).thenReturn(BRIDGE_UID); bridgeHandler = new AwattarBridgeHandler(bridgeMock, httpClientMock, timeZoneProviderMock); bridgeHandler.setCallback(bridgeCallbackMock); + + // mock the private field awattarApi + List fields = ReflectionSupport.findFields(AwattarBridgeHandler.class, + field -> field.getName().equals("awattarApi"), HierarchyTraversalMode.BOTTOM_UP); + + for (Field field : fields) { + field.setAccessible(true); + field.set(bridgeHandler, awattarApiMock); + } + bridgeHandler.refreshIfNeeded(); when(bridgeMock.getHandler()).thenReturn(bridgeHandler); // other mocks when(bestpriceMock.getBridgeUID()).thenReturn(BRIDGE_UID); - when(bestPriceCallbackMock.getBridge(any())).thenReturn(bridgeMock); when(bestPriceCallbackMock.isChannelLinked(any())).thenReturn(true); } @Test - public void testPricesRetrieval() { - SortedSet prices = bridgeHandler.getPrices(); - - assertThat(prices, hasSize(72)); - - Objects.requireNonNull(prices); - - // check if first and last element are correct - assertThat(prices.first().timerange().start(), is(1718316000000L)); - assertThat(prices.last().timerange().end(), is(1718575200000L)); - } - - @Test - public void testGetPriceForSuccess() { + void testGetPriceForSuccess() { AwattarPrice price = bridgeHandler.getPriceFor(1718503200000L); assertThat(price, is(notNullValue())); Objects.requireNonNull(price); - assertThat(price.netPrice(), is(closeTo(0.219, 0.001))); + assertThat(price.netPrice(), is(closeTo(2.19, 0.001))); } @Test - public void testGetPriceForFail() { + void testGetPriceForFail() { AwattarPrice price = bridgeHandler.getPriceFor(1518503200000L); assertThat(price, is(nullValue())); } @Test - public void testContainsPrizeFor() { + void testContainsPrizeFor() { assertThat(bridgeHandler.containsPriceFor(1618503200000L), is(false)); assertThat(bridgeHandler.containsPriceFor(1718503200000L), is(true)); assertThat(bridgeHandler.containsPriceFor(1818503200000L), is(false)); @@ -172,7 +175,7 @@ public static Stream testBestpriceHandler() { @ParameterizedTest @MethodSource - public void testBestpriceHandler(int length, boolean consecutive, String channelId, State expectedState) { + void testBestpriceHandler(int length, boolean consecutive, String channelId, State expectedState) { ThingUID bestPriceUid = new ThingUID(AwattarBindingConstants.THING_TYPE_BESTPRICE, "foo"); Map config = Map.of("length", length, "consecutive", consecutive); when(bestpriceMock.getConfiguration()).thenReturn(new Configuration(config)); diff --git a/bundles/org.openhab.binding.awattar/src/test/resources/org/openhab/binding/awattar/internal/api/api_response.json b/bundles/org.openhab.binding.awattar/src/test/resources/org/openhab/binding/awattar/internal/api/api_response.json new file mode 100644 index 0000000000000..8f0fbbe5135aa --- /dev/null +++ b/bundles/org.openhab.binding.awattar/src/test/resources/org/openhab/binding/awattar/internal/api/api_response.json @@ -0,0 +1,438 @@ +{ + "object": "list", + "data": [ + { + "start_timestamp": 1718316000000, + "end_timestamp": 1718319600000, + "marketprice": 83.13, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718319600000, + "end_timestamp": 1718323200000, + "marketprice": 71.45, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718323200000, + "end_timestamp": 1718326800000, + "marketprice": 63.93, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718326800000, + "end_timestamp": 1718330400000, + "marketprice": 59.53, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718330400000, + "end_timestamp": 1718334000000, + "marketprice": 55.82, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718334000000, + "end_timestamp": 1718337600000, + "marketprice": 64.22, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718337600000, + "end_timestamp": 1718341200000, + "marketprice": 85.01, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718341200000, + "end_timestamp": 1718344800000, + "marketprice": 100.95, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718344800000, + "end_timestamp": 1718348400000, + "marketprice": 104.99, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718348400000, + "end_timestamp": 1718352000000, + "marketprice": 102.54, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718352000000, + "end_timestamp": 1718355600000, + "marketprice": 82.18, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718355600000, + "end_timestamp": 1718359200000, + "marketprice": 68.1, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718359200000, + "end_timestamp": 1718362800000, + "marketprice": 60.88, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718362800000, + "end_timestamp": 1718366400000, + "marketprice": 47.46, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718366400000, + "end_timestamp": 1718370000000, + "marketprice": 40.74, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718370000000, + "end_timestamp": 1718373600000, + "marketprice": 41, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718373600000, + "end_timestamp": 1718377200000, + "marketprice": 60.31, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718377200000, + "end_timestamp": 1718380800000, + "marketprice": 75, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718380800000, + "end_timestamp": 1718384400000, + "marketprice": 90.98, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718384400000, + "end_timestamp": 1718388000000, + "marketprice": 136, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718388000000, + "end_timestamp": 1718391600000, + "marketprice": 127.31, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718391600000, + "end_timestamp": 1718395200000, + "marketprice": 117.12, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718395200000, + "end_timestamp": 1718398800000, + "marketprice": 83.41, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718398800000, + "end_timestamp": 1718402400000, + "marketprice": 59.42, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718402400000, + "end_timestamp": 1718406000000, + "marketprice": 60.68, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718406000000, + "end_timestamp": 1718409600000, + "marketprice": 41.04, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718409600000, + "end_timestamp": 1718413200000, + "marketprice": 29.97, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718413200000, + "end_timestamp": 1718416800000, + "marketprice": 28.86, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718416800000, + "end_timestamp": 1718420400000, + "marketprice": 22.51, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718420400000, + "end_timestamp": 1718424000000, + "marketprice": 10.04, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718424000000, + "end_timestamp": 1718427600000, + "marketprice": 1.54, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718427600000, + "end_timestamp": 1718431200000, + "marketprice": 0.09, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718431200000, + "end_timestamp": 1718434800000, + "marketprice": 0, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718434800000, + "end_timestamp": 1718438400000, + "marketprice": -0.06, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718438400000, + "end_timestamp": 1718442000000, + "marketprice": -10.08, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718442000000, + "end_timestamp": 1718445600000, + "marketprice": -29.04, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718445600000, + "end_timestamp": 1718449200000, + "marketprice": -44.92, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718449200000, + "end_timestamp": 1718452800000, + "marketprice": -65.46, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718452800000, + "end_timestamp": 1718456400000, + "marketprice": -80.01, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718456400000, + "end_timestamp": 1718460000000, + "marketprice": -56.23, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718460000000, + "end_timestamp": 1718463600000, + "marketprice": -29.53, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718463600000, + "end_timestamp": 1718467200000, + "marketprice": -4.84, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718467200000, + "end_timestamp": 1718470800000, + "marketprice": -0.01, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718470800000, + "end_timestamp": 1718474400000, + "marketprice": 40, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718474400000, + "end_timestamp": 1718478000000, + "marketprice": 84.28, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718478000000, + "end_timestamp": 1718481600000, + "marketprice": 79.92, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718481600000, + "end_timestamp": 1718485200000, + "marketprice": 64.3, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718485200000, + "end_timestamp": 1718488800000, + "marketprice": 40.4, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718488800000, + "end_timestamp": 1718492400000, + "marketprice": 24.91, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718492400000, + "end_timestamp": 1718496000000, + "marketprice": 10.36, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718496000000, + "end_timestamp": 1718499600000, + "marketprice": 4.92, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718499600000, + "end_timestamp": 1718503200000, + "marketprice": 2.92, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718503200000, + "end_timestamp": 1718506800000, + "marketprice": 2.19, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718506800000, + "end_timestamp": 1718510400000, + "marketprice": 2.53, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718510400000, + "end_timestamp": 1718514000000, + "marketprice": 2.95, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718514000000, + "end_timestamp": 1718517600000, + "marketprice": 0.69, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718517600000, + "end_timestamp": 1718521200000, + "marketprice": -0.02, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718521200000, + "end_timestamp": 1718524800000, + "marketprice": -1.28, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718524800000, + "end_timestamp": 1718528400000, + "marketprice": -10, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718528400000, + "end_timestamp": 1718532000000, + "marketprice": -13.33, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718532000000, + "end_timestamp": 1718535600000, + "marketprice": -20.01, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718535600000, + "end_timestamp": 1718539200000, + "marketprice": -30.01, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718539200000, + "end_timestamp": 1718542800000, + "marketprice": -35.67, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718542800000, + "end_timestamp": 1718546400000, + "marketprice": -29.04, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718546400000, + "end_timestamp": 1718550000000, + "marketprice": -10.14, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718550000000, + "end_timestamp": 1718553600000, + "marketprice": -2.34, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718553600000, + "end_timestamp": 1718557200000, + "marketprice": 56.22, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718557200000, + "end_timestamp": 1718560800000, + "marketprice": 99.65, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718560800000, + "end_timestamp": 1718564400000, + "marketprice": 119.15, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718564400000, + "end_timestamp": 1718568000000, + "marketprice": 124.28, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718568000000, + "end_timestamp": 1718571600000, + "marketprice": 120.34, + "unit": "Eur/MWh" + }, + { + "start_timestamp": 1718571600000, + "end_timestamp": 1718575200000, + "marketprice": 94.44, + "unit": "Eur/MWh" + } + ], + "url": "/de/v1/marketdata" +} \ No newline at end of file