[awattar] Add tests and improve code (#16871)

* [awattar] add tests

Signed-off-by: Jan N. Klug <github@klug.nrw>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
J-N-K 2024-06-23 22:05:20 +02:00 committed by Ciprian Pascu
parent 45c6234eb7
commit 0d6250db1c
15 changed files with 787 additions and 227 deletions

View File

@ -21,7 +21,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
*/
@NonNullByDefault
public abstract class AwattarBestPriceResult {
private long start;
private long end;

View File

@ -21,11 +21,10 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
*/
@NonNullByDefault
public class AwattarBestpriceConfiguration {
public int rangeStart;
public int rangeDuration;
public int length;
public boolean consecutive;
public int rangeStart = 0;
public int rangeDuration = 24;
public int length = 1;
public boolean consecutive = true;
@Override
public String toString() {

View File

@ -14,7 +14,6 @@ package org.openhab.binding.awattar.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.type.ChannelGroupTypeUID;
/**
* The {@link AwattarBindingConstants} class defines common constants, which are
@ -24,18 +23,12 @@ import org.openhab.core.thing.type.ChannelGroupTypeUID;
*/
@NonNullByDefault
public class AwattarBindingConstants {
public static final String BINDING_ID = "awattar";
public static final String API = "api";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge");
public static final ThingTypeUID THING_TYPE_PRICE = new ThingTypeUID(BINDING_ID, "prices");
public static final ThingTypeUID THING_TYPE_BESTPRICE = new ThingTypeUID(BINDING_ID, "bestprice");
public static final ThingTypeUID THING_TYPE_BESTNEXT = new ThingTypeUID(BINDING_ID, "bestnext");
public static final ChannelGroupTypeUID CHANNEL_GROUP_TYPE_HOURLY_PRICES = new ChannelGroupTypeUID(BINDING_ID,
"hourly-prices");
public static final String CHANNEL_GROUP_CURRENT = "current";
@ -51,7 +44,4 @@ public class AwattarBindingConstants {
public static final String CHANNEL_COUNTDOWN = "countdown";
public static final String CHANNEL_REMAINING = "remaining";
public static final String CHANNEL_HOURS = "hours";
public static final String CHANNEL_DURATION = "rangeDuration";
public static final String CHANNEL_LOOKUP_HOURS = "lookupHours";
public static final String CHANNEL_CONSECUTIVE = "consecutive";
}

View File

@ -21,7 +21,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
*/
@NonNullByDefault
public class AwattarBridgeConfiguration {
public double basePrice;
public double vatPercent;
public String country = "";

View File

@ -28,11 +28,10 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
*/
@NonNullByDefault
public class AwattarConsecutiveBestPriceResult extends AwattarBestPriceResult {
private double priceSum = 0;
private int length = 0;
private String hours;
private ZoneId zoneId;
private final String hours;
private final ZoneId zoneId;
public AwattarConsecutiveBestPriceResult(List<AwattarPrice> prices, ZoneId zoneId) {
super();
@ -40,14 +39,14 @@ public class AwattarConsecutiveBestPriceResult extends AwattarBestPriceResult {
StringBuilder hours = new StringBuilder();
boolean second = false;
for (AwattarPrice price : prices) {
priceSum += price.getPrice();
priceSum += price.netPrice();
length++;
updateStart(price.getStartTimestamp());
updateEnd(price.getEndTimestamp());
updateStart(price.timerange().start());
updateEnd(price.timerange().end());
if (second) {
hours.append(',');
}
hours.append(getHourFrom(price.getStartTimestamp(), zoneId));
hours.append(getHourFrom(price.timerange().start(), zoneId));
second = true;
}
this.hours = hours.toString();

View File

@ -29,12 +29,11 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
*/
@NonNullByDefault
public class AwattarNonConsecutiveBestPriceResult extends AwattarBestPriceResult {
private List<AwattarPrice> members;
private ZoneId zoneId;
private final List<AwattarPrice> members;
private final ZoneId zoneId;
private boolean sorted = true;
public AwattarNonConsecutiveBestPriceResult(int size, ZoneId zoneId) {
public AwattarNonConsecutiveBestPriceResult(ZoneId zoneId) {
super();
this.zoneId = zoneId;
members = new ArrayList<>();
@ -43,13 +42,13 @@ public class AwattarNonConsecutiveBestPriceResult extends AwattarBestPriceResult
public void addMember(AwattarPrice member) {
sorted = false;
members.add(member);
updateStart(member.getStartTimestamp());
updateEnd(member.getEndTimestamp());
updateStart(member.timerange().start());
updateEnd(member.timerange().end());
}
@Override
public boolean isActive() {
return members.stream().anyMatch(x -> x.contains(Instant.now().toEpochMilli()));
return members.stream().anyMatch(x -> x.timerange().contains(Instant.now().toEpochMilli()));
}
@Override
@ -59,12 +58,7 @@ public class AwattarNonConsecutiveBestPriceResult extends AwattarBestPriceResult
private void sort() {
if (!sorted) {
members.sort(new Comparator<>() {
@Override
public int compare(AwattarPrice o1, AwattarPrice o2) {
return Long.compare(o1.getStartTimestamp(), o2.getStartTimestamp());
}
});
members.sort(Comparator.comparingLong(p -> p.timerange().start()));
}
}
@ -77,7 +71,7 @@ public class AwattarNonConsecutiveBestPriceResult extends AwattarBestPriceResult
if (second) {
res.append(',');
}
res.append(getHourFrom(price.getStartTimestamp(), zoneId));
res.append(getHourFrom(price.timerange().start(), zoneId));
second = true;
}
return res.toString();

View File

@ -12,63 +12,26 @@
*/
package org.openhab.binding.awattar.internal;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.awattar.internal.handler.TimeRange;
/**
* Class to store hourly price data.
*
* @author Wolfgang Klimt - initial contribution
* @author Jan N. Klug - Refactored to record
*/
@NonNullByDefault
public class AwattarPrice implements Comparable<AwattarPrice> {
private final Double price;
private final long endTimestamp;
private final long startTimestamp;
private final int hour;
public AwattarPrice(double price, long startTimestamp, long endTimestamp, ZoneId zoneId) {
this.price = price;
this.endTimestamp = endTimestamp;
this.startTimestamp = startTimestamp;
this.hour = ZonedDateTime.ofInstant(Instant.ofEpochMilli(startTimestamp), zoneId).getHour();
}
public long getStartTimestamp() {
return startTimestamp;
}
public long getEndTimestamp() {
return endTimestamp;
}
public double getPrice() {
return price;
}
public record AwattarPrice(double netPrice, double grossPrice, double netTotal, double grossTotal,
TimeRange timerange) implements Comparable<AwattarPrice> {
@Override
public String toString() {
return String.format("(%1$tF %1$tR - %2$tR: %3$.3f)", startTimestamp, endTimestamp, getPrice());
}
public int getHour() {
return hour;
return String.format("(%1$tF %1$tR - %2$tR: %3$.3f)", timerange.start(), timerange.end(), netPrice);
}
@Override
public int compareTo(AwattarPrice o) {
return price.compareTo(o.price);
}
public boolean isBetween(long start, long end) {
return startTimestamp >= start && endTimestamp <= end;
}
public boolean contains(long timestamp) {
return startTimestamp <= timestamp && endTimestamp > timestamp;
return Double.compare(netPrice, o.netPrice);
}
}

View File

@ -44,7 +44,7 @@ public class AwattarUtil {
}
public static ZonedDateTime getCalendarForHour(int hour, ZoneId zone) {
return ZonedDateTime.now(zone).truncatedTo(ChronoUnit.DAYS).plus(hour, ChronoUnit.HOURS);
return ZonedDateTime.now(zone).truncatedTo(ChronoUnit.DAYS).plusHours(hour);
}
public static DateTimeType getDateTimeType(long time, TimeZoneProvider tz) {

View File

@ -30,10 +30,9 @@ import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@ -67,12 +66,11 @@ import org.slf4j.LoggerFactory;
*/
@NonNullByDefault
public class AwattarBestpriceHandler extends BaseThingHandler {
private static final int THING_REFRESH_INTERVAL = 60;
private final Logger logger = LoggerFactory.getLogger(AwattarBestpriceHandler.class);
private final int thingRefreshInterval = 60;
@Nullable
private ScheduledFuture<?> thingRefresher;
private @Nullable ScheduledFuture<?> thingRefresher;
private final TimeZoneProvider timeZoneProvider;
@ -85,14 +83,8 @@ public class AwattarBestpriceHandler extends BaseThingHandler {
public void initialize() {
AwattarBestpriceConfiguration config = getConfigAs(AwattarBestpriceConfiguration.class);
boolean configValid = true;
if (config.length >= config.rangeDuration) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.length.value");
configValid = false;
}
if (!configValid) {
return;
}
@ -104,7 +96,8 @@ public class AwattarBestpriceHandler extends BaseThingHandler {
* here
*/
thingRefresher = scheduler.scheduleAtFixedRate(this::refreshChannels,
getMillisToNextMinute(1, timeZoneProvider), thingRefreshInterval * 1000, TimeUnit.MILLISECONDS);
getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL * 1000,
TimeUnit.MILLISECONDS);
}
}
updateStatus(ThingStatus.UNKNOWN);
@ -138,24 +131,24 @@ public class AwattarBestpriceHandler extends BaseThingHandler {
return;
}
AwattarBridgeHandler bridgeHandler = (AwattarBridgeHandler) bridge.getHandler();
if (bridgeHandler == null || bridgeHandler.getPriceMap() == null) {
if (bridgeHandler == null || bridgeHandler.getPrices() == null) {
logger.debug("No prices available, so can't refresh channel.");
// no prices available, can't continue
updateState(channelUID, state);
return;
}
AwattarBestpriceConfiguration config = getConfigAs(AwattarBestpriceConfiguration.class);
Timerange timerange = getRange(config.rangeStart, config.rangeDuration, bridgeHandler.getTimeZone());
if (!(bridgeHandler.containsPriceFor(timerange.start) && bridgeHandler.containsPriceFor(timerange.end))) {
TimeRange timerange = getRange(config.rangeStart, config.rangeDuration, bridgeHandler.getTimeZone());
if (!(bridgeHandler.containsPriceFor(timerange.start()) && bridgeHandler.containsPriceFor(timerange.end()))) {
updateState(channelUID, state);
return;
}
AwattarBestPriceResult result;
List<AwattarPrice> range = getPriceRange(bridgeHandler, timerange);
if (config.consecutive) {
ArrayList<AwattarPrice> range = new ArrayList<>(config.rangeDuration);
range.addAll(getPriceRange(bridgeHandler, timerange,
(o1, o2) -> Long.compare(o1.getStartTimestamp(), o2.getStartTimestamp())));
range.sort(Comparator.comparing(AwattarPrice::timerange));
AwattarConsecutiveBestPriceResult res = new AwattarConsecutiveBestPriceResult(
range.subList(0, config.length), bridgeHandler.getTimeZone());
@ -168,9 +161,8 @@ public class AwattarBestpriceHandler extends BaseThingHandler {
}
result = res;
} else {
List<AwattarPrice> range = getPriceRange(bridgeHandler, timerange,
(o1, o2) -> Double.compare(o1.getPrice(), o2.getPrice()));
AwattarNonConsecutiveBestPriceResult res = new AwattarNonConsecutiveBestPriceResult(config.length,
range.sort(Comparator.naturalOrder());
AwattarNonConsecutiveBestPriceResult res = new AwattarNonConsecutiveBestPriceResult(
bridgeHandler.getTimeZone());
int ct = 0;
for (AwattarPrice price : range) {
@ -223,21 +215,18 @@ public class AwattarBestpriceHandler extends BaseThingHandler {
}
}
private List<AwattarPrice> getPriceRange(AwattarBridgeHandler bridgeHandler, Timerange range,
Comparator<AwattarPrice> comparator) {
ArrayList<AwattarPrice> result = new ArrayList<>();
SortedMap<Long, AwattarPrice> priceMap = bridgeHandler.getPriceMap();
if (priceMap == null) {
private List<AwattarPrice> getPriceRange(AwattarBridgeHandler bridgeHandler, TimeRange range) {
List<AwattarPrice> result = new ArrayList<>();
SortedSet<AwattarPrice> prices = bridgeHandler.getPrices();
if (prices == null) {
logger.debug("No prices available, can't compute ranges");
return result;
}
result.addAll(priceMap.values().stream().filter(x -> x.isBetween(range.start, range.end))
.collect(Collectors.toSet()));
result.sort(comparator);
result.addAll(prices.stream().filter(x -> range.contains(x.timerange())).toList());
return result;
}
private Timerange getRange(int start, int duration, ZoneId zoneId) {
protected TimeRange getRange(int start, int duration, ZoneId zoneId) {
ZonedDateTime startCal = getCalendarForHour(start, zoneId);
ZonedDateTime endCal = startCal.plusHours(duration);
ZonedDateTime now = ZonedDateTime.now(zoneId);
@ -251,16 +240,6 @@ public class AwattarBestpriceHandler extends BaseThingHandler {
startCal = startCal.plusDays(1);
endCal = endCal.plusDays(1);
}
return new Timerange(startCal.toInstant().toEpochMilli(), endCal.toInstant().toEpochMilli());
}
private class Timerange {
long start;
long end;
Timerange(long start, long end) {
this.start = start;
this.end = end;
}
return new TimeRange(startCal.toInstant().toEpochMilli(), endCal.toInstant().toEpochMilli());
}
}

View File

@ -20,8 +20,9 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.SortedMap;
import java.util.TreeMap;
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;
@ -53,7 +54,7 @@ import com.google.gson.JsonSyntaxException;
* The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API.
*
* The API provides hourly prices for the current day and, starting from 14:00, hourly prices for the next day.
* Check the documentation at https://www.awattar.de/services/api
* Check the documentation at <a href="https://www.awattar.de/services/api" />
*
*
*
@ -61,26 +62,22 @@ import com.google.gson.JsonSyntaxException;
*/
@NonNullByDefault
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;
@Nullable
private ScheduledFuture<?> dataRefresher;
private @Nullable ScheduledFuture<?> dataRefresher;
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
@Nullable
private SortedMap<Long, AwattarPrice> priceMap;
private final int dataRefreshInterval = 60;
private @Nullable SortedSet<AwattarPrice> prices;
private double vatFactor = 0;
private long lastUpdated = 0;
private double basePrice = 0;
private long minTimestamp = 0;
private long maxTimestamp = 0;
private ZoneId zone;
private TimeZoneProvider timeZoneProvider;
private final TimeZoneProvider timeZoneProvider;
public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
super(thing);
@ -110,7 +107,7 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
return;
}
dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, dataRefreshInterval * 1000,
dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000,
TimeUnit.MILLISECONDS);
}
@ -121,18 +118,17 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
localRefresher.cancel(true);
}
dataRefresher = null;
priceMap = null;
lastUpdated = 0;
prices = null;
}
public void refreshIfNeeded() {
void refreshIfNeeded() {
if (needRefresh()) {
refresh();
}
updateStatus(ThingStatus.ONLINE);
}
private void getPrices() {
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);
@ -151,32 +147,26 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
String content = contentResponse.getContentAsString();
logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content);
switch (httpStatus) {
case OK_200:
Gson gson = new Gson();
SortedMap<Long, AwattarPrice> result = new TreeMap<>();
minTimestamp = 0;
maxTimestamp = 0;
AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
if (apiData != null) {
for (Datum d : apiData.data) {
result.put(d.startTimestamp,
new AwattarPrice(d.marketprice / 10.0, d.startTimestamp, d.endTimestamp, zone));
updateMin(d.startTimestamp);
updateMax(d.endTimestamp);
}
priceMap = result;
updateStatus(ThingStatus.ONLINE);
lastUpdated = Instant.now().toEpochMilli();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/error.invalid.data");
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));
}
break;
default:
prices = result;
updateStatus(ThingStatus.ONLINE);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/warn.awattar.statuscode");
"@text/error.invalid.data");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/warn.awattar.statuscode");
}
} catch (JsonSyntaxException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.json");
@ -193,27 +183,9 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
if (getThing().getStatus() != ThingStatus.ONLINE) {
return true;
}
SortedMap<Long, AwattarPrice> localMap = priceMap;
if (localMap == null) {
return true;
}
return localMap.lastKey() < Instant.now().toEpochMilli() + 9 * 3600 * 1000;
}
private void refresh() {
getPrices();
}
public double getVatFactor() {
return vatFactor;
}
public double getBasePrice() {
return basePrice;
}
public long getLastUpdated() {
return lastUpdated;
SortedSet<AwattarPrice> localPrices = prices;
return localPrices == null
|| localPrices.last().timerange().start() < Instant.now().toEpochMilli() + 9 * 3600 * 1000;
}
public ZoneId getTimeZone() {
@ -221,32 +193,25 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
}
@Nullable
public synchronized SortedMap<Long, AwattarPrice> getPriceMap() {
if (priceMap == null) {
public synchronized SortedSet<AwattarPrice> getPrices() {
if (prices == null) {
refresh();
}
return priceMap;
return prices;
}
@Nullable
public AwattarPrice getPriceFor(long timestamp) {
SortedMap<Long, AwattarPrice> priceMap = getPriceMap();
if (priceMap == null) {
public @Nullable AwattarPrice getPriceFor(long timestamp) {
SortedSet<AwattarPrice> localPrices = getPrices();
if (localPrices == null || !containsPriceFor(timestamp)) {
return null;
}
if (!containsPriceFor(timestamp)) {
return null;
}
for (AwattarPrice price : priceMap.values()) {
if (timestamp >= price.getStartTimestamp() && timestamp < price.getEndTimestamp()) {
return price;
}
}
return null;
return localPrices.stream().filter(e -> e.timerange().contains(timestamp)).findAny().orElse(null);
}
public boolean containsPriceFor(long timestamp) {
return minTimestamp <= timestamp && maxTimestamp >= timestamp;
SortedSet<AwattarPrice> localPrices = getPrices();
return localPrices != null && localPrices.first().timerange().start() <= timestamp
&& localPrices.last().timerange().end() > timestamp;
}
@Override
@ -257,12 +222,4 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
logger.debug("Binding {} only supports refresh command", BINDING_ID);
}
}
private void updateMin(long ts) {
minTimestamp = (minTimestamp == 0) ? ts : Math.min(minTimestamp, ts);
}
private void updateMax(long ts) {
maxTimestamp = (maxTimestamp == 0) ? ts : Math.max(ts, maxTimestamp);
}
}

View File

@ -44,7 +44,7 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault
@Component(configurationPid = "binding.awattar", service = ThingHandlerFactory.class)
public class AwattarHandlerFactory extends BaseThingHandlerFactory {
private Logger logger = LoggerFactory.getLogger(AwattarHandlerFactory.class);
private final Logger logger = LoggerFactory.getLogger(AwattarHandlerFactory.class);
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_PRICE, THING_TYPE_BESTPRICE,
THING_TYPE_BRIDGE);
@ -69,11 +69,9 @@ public class AwattarHandlerFactory extends BaseThingHandlerFactory {
if (THING_TYPE_BRIDGE.equals(thingTypeUID)) {
return new AwattarBridgeHandler((Bridge) thing, httpClient, timeZoneProvider);
}
if (THING_TYPE_PRICE.equals(thingTypeUID)) {
} else if (THING_TYPE_PRICE.equals(thingTypeUID)) {
return new AwattarPriceHandler(thing, timeZoneProvider);
}
if (THING_TYPE_BESTPRICE.equals(thingTypeUID)) {
} else if (THING_TYPE_BESTPRICE.equals(thingTypeUID)) {
return new AwattarBestpriceHandler(thing, timeZoneProvider);
}

View File

@ -55,11 +55,10 @@ import org.slf4j.LoggerFactory;
*/
@NonNullByDefault
public class AwattarPriceHandler extends BaseThingHandler {
private static final int THING_REFRESH_INTERVAL = 60;
private final Logger logger = LoggerFactory.getLogger(AwattarPriceHandler.class);
private int thingRefreshInterval = 60;
private TimeZoneProvider timeZoneProvider;
private final TimeZoneProvider timeZoneProvider;
private @Nullable ScheduledFuture<?> thingRefresher;
public AwattarPriceHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
@ -78,7 +77,7 @@ public class AwattarPriceHandler extends BaseThingHandler {
/**
* Initialize the binding and start the refresh job.
* The refresh job runs once after initialization and afterwards every hour.
* The refresh job runs once after initialization and afterward every hour.
*/
@Override
@ -91,7 +90,7 @@ public class AwattarPriceHandler extends BaseThingHandler {
* here
*/
thingRefresher = scheduler.scheduleAtFixedRate(this::refreshChannels,
getMillisToNextMinute(1, timeZoneProvider), thingRefreshInterval * 1000, TimeUnit.MILLISECONDS);
getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL, TimeUnit.SECONDS);
}
}
updateStatus(ThingStatus.UNKNOWN);
@ -141,9 +140,9 @@ public class AwattarPriceHandler extends BaseThingHandler {
if (group.equals(CHANNEL_GROUP_CURRENT)) {
target = ZonedDateTime.now(bridgeHandler.getTimeZone());
} else if (group.startsWith("today")) {
target = getCalendarForHour(Integer.valueOf(group.substring(5)), bridgeHandler.getTimeZone());
target = getCalendarForHour(Integer.parseInt(group.substring(5)), bridgeHandler.getTimeZone());
} else if (group.startsWith("tomorrow")) {
target = getCalendarForHour(Integer.valueOf(group.substring(8)), bridgeHandler.getTimeZone()).plusDays(1);
target = getCalendarForHour(Integer.parseInt(group.substring(8)), bridgeHandler.getTimeZone()).plusDays(1);
} else {
logger.warn("Unsupported channel group {}", group);
updateState(channelUID, state);
@ -157,21 +156,20 @@ public class AwattarPriceHandler extends BaseThingHandler {
updateState(channelUID, state);
return;
}
double currentprice = price.getPrice();
String channelId = channelUID.getIdWithoutGroup();
switch (channelId) {
case CHANNEL_MARKET_NET:
state = toDecimalType(currentprice);
state = toDecimalType(price.netPrice());
break;
case CHANNEL_MARKET_GROSS:
state = toDecimalType(currentprice * bridgeHandler.getVatFactor());
state = toDecimalType(price.grossPrice());
break;
case CHANNEL_TOTAL_NET:
state = toDecimalType(currentprice + bridgeHandler.getBasePrice());
state = toDecimalType(price.netTotal());
break;
case CHANNEL_TOTAL_GROSS:
state = toDecimalType((currentprice + bridgeHandler.getBasePrice()) * bridgeHandler.getVatFactor());
state = toDecimalType(price.grossTotal());
break;
default:
logger.warn("Unknown channel id {} for Thing type {}", channelUID, getThing().getThingTypeUID());

View File

@ -0,0 +1,54 @@
/**
* 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.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link TimeRange} defines a time range (defined by two timestamps)
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public record TimeRange(long start, long end) implements Comparable<TimeRange> {
/**
* Check if a given timestamp is in this time range
*
* @param timestamp the timestamp
* @return {@code true} if the timestamp is equal to or greater than {@link #start} and less than {@link #end}
*/
public boolean contains(long timestamp) {
return timestamp >= start && timestamp < end;
}
/**
* Check if another time range is inside this time range
*
* @param other the other time range
* @return {@code true} if {@link #start} of this time range is the same or before the other time range's
* {@link #start} and this {@link #end} is the same or after the other time range's {@link #end}
*/
public boolean contains(TimeRange other) {
return start <= other.start && end >= other.end;
}
/**
* Compare two time ranges by their start timestamp
*
* @param o the object to be compared
* @return the result of {@link Long#compare(long, long)} for the {@link #start} timestamps
*/
public int compareTo(TimeRange o) {
return Long.compare(start, o.start);
}
}

View File

@ -0,0 +1,193 @@
/**
* 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.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 java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
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.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.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.core.config.core.Configuration;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.test.java.JavaTest;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.types.State;
/**
* The {@link AwattarBridgeHandlerTest} contains tests for the {@link AwattarBridgeHandler}
*
* @author Jan N. Klug - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@NonNullByDefault
public class AwattarBridgeHandlerTest extends JavaTest {
public static final ThingUID BRIDGE_UID = new ThingUID(AwattarBindingConstants.THING_TYPE_BRIDGE, "testBridge");
// bridge mocks
private @Mock @NonNullByDefault({}) Bridge bridgeMock;
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;
// best price handler mocks
private @Mock @NonNullByDefault({}) Thing bestpriceMock;
private @Mock @NonNullByDefault({}) ThingHandlerCallback bestPriceCallbackMock;
private @NonNullByDefault({}) AwattarBridgeHandler bridgeHandler;
@BeforeEach
public void setUp() throws IOException, ExecutionException, InterruptedException, TimeoutException {
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));
}
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"));
bridgeHandler = new AwattarBridgeHandler(bridgeMock, httpClientMock, timeZoneProviderMock);
bridgeHandler.setCallback(bridgeCallbackMock);
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() {
AwattarPrice price = bridgeHandler.getPriceFor(1718503200000L);
assertThat(price, is(notNullValue()));
Objects.requireNonNull(price);
assertThat(price.netPrice(), is(closeTo(0.219, 0.001)));
}
@Test
public void testGetPriceForFail() {
AwattarPrice price = bridgeHandler.getPriceFor(1518503200000L);
assertThat(price, is(nullValue()));
}
@Test
public void testContainsPrizeFor() {
assertThat(bridgeHandler.containsPriceFor(1618503200000L), is(false));
assertThat(bridgeHandler.containsPriceFor(1718503200000L), is(true));
assertThat(bridgeHandler.containsPriceFor(1818503200000L), is(false));
}
public static Stream<Arguments> testBestpriceHandler() {
return Stream.of( //
Arguments.of(1, true, CHANNEL_START, new DateTimeType("2024-06-15T14:00:00.000+0200")),
Arguments.of(1, true, CHANNEL_END, new DateTimeType("2024-06-15T15:00:00.000+0200")),
Arguments.of(1, true, CHANNEL_HOURS, new StringType("14")),
Arguments.of(1, false, CHANNEL_START, new DateTimeType("2024-06-15T14:00:00.000+0200")),
Arguments.of(1, false, CHANNEL_END, new DateTimeType("2024-06-15T15:00:00.000+0200")),
Arguments.of(1, false, CHANNEL_HOURS, new StringType("14")),
Arguments.of(2, true, CHANNEL_START, new DateTimeType("2024-06-15T13:00:00.000+0200")),
Arguments.of(2, true, CHANNEL_END, new DateTimeType("2024-06-15T15:00:00.000+0200")),
Arguments.of(2, true, CHANNEL_HOURS, new StringType("13,14")),
Arguments.of(2, false, CHANNEL_START, new DateTimeType("2024-06-15T13:00:00.000+0200")),
Arguments.of(2, false, CHANNEL_END, new DateTimeType("2024-06-15T15:00:00.000+0200")),
Arguments.of(2, false, CHANNEL_HOURS, new StringType("13,14")));
}
@ParameterizedTest
@MethodSource
public 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));
AwattarBestpriceHandler handler = new AwattarBestpriceHandler(bestpriceMock, timeZoneProviderMock) {
@Override
protected TimeRange getRange(int start, int duration, ZoneId zoneId) {
return new TimeRange(1718402400000L, 1718488800000L);
}
};
handler.setCallback(bestPriceCallbackMock);
ChannelUID channelUID = new ChannelUID(bestPriceUid, channelId);
handler.refreshChannel(channelUID);
verify(bestPriceCallbackMock).stateUpdated(channelUID, expectedState);
}
}

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