Skip to content

Commit

Permalink
[aWATTar] add aWATTar API class
Browse files Browse the repository at this point in the history
Signed-off-by: Thomas Leber <thomas@tl-photography.at>
  • Loading branch information
tl-photography committed Jul 28, 2024
1 parent 7e73ed8 commit 4f57c66
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 84 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package org.openhab.binding.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 javax.validation.constraints.NotBlank;

import org.eclipse.jdt.annotation.NonNull;
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;

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 @NonNull HttpClient httpClient;

private @NotBlank double vatFactor;
private @NotBlank double basePrice;

private @NonNull ZoneId zone;

/**
* Exception thrown when the response from the aWATTar API is empty.
*/
public class EmptyDataResponseException extends Exception {
private static final long serialVersionUID = 1L;

public EmptyDataResponseException(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(@NonNull HttpClient httpClient, @NonNull ZoneId zone) {
this.zone = zone;
this.httpClient = httpClient;
}

/**
* Initialize the aWATTar API.
*
* @param config the configuration to use
*/
public void initialize(AwattarBridgeConfiguration config) {
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 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 InterruptedException, TimeoutException, ExecutionException, EmptyDataResponseException {
// 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) {
logger.error("The response from the aWATTar API is empty");
throw new EmptyDataResponseException("The response from the aWATTar API is empty");
} else if (httpStatus == OK_200) {
Gson gson = new Gson();
SortedSet<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));

AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
for (Datum d : apiData.data) {
double netPrice = d.marketprice / 10.0;
TimeRange timeRange = new TimeRange(d.startTimestamp, d.endTimestamp);

logger.trace("Adding price: netPrice = {}, timeRange = '{}'", netPrice, timeRange);
result.add(new AwattarPrice(netPrice, netPrice * vatFactor, netPrice + basePrice,
(netPrice + basePrice) * vatFactor, timeRange));

}
return result;
} else {
logger.error("The aWATTar API returned code : {}", httpStatus);
throw new ExecutionException("The aWATTar API returned code : " + httpStatus, null);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,12 @@
*/
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.BINDING_ID;

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;
Expand All @@ -31,11 +26,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.api.AwattarApi;
import org.openhab.binding.api.AwattarApi.EmptyDataResponseException;
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.core.i18n.TimeZoneProvider;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
Expand All @@ -47,7 +41,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;

/**
Expand All @@ -65,47 +58,34 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
private static final int DATA_REFRESH_INTERVAL = 60;

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 AwattarApi awattarApi;

public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
super(thing);
this.httpClient = httpClient;
url = URLDE;
this.timeZoneProvider = timeZoneProvider;
zone = timeZoneProvider.getTimeZone();

awattarApi = new AwattarApi(httpClient, zone);
}

@Override
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;

try {
awattarApi.initialize(config);
} catch (IllegalArgumentException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/error.unsupported.country");
return;
}

dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L,
Expand All @@ -128,54 +108,24 @@ 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<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));
AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
if (apiData != null) {
for (Datum d : apiData.data) {
double netPrice = d.marketprice / 10.0;
TimeRange timerange = new TimeRange(d.startTimestamp, d.endTimestamp);
result.add(new AwattarPrice(netPrice, netPrice * vatFactor, netPrice + basePrice,
(netPrice + basePrice) * vatFactor, timerange));
}
prices = result;
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");
}
prices = awattarApi.getData();
updateStatus(ThingStatus.ONLINE);
} catch (ExecutionException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.execution");
} 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");
Thread.currentThread().interrupt();
} catch (TimeoutException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.timeout");
} catch (EmptyDataResponseException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.empty.data");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ 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 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
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,15 @@ channel-group-type.awattar.tomorrow23.label = Morgen 23\:00
channel-group-type.awattar.tomorrow23.description = Morgige Preise von 23\:00 bis 00\:00


error.config.missing=Konfiguration fehlt\!
error.bridge.missing=Bridge fehlt\!
error.channelgroup.missing=Channelgroup fehlt\!
error.config.missing=Konfiguration fehlt!
error.bridge.missing=Bridge fehlt!
error.channelgroup.missing=Channelgroup fehlt!
error.unsupported.country=Land wird nicht unterstützt, bitte DE oder AT verwenden
error.duration.value=Ungültiger Wert für Dauer
error.json=Ungültiges JSON von aWATTar
error.interrupted=Kommunikation unterbrochen
error.execution=Ausführungsfehler
error.timeout=Timeout beim Abrufen der Preise von aWATTar
error.invalid.data=Keine oder ungültige Daten von der aWATTar API erhalten
error.empty.data=Keine Daten von der aWATTar API erhalten
error.length.value=Length muss größer als 0 und kleiner als duration sein.
warn.awattar.statuscode=Der aWATTar Server antwortete nicht mit Statuscode 200
error.start.value=Ungültiger Startwert
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
*/
package org.openhab.binding.awattar.internal.handler;

import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.IOException;
import java.io.InputStream;
Expand Down Expand Up @@ -125,7 +127,7 @@ void testRefreshIfNeeded_ThingOffline() throws SecurityException {
* @throws SecurityException
*/
@Test
void testRefreshIfNeeded_DataEmptry() throws SecurityException {
void testRefreshIfNeeded_DataEmpty() throws SecurityException {
when(bridgeMock.getStatus()).thenReturn(ThingStatus.ONLINE);

bridgeHandler.refreshIfNeeded();
Expand Down

0 comments on commit 4f57c66

Please sign in to comment.