[energyforecast] Initial contribution (#20325)

* initial version

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
Bernd Weymann
2026-04-03 17:03:58 +02:00
committed by GitHub
parent 6ac27805f1
commit 5effac5bc6
21 changed files with 2317 additions and 0 deletions
+1
View File
@@ -112,6 +112,7 @@
/bundles/org.openhab.binding.emotiva/ @espenaf
/bundles/org.openhab.binding.energenie/ @hmerk
/bundles/org.openhab.binding.energidataservice/ @jlaur
/bundles/org.openhab.binding.energyforecast/ @weymann
/bundles/org.openhab.binding.enigma2/ @gdolfen
/bundles/org.openhab.binding.enocean/ @fruggy83
/bundles/org.openhab.binding.enphase/ @Hilbrand
+5
View File
@@ -546,6 +546,11 @@
<artifactId>org.openhab.binding.energidataservice</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.energyforecast</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.enigma2</artifactId>
@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons
@@ -0,0 +1,97 @@
# Energy Forecast Binding
Binding provides AI price forecast 48h / 96h beyond day-ahead pricing with [Energy Forecast Service](https://www.energyforecast.de/).
Check in beforehand if your [price zone](https://www.energyforecast.de/api-docs/index.html) is supported.
[Registration](https://www.energyforecast.de/users/sign_up) is mandatory!
## Binding Configuration
### `price-forecast` Thing Configuration
| Name | Type | Description | Default | Required |
|-------------------|-----------|-----------------------------------------------------------------------------------|-----------|----------|
| token | text | Token for energy forecast service to provide forecast data | N/A | yes |
| zone | text | Bidding zone for price queries | N/A | yes |
| fixCost | decimal | Net fix costs in ct/kWh added on top of the forecast price, e.g. 15,3 | 0 | no |
| resolution | text | Resolution in ISO 8601 Duration format | PT15M | no |
| refreshInterval | integer | Refresh interval in minutes. Check with service throttling | 180 | no |
| errorLimit | integer | Limit error percentage values for better visualization | 0 | no |
`token` needs to be generated after registration at [Energy Forecast Service](https://www.energyforecast.de/api_keys).
`zone` from [API](https://www.energyforecast.de/api-docs/index.html) are given as options.
`resolution` time resolution given as options. `PT15M` an `PT60M` are supported.
`refreshInterval` given in minutes. Align this value with your [booked plan](https://www.energyforecast.de/pricing).
`errorLimit` to avoid extraordinary error percentage values in `metric` group. Value `0` is no limit.
#### Calculate Gross Price
`fixCost` shall be net costs which will be added to the net energy price.
If you've already one or more items holding fix cost values consider to [calculate the future prices in a rule](https://www.openhab.org/addons/bindings/energidataservice/#time-series).
Use [VAT Transformation Service](https://www.openhab.org/addons/transformations/vat/) to calculate the gross price.
## Channels
All channels delivering `timeseries` information.
Attaching items which are bound only to `rrd4j` persistence will not work.
If you don't have a database installed [InMemory persistence](https://www.openhab.org/addons/persistence/inmemory/) can be used.
### Group `price`
| Channel | Type | Description |
|---------------|-----------------------|---------------------------------------------------|
| series | Number:EnergyPrice | Actual and future price series |
| origin | Number | Originator of the price (market or forecast) |
`series` delivers price information from 48h up to 96h into the future depending on your [booked plan](https://www.energyforecast.de/pricing).
`origin` shows for every price the originator.
- 0: Market
- 1: AI Forecast
### Group `metric`
Metrics for AI price forecasts.
Calculation is done on net prices without configured `fixCost` and any VAT applied via transformations/rules.
See [Future Forecasting](https://www.future-forecasting.de/en/wiki/fehlermass/) for further description.
**Note: After first installation these values will stay empty up to 1,5 days!**
To compare market and forecast prices this time is needed until a market price is available.
| Channel | Type | Description |
|-------------------|-----------------------|-----------------------------------------------------------------------------------|
| forecast | Number:EnergyPrice | AI forecast price series without any market prices |
| forecast-error | Number:EnergyPrice | Difference between market and forecast price |
| percent-error | Number:Dimensionless | Percentage error between market and forecast price |
| mean-abs | Number:EnergyPrice | Mean absolute error showing the average of absolute forecast errors |
| mean-abs-percent | Number:Dimensionless | Mean absolute percentage error showing the average of absolute percentage errors |
`forecast` timeseries contains only forecast prices, market prices are excluded.
`forecast-error` price difference between market and forecast price.
`percent-error` is the `forecast-error` in percent.
It can show extraordinary high values if market prices are around zero cost.
If market price is 0.001 and forecast was 0.006 the percentage error is _high_ while `forecast-error` is quite low.
For visualization you can limit these values with configuration `errorLimit`.
`mean-abs` shows the average of all absolute `forecast-error` calculations as one value.
`mean-abs-percent` shows the average of all absolute `percent-error` calculations as one value.
## Full Example
### `demo.things`
```java
Thing energyforecast:price-forecast:UID "Energy Forecast" [zone="YOUR_BIDDING_ZONE", token="YOUR_TOKEN", fixCost=12.3, resolution="PT15M", refreshInterval=180, errorLimit=0]
```
### `demo.items`
```java
Number:EnergyPrice Energy_Forecast_Price_Series "Price Series" {channel="energyforecast:price-forecast:UID:price#series"}
Number Energy_Forecast_Price_Origins "Price Origin" {channel="energyforecast:price-forecast:UID:price#origin"}
Number:EnergyPrice Energy_Forecast_Forecast "Forecast" {channel="energyforecast:price-forecast:UID:metric#forecast"}
Number:EnergyPrice Energy_Forecast_Forecast_Error "Forecast Error" {channel="energyforecast:price-forecast:UID:metric#forecast-error"}
Number:Dimensionless Energy_Forecast_Percent_Error "Percent Error" {channel="energyforecast:price-forecast:UID:metric#percent-error"}
Number:EnergyPrice Energy_Forecast_Mean_Absolute "Mean Absolute" {channel="energyforecast:price-forecast:UID:metric#mean-abs"}
Number:Dimensionless Energy_Forecast_Mean_Absolute_Percent "Mean Absolute Percent" {channel="energyforecast:price-forecast:UID:metric#mean-abs-percent"}
```
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.energyforecast</artifactId>
<name>openHAB Add-ons :: Bundles :: EnergyForecast Binding</name>
<dependencies>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>${json.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.energyforecast-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.6.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-energyforecast" description="EnergyForecast Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.energyforecast/${project.version}</bundle>
</feature>
</features>
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2010-2026 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.energyforecast.internal;
import java.time.Clock;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link EnergyForecastBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class EnergyForecastBindingConstants {
public static final String BINDING_ID = "energyforecast";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_PRICE_FORECAST = new ThingTypeUID(BINDING_ID, "price-forecast");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_PRICE_FORECAST);
public static final String ENERGY_FORECAST_URL = "https://www.energyforecast.de/api/v1/predictions/next_96_hours";
public static final String CHANNEL_GROUP_PRICE = "price";
public static final String CHANNEL_GROUP_METRIC = "metric";
public static final String CHANNEL_PRICE_SERIES = "series";
public static final String CHANNEL_PRICE_ORIGIN = "origin";
public static final String CHANNEL_METRIC_FORECAST = "forecast";
public static final String CHANNEL_METRIC_FORECAST_ERROR = "forecast-error";
public static final String CHANNEL_METRIC_PERCENT_ERROR = "percent-error";
public static final String CHANNEL_METRIC_MAE = "mean-abs";
public static final String CHANNEL_METRIC_MAPE = "mean-abs-percent";
public static Clock clock = Clock.systemDefaultZone();
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2010-2026 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.energyforecast.internal;
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.energyforecast.internal.handler.EnergyForecastHandler;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link EnergyForecastHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.energyforecast", service = ThingHandlerFactory.class)
public class EnergyForecastHandlerFactory extends BaseThingHandlerFactory {
private final StorageService storageService;
private final HttpClientFactory hcf;
private final TimeZoneProvider tzp;
@Activate
public EnergyForecastHandlerFactory(final @Reference HttpClientFactory hcf,
final @Reference StorageService storageService, final @Reference TimeZoneProvider tzp) {
this.hcf = hcf;
this.storageService = storageService;
this.tzp = tzp;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_PRICE_FORECAST.equals(thingTypeUID)) {
return new EnergyForecastHandler(thing, hcf.getCommonHttpClient(), storageService, tzp);
}
return null;
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.energyforecast.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link EnergyForecastConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class EnergyForecastConfiguration {
public String zone = "";
public String token = "";
public double fixCost = 0.0;
public String resolution = "PT15M";
public int refreshInterval = 180;
public int errorLimit = 0;
}
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2010-2026 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.energyforecast.internal.dto;
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.json.JSONArray;
import org.json.JSONObject;
import org.openhab.binding.energyforecast.internal.config.EnergyForecastConfiguration;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.storage.Storage;
import org.openhab.core.types.TimeSeries;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link PriceInfo} class is responsible for storing the price information, calculating the metrics and providing
* timeseries
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class PriceInfo {
private static final String JSON_KEY_FORECAST = "forecast";
private static final String JSON_KEY_MARKET = "market";
private final Logger logger = LoggerFactory.getLogger(PriceInfo.class);
private final Storage<String> store;
private final TimeZoneProvider tzp;
private EnergyForecastConfiguration config;
// map with market and forecast prices
private SortedMap<Instant, Double> priceSeries = new TreeMap<>();
private SortedMap<Instant, String> originSeries = new TreeMap<>();
// for metrics
// map keeping all forecast data
private SortedMap<Instant, Double> forecastSeries = new TreeMap<>();
private SortedMap<Instant, Double> forecastErrorSeries = new TreeMap<>();
private SortedMap<Instant, Double> percentErrorSeries = new TreeMap<>();
private Double maeValue = Double.NaN;
private Double mapeValue = Double.NaN;
public PriceInfo(EnergyForecastConfiguration config, Storage<String> store, TimeZoneProvider tzp) {
this.config = config;
this.store = store;
this.tzp = tzp;
restore();
}
private void restore() {
String forecastString = store.get(JSON_KEY_FORECAST);
if (forecastString != null) {
JSONObject forecastJson = new JSONObject(forecastString);
forecastJson.keySet().forEach(key -> {
forecastSeries.put(Instant.parse(key), forecastJson.getDouble(key));
});
logger.debug("Restored {} forecast entries from storage", forecastJson.length());
} else {
logger.debug("No forecast data found in storage, starting with empty series.");
}
}
public synchronized void newPriceSeries(String prices) {
JSONArray priceArray = new JSONArray(prices);
priceArray.forEach(item -> {
JSONObject jsonObject = (JSONObject) item;
Instant start = Instant.parse(jsonObject.getString("start"));
Double price = jsonObject.getDouble("price");
String origin = jsonObject.getString("price_origin");
priceSeries.put(start, price);
originSeries.put(start, origin);
if (JSON_KEY_FORECAST.equals(origin)) {
forecastSeries.put(start, price);
}
});
store.put(JSON_KEY_FORECAST, new JSONObject(forecastSeries).toString());
calculateMetrics();
}
private void calculateMetrics() {
originSeries.forEach((time, orgin) -> {
if (JSON_KEY_MARKET.equals(orgin)) {
Double marketPrice = priceSeries.get(time);
Double forecastPrice = forecastSeries.get(time);
if (marketPrice != null && forecastPrice != null) {
// forecast error
double forecastError = marketPrice - forecastPrice;
forecastErrorSeries.put(time, forecastError);
// percentage error
double percentageError = 0;
if (marketPrice != 0) {
percentageError = (forecastError / marketPrice) * 100;
percentErrorSeries.put(time, percentageError);
} // omit undefined percentage error when market price is zero
}
}
});
maeValue = forecastErrorSeries.values().stream().mapToDouble(value -> Math.abs(value)).sum()
/ forecastErrorSeries.size();
// remove all undefined values for mape calculation
SortedMap<Instant, Double> cleanPercentErrorSeries = percentErrorSeries.entrySet().stream()
.filter(entry -> entry.getValue() != Double.MIN_VALUE)
.collect(TreeMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), TreeMap::putAll);
mapeValue = cleanPercentErrorSeries.values().stream().filter(value -> value != Double.MIN_VALUE)
.mapToDouble(value -> Math.abs(value)).sum() / cleanPercentErrorSeries.size();
}
public synchronized void consolidate() {
Instant startOfDay = LocalDate.now(clock).atStartOfDay(tzp.getTimeZone()).toInstant();
priceSeries = priceSeries.tailMap(startOfDay);
originSeries = originSeries.tailMap(startOfDay);
forecastSeries = forecastSeries.tailMap(startOfDay);
forecastErrorSeries = forecastErrorSeries.tailMap(startOfDay);
percentErrorSeries = percentErrorSeries.tailMap(startOfDay);
}
public synchronized Map<String, TimeSeries> getTimeSeries() {
Map<String, TimeSeries> timeSeriesMap = new HashMap<>();
timeSeriesMap.put(CHANNEL_PRICE_SERIES, getPriceTimeSeries(priceSeries, true));
timeSeriesMap.put(CHANNEL_PRICE_ORIGIN, getOriginsTimeSeries(originSeries));
timeSeriesMap.put(CHANNEL_METRIC_FORECAST, getPriceTimeSeries(forecastSeries, true));
timeSeriesMap.put(CHANNEL_METRIC_FORECAST_ERROR, getPriceTimeSeries(forecastErrorSeries, false));
timeSeriesMap.put(CHANNEL_METRIC_PERCENT_ERROR, getPercentTimeSeries(percentErrorSeries));
timeSeriesMap.put(CHANNEL_METRIC_MAE, getSingleValueSeries(maeValue, " EUR/kWh"));
timeSeriesMap.put(CHANNEL_METRIC_MAPE, getSingleValueSeries(mapeValue, " %"));
return timeSeriesMap;
}
private TimeSeries getPercentTimeSeries(SortedMap<Instant, Double> source) {
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
source.forEach((time, value) -> {
series.add(time, QuantityType.valueOf(value + " %"));
});
return series;
}
private TimeSeries getPriceTimeSeries(SortedMap<Instant, Double> source, boolean priceCorrection) {
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
source.forEach((time, value) -> {
double price = priceCorrection ? (value + (config.fixCost / 100)) : value;
series.add(time, QuantityType.valueOf(price + " EUR/kWh"));
});
return series;
}
private TimeSeries getOriginsTimeSeries(SortedMap<Instant, String> source) {
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
source.forEach((time, value) -> {
int origin = JSON_KEY_MARKET.equals(value) ? 0 : 1;
series.add(time, new DecimalType(origin));
});
return series;
}
private TimeSeries getSingleValueSeries(Double value, String unit) {
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
if (!value.isNaN()) {
series.add(Instant.now(), QuantityType.valueOf(value + unit));
}
return series;
}
public void handleRemoval() {
store.remove(JSON_KEY_FORECAST);
}
}
@@ -0,0 +1,239 @@
/*
* Copyright (c) 2010-2026 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.energyforecast.internal.handler;
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.energyforecast.internal.config.EnergyForecastConfiguration;
import org.openhab.binding.energyforecast.internal.dto.PriceInfo;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.TimeSeries;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@link EnergyForecastHandler} handles config, thing status and channel updates
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class EnergyForecastHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(EnergyForecastHandler.class);
private final HttpClient httpClient;
private final TimeZoneProvider tzp;
private final StorageService storageService;
private @Nullable ScheduledFuture<?> refreshJob;
private @Nullable PriceInfo prices;
private EnergyForecastConfiguration config = new EnergyForecastConfiguration();
public EnergyForecastHandler(Thing thing, HttpClient client, StorageService storageService, TimeZoneProvider tzp) {
super(thing);
this.httpClient = client;
this.storageService = storageService;
this.tzp = tzp;
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
if (prices == null) {
logger.debug("Ignoring refresh command for channel {} because prices are not initialized", channelUID);
return;
}
updatePriceChannels();
}
}
@Override
public void initialize() {
config = getConfigAs(EnergyForecastConfiguration.class);
if (checkConfig()) {
prices = new PriceInfo(config, storageService.getStorage(thing.getUID().getAsString()), tzp);
updateStatus(ThingStatus.UNKNOWN);
refreshJob = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.refreshInterval,
TimeUnit.MINUTES);
}
}
private boolean checkConfig() {
if (config.token.isBlank()) {
configError("@text/thing-status.energyforecast.token-empty");
return false;
}
if (config.zone.isBlank()) {
configError("@text/thing-status.energyforecast.zone-empty");
return false;
}
if (config.refreshInterval < 15) {
configError("@text/thing-status.energyforecast.refresh-interval-too-low");
return false;
}
return true;
}
private void configError(String reason) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, reason);
}
@Override
public void dispose() {
ScheduledFuture<?> localRefreshJob = refreshJob;
if (localRefreshJob != null) {
localRefreshJob.cancel(true);
refreshJob = null;
}
super.dispose();
}
@Override
public void handleRemoval() {
PriceInfo localPrices = prices;
if (localPrices != null) {
localPrices.handleRemoval();
}
super.handleRemoval();
}
private void refreshData() {
if (!config.token.isBlank()) {
if (fetchEnergyForecastPrices()) {
getPriceInfo().consolidate();
updatePriceChannels();
}
}
}
private void updatePriceChannels() {
Map<String, TimeSeries> seriesMap = getPriceInfo().getTimeSeries();
// price group
sendSeriesToChannel(CHANNEL_GROUP_PRICE, CHANNEL_PRICE_SERIES, seriesMap.get(CHANNEL_PRICE_SERIES));
sendSeriesToChannel(CHANNEL_GROUP_PRICE, CHANNEL_PRICE_ORIGIN, seriesMap.get(CHANNEL_PRICE_ORIGIN));
// metric group
sendSeriesToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_FORECAST, seriesMap.get(CHANNEL_METRIC_FORECAST));
sendSeriesToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_FORECAST_ERROR,
seriesMap.get(CHANNEL_METRIC_FORECAST_ERROR));
sendSeriesToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_PERCENT_ERROR,
seriesMap.get(CHANNEL_METRIC_PERCENT_ERROR));
sendValueToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_MAE, seriesMap.get(CHANNEL_METRIC_MAE));
sendValueToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_MAPE, seriesMap.get(CHANNEL_METRIC_MAPE));
}
private void sendSeriesToChannel(String group, String channel, @Nullable TimeSeries series) {
if (series != null) {
if (CHANNEL_METRIC_PERCENT_ERROR.equals(channel)) {
TimeSeries limitSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
series.getStates().forEach(entry -> limitSeries.add(entry.timestamp(), applyLimit(entry.state())));
sendTimeSeries(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + channel, limitSeries);
} else {
sendTimeSeries(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + channel, series);
}
} else {
logger.warn("No data available for channel {} in group {}", channel, group);
}
}
private void sendValueToChannel(String group, String channel, @Nullable TimeSeries series) {
if (series != null) {
Optional<TimeSeries.Entry> value = series.getStates().findFirst();
value.ifPresent(entry -> {
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + channel, applyLimit(entry.state()));
});
}
}
private State applyLimit(State state) {
if (config.errorLimit != 0) {
if (state instanceof QuantityType quantity) {
if (Units.PERCENT.equals(quantity.getUnit())) {
double originValue = quantity.doubleValue();
if (originValue > 0) {
return QuantityType.valueOf(Math.min(config.errorLimit, quantity.doubleValue()) + " %");
} else {
return QuantityType.valueOf(Math.max(-config.errorLimit, quantity.doubleValue()) + " %");
}
}
}
}
return state;
}
private boolean fetchEnergyForecastPrices() {
try {
Request forecastRequest = httpClient.newRequest(ENERGY_FORECAST_URL).timeout(10, TimeUnit.SECONDS);
forecastRequest.param("fixed_cost_cent", "0");
forecastRequest.param("vat", "0");
forecastRequest.param("resolution", "PT15M".equals(config.resolution) ? "QUARTER_HOURLY" : "HOURLY");
forecastRequest.param("market_zone", config.zone);
forecastRequest.param("token", config.token);
ContentResponse response = forecastRequest.send();
int status = response.getStatus();
logger.debug("Energy price forecast response: {}", status);
if (status == HttpStatus.OK_200) {
getPriceInfo().newPriceSeries(response.getContentAsString());
updateStatus(ThingStatus.ONLINE);
return true;
} else {
String reason = response.getReason();
String statusMessage = "HTTP " + status + (!reason.isEmpty() ? " " + reason : "");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, statusMessage);
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore interrupt status
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
return false;
} catch (ExecutionException | TimeoutException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
return false;
}
}
private PriceInfo getPriceInfo() {
PriceInfo localPrices = prices;
if (localPrices == null) {
throw new IllegalStateException("PriceInfo is not initialized");
}
return localPrices;
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="energyforecast" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>Energy Forecast Binding</name>
<description>Binding provides AI price forecast 48h / 96h beyond day-ahead pricing</description>
<connection>cloud</connection>
<countries>at,be,ch,cz,de,lu,dk,fr,hu,it,nl,no,pl,se,si</countries>
</addon:addon>
@@ -0,0 +1,76 @@
# add-on
addon.energyforecast.name = Energy Forecast Binding
addon.energyforecast.description = Binding provides AI price forecast 48h / 96h beyond day-ahead pricing
# thing types
thing-type.energyforecast.price-forecast.label = Price Forecast
thing-type.energyforecast.price-forecast.description = AI based price forecast beyond day-ahead prices
# thing types config
thing-type.config.energyforecast.price-forecast.errorLimit.label = Error Limit
thing-type.config.energyforecast.price-forecast.errorLimit.description = Limit error percentage values for better visualization
thing-type.config.energyforecast.price-forecast.fixCost.label = Energy Forecast Fix Cost
thing-type.config.energyforecast.price-forecast.fixCost.description = Net fix costs in ct/kWh which will be added on top of the forecast price, e.g. 15,3
thing-type.config.energyforecast.price-forecast.refreshInterval.label = Refresh Interval
thing-type.config.energyforecast.price-forecast.refreshInterval.description = Refresh interval in minutes. Check with service throttling
thing-type.config.energyforecast.price-forecast.resolution.label = Time Resolution
thing-type.config.energyforecast.price-forecast.resolution.description = Resolution in ISO 8601 Duration format
thing-type.config.energyforecast.price-forecast.resolution.option.PT15M = 15 minutes
thing-type.config.energyforecast.price-forecast.resolution.option.PT60M = 60 minutes
thing-type.config.energyforecast.price-forecast.token.label = Energy Forecast Token
thing-type.config.energyforecast.price-forecast.token.description = Token for energy forecast service to provide forecast data
thing-type.config.energyforecast.price-forecast.zone.label = Bidding Zone
thing-type.config.energyforecast.price-forecast.zone.description = Bidding zone for price queries
thing-type.config.energyforecast.price-forecast.zone.option.AT = Austria
thing-type.config.energyforecast.price-forecast.zone.option.BE = Belgium
thing-type.config.energyforecast.price-forecast.zone.option.CH = Switzerland
thing-type.config.energyforecast.price-forecast.zone.option.CZ = Czech Republic
thing-type.config.energyforecast.price-forecast.zone.option.DE-LU = Germany, Luxembourg
thing-type.config.energyforecast.price-forecast.zone.option.DK1 = Denmark 1
thing-type.config.energyforecast.price-forecast.zone.option.DK2 = Denmark 2
thing-type.config.energyforecast.price-forecast.zone.option.FR = France
thing-type.config.energyforecast.price-forecast.zone.option.HU = Hungary
thing-type.config.energyforecast.price-forecast.zone.option.IT-North = Italy North
thing-type.config.energyforecast.price-forecast.zone.option.NL = Netherlands
thing-type.config.energyforecast.price-forecast.zone.option.NO2 = Norway 2
thing-type.config.energyforecast.price-forecast.zone.option.PL = Poland
thing-type.config.energyforecast.price-forecast.zone.option.SE4 = Sweden 4
thing-type.config.energyforecast.price-forecast.zone.option.SI = Slovenia
# channel group types
channel-group-type.energyforecast.metric-group.label = Metrics
channel-group-type.energyforecast.metric-group.description = Metric calculations for AI price forecasts
channel-group-type.energyforecast.metric-group.channel.forecast.label = AI Forecast Price
channel-group-type.energyforecast.metric-group.channel.forecast.description = AI forecast price series without any market prices
channel-group-type.energyforecast.metric-group.channel.forecast-error.label = Forecast Error
channel-group-type.energyforecast.metric-group.channel.forecast-error.description = Difference between market and forecast price
channel-group-type.energyforecast.metric-group.channel.mean-abs.label = Mean Absolute
channel-group-type.energyforecast.metric-group.channel.mean-abs.description = Mean absolute error showing the average of absolute forecast errors
channel-group-type.energyforecast.metric-group.channel.mean-abs-percent.label = Mean Absolute Percent
channel-group-type.energyforecast.metric-group.channel.mean-abs-percent.description = Mean absolute percentage error showing the average of absolute percentage errors
channel-group-type.energyforecast.metric-group.channel.percent-error.label = Percentage Error
channel-group-type.energyforecast.metric-group.channel.percent-error.description = Percentage error between market and forecast price
channel-group-type.energyforecast.price-group.label = Pricing
channel-group-type.energyforecast.price-group.description = Price information including price and price origin
channel-group-type.energyforecast.price-group.channel.origin.label = Price Origin
channel-group-type.energyforecast.price-group.channel.origin.description = Originator of the price (market or forecast)
channel-group-type.energyforecast.price-group.channel.series.label = Price Series
channel-group-type.energyforecast.price-group.channel.series.description = Actual and future price series
# channel types
channel-type.energyforecast.origin.label = Price Origin
channel-type.energyforecast.origin.state.option.0 = Market
channel-type.energyforecast.origin.state.option.1 = AI Forecast
channel-type.energyforecast.percent.label = Delta
channel-type.energyforecast.price.label = Energy Price
# thing status
thing-status.energyforecast.token-empty = Token configuration is mandatory
thing-status.energyforecast.zone-empty = Zone configuration is mandatory
thing-status.energyforecast.refresh-interval-too-low = Refresh interval minimum is 15 minutes
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="energyforecast"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="price-forecast">
<label>Price Forecast</label>
<description>AI based price forecast beyond day-ahead prices</description>
<category>WebService</category>
<semantic-equipment-tag>WebService</semantic-equipment-tag>
<channel-groups>
<channel-group id="price" typeId="price-group"/>
<channel-group id="metric" typeId="metric-group"/>
</channel-groups>
<config-description>
<parameter name="zone" type="text" required="true">
<label>Bidding Zone</label>
<description>Bidding zone for price queries</description>
<options>
<option value="AT">Austria</option>
<option value="BE">Belgium</option>
<option value="CH">Switzerland</option>
<option value="CZ">Czech Republic</option>
<option value="DE-LU">Germany, Luxembourg</option>
<option value="DK1">Denmark 1</option>
<option value="DK2">Denmark 2</option>
<option value="FR">France</option>
<option value="HU">Hungary</option>
<option value="IT-North">Italy North</option>
<option value="NL">Netherlands</option>
<option value="NO2">Norway 2</option>
<option value="PL">Poland</option>
<option value="SE4">Sweden 4</option>
<option value="SI">Slovenia</option>
</options>
<limitToOptions>false</limitToOptions>
</parameter>
<parameter name="token" type="text" required="true">
<label>Energy Forecast Token</label>
<description>Token for energy forecast service to provide forecast data</description>
</parameter>
<parameter name="fixCost" type="decimal" required="false">
<label>Energy Forecast Fix Cost</label>
<description>Net fix costs in ct/kWh which will be added on top of the forecast price, e.g. 15,3</description>
<default>0</default>
</parameter>
<parameter name="resolution" type="text" required="false">
<label>Time Resolution</label>
<description>Resolution in ISO 8601 Duration format</description>
<options>
<option value="PT15M">15 minutes</option>
<option value="PT60M">60 minutes</option>
</options>
<default>PT15M</default>
</parameter>
<parameter name="refreshInterval" type="integer" min="15">
<label>Refresh Interval</label>
<description>Refresh interval in minutes. Check with service throttling</description>
<default>180</default>
</parameter>
<parameter name="errorLimit" type="integer">
<label>Error Limit</label>
<description>Limit error percentage values for better visualization</description>
<default>0</default>
</parameter>
</config-description>
</thing-type>
<channel-group-type id="price-group">
<label>Pricing</label>
<description>Price information including price and price origin</description>
<channels>
<channel id="series" typeId="price">
<label>Price Series</label>
<description>Actual and future price series</description>
</channel>
<channel id="origin" typeId="origin">
<label>Price Origin</label>
<description>Originator of the price (market or forecast)</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="metric-group">
<label>Metrics</label>
<description>Metric calculations for AI price forecasts</description>
<channels>
<channel id="forecast" typeId="price">
<label>AI Forecast Price</label>
<description>AI forecast price series without any market prices </description>
</channel>
<channel id="forecast-error" typeId="price">
<label>Forecast Error</label>
<description>Difference between market and forecast price</description>
</channel>
<channel id="percent-error" typeId="percent">
<label>Percentage Error</label>
<description>Percentage error between market and forecast price</description>
</channel>
<channel id="mean-abs" typeId="price">
<label>Mean Absolute</label>
<description>Mean absolute error showing the average of absolute forecast errors</description>
</channel>
<channel id="mean-abs-percent" typeId="percent">
<label>Mean Absolute Percent</label>
<description>Mean absolute percentage error showing the average of absolute percentage errors</description>
</channel>
</channels>
</channel-group-type>
<channel-type id="price">
<item-type>Number:EnergyPrice</item-type>
<label>Energy Price</label>
<category>Price</category>
<tags>
<tag>Status</tag>
<tag>Price</tag>
</tags>
<state pattern="%.3f %unit%"/>
</channel-type>
<channel-type id="percent">
<item-type unitHint="%">Number:Dimensionless</item-type>
<label>Delta</label>
<state pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="origin">
<item-type>Number</item-type>
<label>Price Origin</label>
<state>
<options>
<option value="0">Market</option>
<option value="1">AI Forecast</option>
</options>
</state>
</channel-type>
</thing:thing-descriptions>
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2010-2026 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.energyforecast;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@link APICaller} performs a real query towards energyforecast.de to check if the API is still working as expected.
* This test should be executed before each release.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class APICaller {
private final Logger logger = LoggerFactory.getLogger(APICaller.class);
private static HttpClient httpClient = new HttpClient(new SslContextFactory.Client());
private String token = "YOUR_TOKEN";
public static void main(String[] args) {
APICaller api = new APICaller();
api.call();
}
private void call() {
try {
httpClient.start();
ContentResponse response = httpClient
.GET(EnergyForecastBindingConstants.ENERGY_FORECAST_URL + "?token=" + token);
logger.warn("Response status: {}", response.getStatus());
logger.warn("{}", response.getContentAsString());
httpClient.stop();
} catch (Exception e) {
logger.warn("Call failed: {}", e.getMessage());
}
}
}
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2010-2026 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.energyforecast;
import static org.junit.jupiter.api.Assertions.fail;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.ConfigDescription;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.binding.builder.ThingStatusInfoBuilder;
import org.openhab.core.thing.type.ChannelGroupTypeUID;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.TimeSeries;
/**
* Test implementation of {@link ThingHandlerCallback} used by the energyforecast binding
* to verify interactions in unit and integration tests. It records state updates, triggers
* and time series sent by handlers so that tests can assert on them.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class CallbackMock implements ThingHandlerCallback {
private @Nullable Bridge bridge;
private ThingStatusInfo status = ThingStatusInfoBuilder.create(ThingStatus.OFFLINE).build();
public Map<String, State> stateMap = new HashMap<>();
public Map<String, String> triggerMap = new HashMap<>();
public Map<String, TimeSeries> timeSeriesMap = new HashMap<>();
public void clear() {
stateMap.clear();
}
public @Nullable State getState(String channel) {
return stateMap.get(channel);
}
@Override
public void stateUpdated(ChannelUID channelUID, State state) {
stateMap.put(channelUID.getAsString(), state);
}
@Override
public void postCommand(ChannelUID channelUID, Command command) {
}
@Override
public void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) {
synchronized (timeSeriesMap) {
timeSeriesMap.put(channelUID.getAsString(), timeSeries);
timeSeriesMap.notifyAll();
}
}
public @Nullable TimeSeries getTimeSeries(String channel) {
synchronized (timeSeriesMap) {
Instant start = Instant.now();
Instant check = Instant.now();
while (timeSeriesMap.get(channel) == null && Duration.between(start, check).getSeconds() < 10) {
try {
timeSeriesMap.wait(100);
} catch (InterruptedException e) {
fail("Interrupted getting timeseries " + channel);
}
check = Instant.now();
}
TimeSeries timeSeries = timeSeriesMap.get(channel);
if (timeSeries == null) {
fail("No timeseries available for " + channel);
}
return timeSeries;
}
}
@Override
public void statusUpdated(Thing thing, ThingStatusInfo thingStatus) {
synchronized (this) {
status = thingStatus;
this.notifyAll();
}
}
public ThingStatusInfo getStatus() {
return status;
}
public void waitForOnline() {
synchronized (this) {
Instant start = Instant.now();
Instant check = Instant.now();
while (!ThingStatus.ONLINE.equals(status.getStatus()) && Duration.between(start, check).getSeconds() < 10) {
try {
this.wait(1000);
} catch (InterruptedException e) {
fail("Interrupted waiting for ONLINE");
}
check = Instant.now();
}
}
// if method is exited without reaching ONLINE e.g. through timeout fail
if (!ThingStatus.ONLINE.equals(status.getStatus())) {
fail("waitForOnline just reached status " + status);
}
}
@Override
public void thingUpdated(Thing thing) {
}
@Override
public void validateConfigurationParameters(Thing thing, Map<String, Object> configurationParameters) {
}
@Override
public void validateConfigurationParameters(Channel channel, Map<String, Object> configurationParameters) {
}
@Override
public @Nullable ConfigDescription getConfigDescription(ChannelTypeUID channelTypeUID) {
return null;
}
@Override
public @Nullable ConfigDescription getConfigDescription(ThingTypeUID thingTypeUID) {
return null;
}
@Override
public void configurationUpdated(Thing thing) {
}
@Override
public void migrateThingType(Thing thing, ThingTypeUID thingTypeUID, Configuration configuration) {
}
@Override
public void channelTriggered(Thing thing, ChannelUID channelUID, String event) {
triggerMap.put(channelUID.getAsString(), event);
}
@Override
public ChannelBuilder createChannelBuilder(ChannelUID channelUID, ChannelTypeUID channelTypeUID) {
return ChannelBuilder.create(new ChannelUID("handler:test"));
}
@Override
public ChannelBuilder editChannel(Thing thing, ChannelUID channelUID) {
return ChannelBuilder.create(new ChannelUID("handler:test"));
}
@Override
public List<ChannelBuilder> createChannelBuilders(ChannelGroupUID channelGroupUID,
ChannelGroupTypeUID channelGroupTypeUID) {
return List.of();
}
@Override
public boolean isChannelLinked(ChannelUID channelUID) {
return false;
}
@Override
public @Nullable Bridge getBridge(ThingUID bridgeUID) {
return bridge;
}
public void setBridge(Bridge bridge) {
this.bridge = bridge;
}
}
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2010-2026 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.energyforecast;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.THING_TYPE_PRICE_FORECAST;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpStatus;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants;
import org.openhab.binding.energyforecast.internal.handler.EnergyForecastHandler;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.unit.CurrencyUnits;
import org.openhab.core.storage.Storage;
import org.openhab.core.test.storage.VolatileStorageService;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.internal.ThingImpl;
import org.openhab.core.types.TimeSeries;
/**
* {@link HandlerTest} tests handler initialization, data update cycle and channel updates
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
class HandlerTest {
private TimeZoneProvider tzp = new TimeZoneProvider() {
@Override
public ZoneId getTimeZone() {
return ZoneId.of("Europe/Berlin");
}
};
@BeforeAll
static void setup() {
String fixedInstant = "2026-02-26T15:00:00Z";
EnergyForecastBindingConstants.clock = Clock.fixed(Instant.parse(fixedInstant), ZoneId.of("Europe/Berlin"));
CurrencyUnits.addUnit(CurrencyUnits.createCurrency("EUR", "Euro"));
}
String readFile(String path) {
try {
return Files.readString(Paths.get(path));
} catch (IOException e) {
fail(e.getMessage());
return "";
}
}
HttpClient prepareResponse(String fileName) {
HttpClient client = mock(HttpClient.class);
Request request = mock(Request.class);
ContentResponse response = mock(ContentResponse.class);
when(client.newRequest(anyString())).thenReturn(request);
when(request.timeout(10, TimeUnit.SECONDS)).thenReturn(request);
try {
when(request.send()).thenReturn(response);
} catch (InterruptedException | TimeoutException | ExecutionException e) {
fail(e.getMessage());
}
when(response.getStatus()).thenReturn(HttpStatus.OK_200);
when(response.getContentAsString()).thenReturn(readFile(fileName));
return client;
}
List<?> createHandler(@Nullable Configuration config) {
ThingImpl thing = new ThingImpl(THING_TYPE_PRICE_FORECAST, "forecast_test");
if (config != null) {
thing.setConfiguration(config);
}
HttpClient client = prepareResponse("src/test/resources/2026-02-26-response.json");
VolatileStorageService storageService = new VolatileStorageService();
EnergyForecastHandler tester = new EnergyForecastHandler(thing, client, storageService, tzp);
CallbackMock callback = new CallbackMock();
tester.setCallback(callback);
return List.of(tester, callback, thing, storageService);
}
@Test
void testConfigError() {
List<?> testObjects = createHandler(null);
EnergyForecastHandler tester = (EnergyForecastHandler) testObjects.get(0);
assertNotNull(tester);
CallbackMock callback = (CallbackMock) testObjects.get(1);
assertNotNull(callback);
ThingImpl thing = (ThingImpl) testObjects.get(2);
assertNotNull(thing);
tester.initialize();
ThingStatusInfo statusInfo = callback.getStatus();
assertEquals(ThingStatus.OFFLINE, statusInfo.getStatus());
assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, statusInfo.getStatusDetail());
assertEquals("@text/thing-status.energyforecast.token-empty", statusInfo.getDescription());
Configuration config = new Configuration();
config.put("token", "abc");
thing.setConfiguration(config);
tester.initialize();
statusInfo = callback.getStatus();
assertEquals(ThingStatus.OFFLINE, statusInfo.getStatus());
assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, statusInfo.getStatusDetail());
assertEquals("@text/thing-status.energyforecast.zone-empty", statusInfo.getDescription());
}
@Test
void testFullCycle() {
Configuration config = new Configuration();
config.put("token", "abc");
config.put("zone", "DE-LU");
List<?> testObjects = createHandler(config);
EnergyForecastHandler tester = (EnergyForecastHandler) testObjects.get(0);
assertNotNull(tester);
CallbackMock callback = (CallbackMock) testObjects.get(1);
assertNotNull(callback);
ThingImpl thing = (ThingImpl) testObjects.get(2);
assertNotNull(thing);
VolatileStorageService storageService = (VolatileStorageService) testObjects.get(3);
assertNotNull(storageService);
Storage<String> store = storageService.getStorage(thing.getUID().getAsString());
tester.initialize();
callback.waitForOnline();
TimeSeries priceSeries = callback.getTimeSeries("energyforecast:price-forecast:forecast_test:price#series");
assertNotNull(priceSeries);
assertEquals(23 + 48, priceSeries.size());
String storeData = store.get("forecast");
assertNotNull(storeData);
JSONObject storedJson = new JSONObject(storeData);
assertEquals(23, storedJson.length());
}
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2010-2026 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.energyforecast;
import static org.junit.jupiter.api.Assertions.*;
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.ZoneId;
import java.util.Iterator;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.openhab.binding.energyforecast.internal.config.EnergyForecastConfiguration;
import org.openhab.binding.energyforecast.internal.dto.PriceInfo;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.CurrencyUnits;
import org.openhab.core.test.storage.VolatileStorage;
import org.openhab.core.types.TimeSeries;
/**
* {@link PriceInfoTest} to check test calculations based on predefined responses.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
class PriceInfoTest {
private TimeZoneProvider tzp = new TimeZoneProvider() {
@Override
public ZoneId getTimeZone() {
return ZoneId.of("Europe/Berlin");
}
};
String readFile(String path) {
try {
return Files.readString(Paths.get(path));
} catch (IOException e) {
fail(e.getMessage());
return "";
}
}
PriceInfo createPriceInfo(EnergyForecastConfiguration config, VolatileStorage<String> store) {
CurrencyUnits.addUnit(CurrencyUnits.createCurrency("EUR", "Euro"));
PriceInfo priceInfo = new PriceInfo(config, store, tzp);
priceInfo.newPriceSeries(readFile("src/test/resources/2026-02-26-response.json"));
Map<String, TimeSeries> timeSeriesMap = priceInfo.getTimeSeries();
TimeSeries maeSeries = timeSeriesMap.get(CHANNEL_METRIC_MAE);
assertNotNull(maeSeries);
assertEquals(0, maeSeries.size());
return priceInfo;
}
@Test
void testMapeValue() {
PriceInfo priceInfo = createPriceInfo(new EnergyForecastConfiguration(), new VolatileStorage<>());
priceInfo.newPriceSeries(readFile("src/test/resources/2026-02-27-response.json"));
Map<String, TimeSeries> timeSeriesMap = priceInfo.getTimeSeries();
TimeSeries mapeSeries = timeSeriesMap.get(CHANNEL_METRIC_MAPE);
assertNotNull(mapeSeries);
assertEquals(1, mapeSeries.size());
double expectedMape = 12.94562;
mapeSeries.getStates().forEach(entry -> {
assertEquals(expectedMape, ((QuantityType<?>) entry.state()).doubleValue(), 0.0001,
"MAPE value should match expected value");
});
}
@Test
void testStorage() {
VolatileStorage<String> store = new VolatileStorage<>();
createPriceInfo(new EnergyForecastConfiguration(), store);
String storeString = store.get(CHANNEL_METRIC_FORECAST);
JSONObject storedForecast = new JSONObject(storeString);
assertEquals(23, storedForecast.length(), "Stored forecast should have 23 entries");
}
@Test
void testFixCost() {
double fixCost = 12.3; // ct/kWh
EnergyForecastConfiguration configNoFixCosts = new EnergyForecastConfiguration();
PriceInfo priceInfoNoFixCosts = createPriceInfo(configNoFixCosts, new VolatileStorage<>());
EnergyForecastConfiguration configWithFixCost = new EnergyForecastConfiguration();
configWithFixCost.fixCost = fixCost;
PriceInfo priceInfoWithFixCosts = createPriceInfo(configWithFixCost, new VolatileStorage<>());
Map<String, TimeSeries> timeSeriesMapNoFixCost = priceInfoNoFixCosts.getTimeSeries();
TimeSeries priceTimeSeriesNoFixCost = timeSeriesMapNoFixCost.get(CHANNEL_PRICE_SERIES);
assertNotNull(priceTimeSeriesNoFixCost);
Map<String, TimeSeries> timeSeriesMapWithFixCost = priceInfoWithFixCosts.getTimeSeries();
TimeSeries priceTimeSeriesWithFixCost = timeSeriesMapWithFixCost.get(CHANNEL_PRICE_SERIES);
assertNotNull(priceTimeSeriesWithFixCost);
Iterator<TimeSeries.Entry> noFixCostIterator = priceTimeSeriesNoFixCost.getStates().iterator();
Iterator<TimeSeries.Entry> fixCostIterator = priceTimeSeriesWithFixCost.getStates().iterator();
double euroPerKWhFixCost = fixCost / 100;
while (noFixCostIterator.hasNext() && fixCostIterator.hasNext()) {
TimeSeries.Entry noFixCostEntry = noFixCostIterator.next();
TimeSeries.Entry fixCostEntry = fixCostIterator.next();
double noFixCostPrice = ((QuantityType<?>) noFixCostEntry.state()).doubleValue();
double fixCostPrice = ((QuantityType<?>) fixCostEntry.state()).doubleValue();
assertEquals(euroPerKWhFixCost, fixCostPrice - noFixCostPrice, 0.0001,
"Price difference should be equal to fix cost");
}
}
}
@@ -0,0 +1,428 @@
[
{
"start": "2026-02-26T00:00:00.000+01:00",
"end": "2026-02-26T01:00:00.000+01:00",
"price": 0.26945,
"price_origin": "market"
},
{
"start": "2026-02-26T01:00:00.000+01:00",
"end": "2026-02-26T02:00:00.000+01:00",
"price": 0.26463,
"price_origin": "market"
},
{
"start": "2026-02-26T02:00:00.000+01:00",
"end": "2026-02-26T03:00:00.000+01:00",
"price": 0.26025,
"price_origin": "market"
},
{
"start": "2026-02-26T03:00:00.000+01:00",
"end": "2026-02-26T04:00:00.000+01:00",
"price": 0.25936,
"price_origin": "market"
},
{
"start": "2026-02-26T04:00:00.000+01:00",
"end": "2026-02-26T05:00:00.000+01:00",
"price": 0.25868,
"price_origin": "market"
},
{
"start": "2026-02-26T05:00:00.000+01:00",
"end": "2026-02-26T06:00:00.000+01:00",
"price": 0.26876,
"price_origin": "market"
},
{
"start": "2026-02-26T06:00:00.000+01:00",
"end": "2026-02-26T07:00:00.000+01:00",
"price": 0.29848,
"price_origin": "market"
},
{
"start": "2026-02-26T07:00:00.000+01:00",
"end": "2026-02-26T08:00:00.000+01:00",
"price": 0.30952,
"price_origin": "market"
},
{
"start": "2026-02-26T08:00:00.000+01:00",
"end": "2026-02-26T09:00:00.000+01:00",
"price": 0.29444,
"price_origin": "market"
},
{
"start": "2026-02-26T09:00:00.000+01:00",
"end": "2026-02-26T10:00:00.000+01:00",
"price": 0.27421,
"price_origin": "market"
},
{
"start": "2026-02-26T10:00:00.000+01:00",
"end": "2026-02-26T11:00:00.000+01:00",
"price": 0.25924,
"price_origin": "market"
},
{
"start": "2026-02-26T11:00:00.000+01:00",
"end": "2026-02-26T12:00:00.000+01:00",
"price": 0.24199,
"price_origin": "market"
},
{
"start": "2026-02-26T12:00:00.000+01:00",
"end": "2026-02-26T13:00:00.000+01:00",
"price": 0.20233,
"price_origin": "market"
},
{
"start": "2026-02-26T13:00:00.000+01:00",
"end": "2026-02-26T14:00:00.000+01:00",
"price": 0.19525,
"price_origin": "market"
},
{
"start": "2026-02-26T14:00:00.000+01:00",
"end": "2026-02-26T15:00:00.000+01:00",
"price": 0.23163,
"price_origin": "market"
},
{
"start": "2026-02-26T15:00:00.000+01:00",
"end": "2026-02-26T16:00:00.000+01:00",
"price": 0.26335,
"price_origin": "market"
},
{
"start": "2026-02-26T16:00:00.000+01:00",
"end": "2026-02-26T17:00:00.000+01:00",
"price": 0.29122,
"price_origin": "market"
},
{
"start": "2026-02-26T17:00:00.000+01:00",
"end": "2026-02-26T18:00:00.000+01:00",
"price": 0.31074,
"price_origin": "market"
},
{
"start": "2026-02-26T18:00:00.000+01:00",
"end": "2026-02-26T19:00:00.000+01:00",
"price": 0.31309,
"price_origin": "market"
},
{
"start": "2026-02-26T19:00:00.000+01:00",
"end": "2026-02-26T20:00:00.000+01:00",
"price": 0.30699,
"price_origin": "market"
},
{
"start": "2026-02-26T20:00:00.000+01:00",
"end": "2026-02-26T21:00:00.000+01:00",
"price": 0.28971,
"price_origin": "market"
},
{
"start": "2026-02-26T21:00:00.000+01:00",
"end": "2026-02-26T22:00:00.000+01:00",
"price": 0.27852,
"price_origin": "market"
},
{
"start": "2026-02-26T22:00:00.000+01:00",
"end": "2026-02-26T23:00:00.000+01:00",
"price": 0.27424,
"price_origin": "market"
},
{
"start": "2026-02-26T23:00:00.000+01:00",
"end": "2026-02-27T00:00:00.000+01:00",
"price": 0.26367,
"price_origin": "market"
},
{
"start": "2026-02-27T00:00:00.000+01:00",
"end": "2026-02-27T01:00:00.000+01:00",
"price": 0.24416,
"price_origin": "market"
},
{
"start": "2026-02-27T01:00:00.000+01:00",
"end": "2026-02-27T02:00:00.000+01:00",
"price": 0.24514,
"price_origin": "market"
},
{
"start": "2026-02-27T02:00:00.000+01:00",
"end": "2026-02-27T03:00:00.000+01:00",
"price": 0.24672,
"price_origin": "market"
},
{
"start": "2026-02-27T03:00:00.000+01:00",
"end": "2026-02-27T04:00:00.000+01:00",
"price": 0.24987,
"price_origin": "market"
},
{
"start": "2026-02-27T04:00:00.000+01:00",
"end": "2026-02-27T05:00:00.000+01:00",
"price": 0.25201,
"price_origin": "market"
},
{
"start": "2026-02-27T05:00:00.000+01:00",
"end": "2026-02-27T06:00:00.000+01:00",
"price": 0.25704,
"price_origin": "market"
},
{
"start": "2026-02-27T06:00:00.000+01:00",
"end": "2026-02-27T07:00:00.000+01:00",
"price": 0.26959,
"price_origin": "market"
},
{
"start": "2026-02-27T07:00:00.000+01:00",
"end": "2026-02-27T08:00:00.000+01:00",
"price": 0.28384,
"price_origin": "market"
},
{
"start": "2026-02-27T08:00:00.000+01:00",
"end": "2026-02-27T09:00:00.000+01:00",
"price": 0.2729,
"price_origin": "market"
},
{
"start": "2026-02-27T09:00:00.000+01:00",
"end": "2026-02-27T10:00:00.000+01:00",
"price": 0.25514,
"price_origin": "market"
},
{
"start": "2026-02-27T10:00:00.000+01:00",
"end": "2026-02-27T11:00:00.000+01:00",
"price": 0.21604,
"price_origin": "market"
},
{
"start": "2026-02-27T11:00:00.000+01:00",
"end": "2026-02-27T12:00:00.000+01:00",
"price": 0.17966,
"price_origin": "market"
},
{
"start": "2026-02-27T12:00:00.000+01:00",
"end": "2026-02-27T13:00:00.000+01:00",
"price": 0.1785,
"price_origin": "market"
},
{
"start": "2026-02-27T13:00:00.000+01:00",
"end": "2026-02-27T14:00:00.000+01:00",
"price": 0.1785,
"price_origin": "market"
},
{
"start": "2026-02-27T14:00:00.000+01:00",
"end": "2026-02-27T15:00:00.000+01:00",
"price": 0.18769,
"price_origin": "market"
},
{
"start": "2026-02-27T15:00:00.000+01:00",
"end": "2026-02-27T16:00:00.000+01:00",
"price": 0.2496,
"price_origin": "market"
},
{
"start": "2026-02-27T16:00:00.000+01:00",
"end": "2026-02-27T17:00:00.000+01:00",
"price": 0.28798,
"price_origin": "market"
},
{
"start": "2026-02-27T17:00:00.000+01:00",
"end": "2026-02-27T18:00:00.000+01:00",
"price": 0.31559,
"price_origin": "market"
},
{
"start": "2026-02-27T18:00:00.000+01:00",
"end": "2026-02-27T19:00:00.000+01:00",
"price": 0.31904,
"price_origin": "market"
},
{
"start": "2026-02-27T19:00:00.000+01:00",
"end": "2026-02-27T20:00:00.000+01:00",
"price": 0.30854,
"price_origin": "market"
},
{
"start": "2026-02-27T20:00:00.000+01:00",
"end": "2026-02-27T21:00:00.000+01:00",
"price": 0.29753,
"price_origin": "market"
},
{
"start": "2026-02-27T21:00:00.000+01:00",
"end": "2026-02-27T22:00:00.000+01:00",
"price": 0.28673,
"price_origin": "market"
},
{
"start": "2026-02-27T22:00:00.000+01:00",
"end": "2026-02-27T23:00:00.000+01:00",
"price": 0.28263,
"price_origin": "market"
},
{
"start": "2026-02-27T23:00:00.000+01:00",
"end": "2026-02-28T00:00:00.000+01:00",
"price": 0.2737,
"price_origin": "market"
},
{
"start": "2026-02-28T00:00:00.000+01:00",
"end": "2026-02-28T01:00:00.000+01:00",
"price": 0.25978,
"price_origin": "forecast"
},
{
"start": "2026-02-28T01:00:00.000+01:00",
"end": "2026-02-28T02:00:00.000+01:00",
"price": 0.2586,
"price_origin": "forecast"
},
{
"start": "2026-02-28T02:00:00.000+01:00",
"end": "2026-02-28T03:00:00.000+01:00",
"price": 0.25241,
"price_origin": "forecast"
},
{
"start": "2026-02-28T03:00:00.000+01:00",
"end": "2026-02-28T04:00:00.000+01:00",
"price": 0.25047,
"price_origin": "forecast"
},
{
"start": "2026-02-28T04:00:00.000+01:00",
"end": "2026-02-28T05:00:00.000+01:00",
"price": 0.24941,
"price_origin": "forecast"
},
{
"start": "2026-02-28T05:00:00.000+01:00",
"end": "2026-02-28T06:00:00.000+01:00",
"price": 0.25106,
"price_origin": "forecast"
},
{
"start": "2026-02-28T06:00:00.000+01:00",
"end": "2026-02-28T07:00:00.000+01:00",
"price": 0.25192,
"price_origin": "forecast"
},
{
"start": "2026-02-28T07:00:00.000+01:00",
"end": "2026-02-28T08:00:00.000+01:00",
"price": 0.25476,
"price_origin": "forecast"
},
{
"start": "2026-02-28T08:00:00.000+01:00",
"end": "2026-02-28T09:00:00.000+01:00",
"price": 0.25404,
"price_origin": "forecast"
},
{
"start": "2026-02-28T09:00:00.000+01:00",
"end": "2026-02-28T10:00:00.000+01:00",
"price": 0.24679,
"price_origin": "forecast"
},
{
"start": "2026-02-28T10:00:00.000+01:00",
"end": "2026-02-28T11:00:00.000+01:00",
"price": 0.2425,
"price_origin": "forecast"
},
{
"start": "2026-02-28T11:00:00.000+01:00",
"end": "2026-02-28T12:00:00.000+01:00",
"price": 0.23763,
"price_origin": "forecast"
},
{
"start": "2026-02-28T12:00:00.000+01:00",
"end": "2026-02-28T13:00:00.000+01:00",
"price": 0.23554,
"price_origin": "forecast"
},
{
"start": "2026-02-28T13:00:00.000+01:00",
"end": "2026-02-28T14:00:00.000+01:00",
"price": 0.2354,
"price_origin": "forecast"
},
{
"start": "2026-02-28T14:00:00.000+01:00",
"end": "2026-02-28T15:00:00.000+01:00",
"price": 0.23785,
"price_origin": "forecast"
},
{
"start": "2026-02-28T15:00:00.000+01:00",
"end": "2026-02-28T16:00:00.000+01:00",
"price": 0.24236,
"price_origin": "forecast"
},
{
"start": "2026-02-28T16:00:00.000+01:00",
"end": "2026-02-28T17:00:00.000+01:00",
"price": 0.2468,
"price_origin": "forecast"
},
{
"start": "2026-02-28T17:00:00.000+01:00",
"end": "2026-02-28T18:00:00.000+01:00",
"price": 0.25263,
"price_origin": "forecast"
},
{
"start": "2026-02-28T18:00:00.000+01:00",
"end": "2026-02-28T19:00:00.000+01:00",
"price": 0.26072,
"price_origin": "forecast"
},
{
"start": "2026-02-28T19:00:00.000+01:00",
"end": "2026-02-28T20:00:00.000+01:00",
"price": 0.26165,
"price_origin": "forecast"
},
{
"start": "2026-02-28T20:00:00.000+01:00",
"end": "2026-02-28T21:00:00.000+01:00",
"price": 0.25457,
"price_origin": "forecast"
},
{
"start": "2026-02-28T21:00:00.000+01:00",
"end": "2026-02-28T22:00:00.000+01:00",
"price": 0.24939,
"price_origin": "forecast"
},
{
"start": "2026-02-28T22:00:00.000+01:00",
"end": "2026-02-28T23:00:00.000+01:00",
"price": 0.24787,
"price_origin": "forecast"
}
]
@@ -0,0 +1,386 @@
[
{
"start": "2026-02-27T00:00:00.000+01:00",
"end": "2026-02-27T01:00:00.000+01:00",
"price": 0.24416,
"price_origin": "market"
},
{
"start": "2026-02-27T01:00:00.000+01:00",
"end": "2026-02-27T02:00:00.000+01:00",
"price": 0.24514,
"price_origin": "market"
},
{
"start": "2026-02-27T02:00:00.000+01:00",
"end": "2026-02-27T03:00:00.000+01:00",
"price": 0.24672,
"price_origin": "market"
},
{
"start": "2026-02-27T03:00:00.000+01:00",
"end": "2026-02-27T04:00:00.000+01:00",
"price": 0.24987,
"price_origin": "market"
},
{
"start": "2026-02-27T04:00:00.000+01:00",
"end": "2026-02-27T05:00:00.000+01:00",
"price": 0.25201,
"price_origin": "market"
},
{
"start": "2026-02-27T05:00:00.000+01:00",
"end": "2026-02-27T06:00:00.000+01:00",
"price": 0.25704,
"price_origin": "market"
},
{
"start": "2026-02-27T06:00:00.000+01:00",
"end": "2026-02-27T07:00:00.000+01:00",
"price": 0.26959,
"price_origin": "market"
},
{
"start": "2026-02-27T07:00:00.000+01:00",
"end": "2026-02-27T08:00:00.000+01:00",
"price": 0.28384,
"price_origin": "market"
},
{
"start": "2026-02-27T08:00:00.000+01:00",
"end": "2026-02-27T09:00:00.000+01:00",
"price": 0.2729,
"price_origin": "market"
},
{
"start": "2026-02-27T09:00:00.000+01:00",
"end": "2026-02-27T10:00:00.000+01:00",
"price": 0.25514,
"price_origin": "market"
},
{
"start": "2026-02-27T10:00:00.000+01:00",
"end": "2026-02-27T11:00:00.000+01:00",
"price": 0.21604,
"price_origin": "market"
},
{
"start": "2026-02-27T11:00:00.000+01:00",
"end": "2026-02-27T12:00:00.000+01:00",
"price": 0.17966,
"price_origin": "market"
},
{
"start": "2026-02-27T12:00:00.000+01:00",
"end": "2026-02-27T13:00:00.000+01:00",
"price": 0.1785,
"price_origin": "market"
},
{
"start": "2026-02-27T13:00:00.000+01:00",
"end": "2026-02-27T14:00:00.000+01:00",
"price": 0.1785,
"price_origin": "market"
},
{
"start": "2026-02-27T14:00:00.000+01:00",
"end": "2026-02-27T15:00:00.000+01:00",
"price": 0.18769,
"price_origin": "market"
},
{
"start": "2026-02-27T15:00:00.000+01:00",
"end": "2026-02-27T16:00:00.000+01:00",
"price": 0.2496,
"price_origin": "market"
},
{
"start": "2026-02-27T16:00:00.000+01:00",
"end": "2026-02-27T17:00:00.000+01:00",
"price": 0.28798,
"price_origin": "market"
},
{
"start": "2026-02-27T17:00:00.000+01:00",
"end": "2026-02-27T18:00:00.000+01:00",
"price": 0.31559,
"price_origin": "market"
},
{
"start": "2026-02-27T18:00:00.000+01:00",
"end": "2026-02-27T19:00:00.000+01:00",
"price": 0.31904,
"price_origin": "market"
},
{
"start": "2026-02-27T19:00:00.000+01:00",
"end": "2026-02-27T20:00:00.000+01:00",
"price": 0.30854,
"price_origin": "market"
},
{
"start": "2026-02-27T20:00:00.000+01:00",
"end": "2026-02-27T21:00:00.000+01:00",
"price": 0.29753,
"price_origin": "market"
},
{
"start": "2026-02-27T21:00:00.000+01:00",
"end": "2026-02-27T22:00:00.000+01:00",
"price": 0.28673,
"price_origin": "market"
},
{
"start": "2026-02-27T22:00:00.000+01:00",
"end": "2026-02-27T23:00:00.000+01:00",
"price": 0.28263,
"price_origin": "market"
},
{
"start": "2026-02-27T23:00:00.000+01:00",
"end": "2026-02-28T00:00:00.000+01:00",
"price": 0.2737,
"price_origin": "market"
},
{
"start": "2026-02-28T00:00:00.000+01:00",
"end": "2026-02-28T01:00:00.000+01:00",
"price": 0.27373,
"price_origin": "market"
},
{
"start": "2026-02-28T01:00:00.000+01:00",
"end": "2026-02-28T02:00:00.000+01:00",
"price": 0.2693,
"price_origin": "market"
},
{
"start": "2026-02-28T02:00:00.000+01:00",
"end": "2026-02-28T03:00:00.000+01:00",
"price": 0.26745,
"price_origin": "market"
},
{
"start": "2026-02-28T03:00:00.000+01:00",
"end": "2026-02-28T04:00:00.000+01:00",
"price": 0.26391,
"price_origin": "market"
},
{
"start": "2026-02-28T04:00:00.000+01:00",
"end": "2026-02-28T05:00:00.000+01:00",
"price": 0.26403,
"price_origin": "market"
},
{
"start": "2026-02-28T05:00:00.000+01:00",
"end": "2026-02-28T06:00:00.000+01:00",
"price": 0.26448,
"price_origin": "market"
},
{
"start": "2026-02-28T06:00:00.000+01:00",
"end": "2026-02-28T07:00:00.000+01:00",
"price": 0.26525,
"price_origin": "market"
},
{
"start": "2026-02-28T07:00:00.000+01:00",
"end": "2026-02-28T08:00:00.000+01:00",
"price": 0.2693,
"price_origin": "market"
},
{
"start": "2026-02-28T08:00:00.000+01:00",
"end": "2026-02-28T09:00:00.000+01:00",
"price": 0.24648,
"price_origin": "market"
},
{
"start": "2026-02-28T09:00:00.000+01:00",
"end": "2026-02-28T10:00:00.000+01:00",
"price": 0.19965,
"price_origin": "market"
},
{
"start": "2026-02-28T10:00:00.000+01:00",
"end": "2026-02-28T11:00:00.000+01:00",
"price": 0.1785,
"price_origin": "market"
},
{
"start": "2026-02-28T11:00:00.000+01:00",
"end": "2026-02-28T12:00:00.000+01:00",
"price": 0.17847,
"price_origin": "market"
},
{
"start": "2026-02-28T12:00:00.000+01:00",
"end": "2026-02-28T13:00:00.000+01:00",
"price": 0.1782,
"price_origin": "market"
},
{
"start": "2026-02-28T13:00:00.000+01:00",
"end": "2026-02-28T14:00:00.000+01:00",
"price": 0.17823,
"price_origin": "market"
},
{
"start": "2026-02-28T14:00:00.000+01:00",
"end": "2026-02-28T15:00:00.000+01:00",
"price": 0.1785,
"price_origin": "market"
},
{
"start": "2026-02-28T15:00:00.000+01:00",
"end": "2026-02-28T16:00:00.000+01:00",
"price": 0.19617,
"price_origin": "market"
},
{
"start": "2026-02-28T16:00:00.000+01:00",
"end": "2026-02-28T17:00:00.000+01:00",
"price": 0.25662,
"price_origin": "market"
},
{
"start": "2026-02-28T17:00:00.000+01:00",
"end": "2026-02-28T18:00:00.000+01:00",
"price": 0.28551,
"price_origin": "market"
},
{
"start": "2026-02-28T18:00:00.000+01:00",
"end": "2026-02-28T19:00:00.000+01:00",
"price": 0.29179,
"price_origin": "market"
},
{
"start": "2026-02-28T19:00:00.000+01:00",
"end": "2026-02-28T20:00:00.000+01:00",
"price": 0.27983,
"price_origin": "market"
},
{
"start": "2026-02-28T20:00:00.000+01:00",
"end": "2026-02-28T21:00:00.000+01:00",
"price": 0.26076,
"price_origin": "market"
},
{
"start": "2026-02-28T21:00:00.000+01:00",
"end": "2026-02-28T22:00:00.000+01:00",
"price": 0.25543,
"price_origin": "market"
},
{
"start": "2026-02-28T22:00:00.000+01:00",
"end": "2026-02-28T23:00:00.000+01:00",
"price": 0.25546,
"price_origin": "market"
},
{
"start": "2026-02-28T23:00:00.000+01:00",
"end": "2026-03-01T00:00:00.000+01:00",
"price": 0.2507,
"price_origin": "market"
},
{
"start": "2026-03-01T00:00:00.000+01:00",
"end": "2026-03-01T01:00:00.000+01:00",
"price": 0.24583,
"price_origin": "forecast"
},
{
"start": "2026-03-01T01:00:00.000+01:00",
"end": "2026-03-01T02:00:00.000+01:00",
"price": 0.24497,
"price_origin": "forecast"
},
{
"start": "2026-03-01T02:00:00.000+01:00",
"end": "2026-03-01T03:00:00.000+01:00",
"price": 0.24195,
"price_origin": "forecast"
},
{
"start": "2026-03-01T03:00:00.000+01:00",
"end": "2026-03-01T04:00:00.000+01:00",
"price": 0.24051,
"price_origin": "forecast"
},
{
"start": "2026-03-01T04:00:00.000+01:00",
"end": "2026-03-01T05:00:00.000+01:00",
"price": 0.24283,
"price_origin": "forecast"
},
{
"start": "2026-03-01T05:00:00.000+01:00",
"end": "2026-03-01T06:00:00.000+01:00",
"price": 0.2439,
"price_origin": "forecast"
},
{
"start": "2026-03-01T06:00:00.000+01:00",
"end": "2026-03-01T07:00:00.000+01:00",
"price": 0.24325,
"price_origin": "forecast"
},
{
"start": "2026-03-01T07:00:00.000+01:00",
"end": "2026-03-01T08:00:00.000+01:00",
"price": 0.2455,
"price_origin": "forecast"
},
{
"start": "2026-03-01T08:00:00.000+01:00",
"end": "2026-03-01T09:00:00.000+01:00",
"price": 0.24623,
"price_origin": "forecast"
},
{
"start": "2026-03-01T09:00:00.000+01:00",
"end": "2026-03-01T10:00:00.000+01:00",
"price": 0.24104,
"price_origin": "forecast"
},
{
"start": "2026-03-01T10:00:00.000+01:00",
"end": "2026-03-01T11:00:00.000+01:00",
"price": 0.23912,
"price_origin": "forecast"
},
{
"start": "2026-03-01T11:00:00.000+01:00",
"end": "2026-03-01T12:00:00.000+01:00",
"price": 0.23648,
"price_origin": "forecast"
},
{
"start": "2026-03-01T12:00:00.000+01:00",
"end": "2026-03-01T13:00:00.000+01:00",
"price": 0.23746,
"price_origin": "forecast"
},
{
"start": "2026-03-01T13:00:00.000+01:00",
"end": "2026-03-01T14:00:00.000+01:00",
"price": 0.23803,
"price_origin": "forecast"
},
{
"start": "2026-03-01T14:00:00.000+01:00",
"end": "2026-03-01T15:00:00.000+01:00",
"price": 0.24172,
"price_origin": "forecast"
},
{
"start": "2026-03-01T15:00:00.000+01:00",
"end": "2026-03-01T16:00:00.000+01:00",
"price": 0.24531,
"price_origin": "forecast"
}
]
+1
View File
@@ -147,6 +147,7 @@
<module>org.openhab.binding.emotiva</module>
<module>org.openhab.binding.energenie</module>
<module>org.openhab.binding.energidataservice</module>
<module>org.openhab.binding.energyforecast</module>
<module>org.openhab.binding.enigma2</module>
<module>org.openhab.binding.enocean</module>
<module>org.openhab.binding.enphase</module>