[awattarar] Add aWATTar API class (#17169)

Signed-off-by: Thomas Leber <thomas@tl-photography.at>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
tl-photography 2024-08-30 18:14:44 +02:00 committed by Ciprian Pascu
parent b6ce04d6f2
commit 4643c1cca4
9 changed files with 934 additions and 199 deletions

View File

@ -20,6 +20,12 @@ import org.openhab.binding.awattar.internal.handler.TimeRange;
*
* @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,

View File

@ -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<AwattarPrice> 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<AwattarPrice> 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");
}
}
}

View File

@ -99,7 +99,7 @@ public class AwattarBestPriceHandler extends BaseThingHandler {
* here
*/
thingRefresher = scheduler.scheduleAtFixedRate(this::refreshChannels,
getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL * 1000,
getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL * 1000L,
TimeUnit.MILLISECONDS);
}
}

View File

@ -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 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.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.openhab.core.types.util.UnitUtils;
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 <a href="https://www.awattar.de/services/api" />
*
*
@ -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<AwattarPrice> 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 class AwattarBridgeHandler extends BaseBridgeHandler {
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 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
}
}
/**
* 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();
// Method is private and only called when dataRefresher is initialized.
// DataRefresher is initialized after successful creation of AwattarApi.
prices = awattarApi.getData();
StringBuilder request = new StringBuilder(url);
request.append("?start=").append(start).append("&end=").append(end);
TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
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);
Unit<?> priceUnit = getPriceUnit();
if (httpStatus == OK_200) {
Gson gson = new Gson();
SortedSet<AwattarPrice> 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);
for (AwattarPrice price : prices) {
Instant timestamp = Instant.ofEpochMilli(price.timerange().start());
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");
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 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
}
private void createAndSendTimeSeries(String channelId, Function<AwattarPrice, Double> valueFunction) {
SortedSet<AwattarPrice> prices = getPrices();
SortedSet<AwattarPrice> 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 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
* The data is refreshed if:
* - the thing is offline
* - the local cache is empty
* - the current time is after 15:00 and the last refresh was more than an hour ago
* - the current time is after 18:00 and the last refresh was more than an hour ago
* - the current time is after 21:00 and the last refresh was more than an hour ago
* - the current time is after 15:00 and the last refresh was more than an hour
* ago
* - the current time is after 18:00 and the last refresh was more than an hour
* ago
* - the current time is after 21:00 and the last refresh was more than an hour
* ago
*
* @return true if the data needs to be refreshed
*/
@ -249,10 +194,12 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
return true;
}
// Note: all this magic is made to avoid refreshing the data too often, since the API is rate-limited
// Note: all this magic is made to avoid refreshing the data too often, since
// the API is rate-limited
// to 100 requests per day.
// do not refresh before 15:00, since the prices for the next day are available only after 14:00
// do not refresh before 15:00, since the prices for the next day are available
// only after 14:00
ZonedDateTime now = ZonedDateTime.now(zone);
if (now.getHour() < 15) {
return false;

View File

@ -29,7 +29,7 @@ thing-type.config.awattar.bestprice.length.description = The number of hours the
thing-type.config.awattar.bestprice.consecutive.label = Consecutive
thing-type.config.awattar.bestprice.consecutive.description = Do the hours need to be consecutive?
thing-type.config.awattar.bestprice.inverted.label = Inverted
thing-type.config.awattar.bestprice.inverted.description = Invert the search for the highest price
thing-type.config.awattar.bestprice.inverted.description = Invert the search to the highest price.
# channel types
channel-type.awattar.price.label = ct/kWh
@ -167,7 +167,7 @@ error.json=Invalid JSON response from aWATTar API
error.interrupted=Communication interrupted
error.execution=Execution error
error.timeout=Timeout retrieving prices from aWATTar API
error.invalid.data=No or invalid data received from aWATTar API
error.empty.data=No or invalid data received from aWATTar API
error.length.value=length needs to be > 0 and < duration.
warn.awattar.statuscode=aWATTar server did not respond with status code 200
error.start.value=Invalid start value

View File

@ -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<AwattarPrice> 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"));
}
}

View File

@ -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.ThingUID;
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 class AwattarBridgeHandlerRefreshTest extends JavaTest {
bridgeHandler = new AwattarBridgeHandler(bridgeMock, httpClientMock, timeZoneProviderMock);
bridgeHandler.setCallback(bridgeCallbackMock);
when(bridgeMock.getHandler()).thenReturn(bridgeHandler);
List<Field> fields = ReflectionSupport.findFields(AwattarBridgeHandler.class,
field -> field.getName().equals("awattarApi"), HierarchyTraversalMode.BOTTOM_UP);
// other mocks
when(bestpriceMock.getBridgeUID()).thenReturn(BRIDGE_UID);
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<Field> 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));
}
}

View File

@ -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.ThingUID;
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<AwattarPrice> 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<Field> 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<AwattarPrice> 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 class AwattarBridgeHandlerTest extends JavaTest {
@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<String, Object> config = Map.of("length", length, "consecutive", consecutive);
when(bestpriceMock.getConfiguration()).thenReturn(new Configuration(config));

View File

@ -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"
}