[tibber] Introduce history values (#20699)

* initial commit

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
Bernd Weymann
2026-05-25 21:37:01 +02:00
committed by GitHub
parent fa846551ba
commit db89c29ca4
17 changed files with 1493 additions and 20 deletions
@@ -2,6 +2,7 @@
The Tibber Binding retrieves `prices` from the [Tibber API](https://developer.tibber.com).
If you have Tibber Pulse hardware, you can also use the [live group](#live-group) and [statistics group](#statistics-group).
Historical energy data (consumption, cost, and production) is available in the [history group](#history-group) for all users.
## Supported Things
@@ -124,6 +125,49 @@ All values are read-only.
| daily-production | Number:Energy | Net energy produced since midnight in kilowatt-hours |
| last-hour-production | Number:Energy | Net energy produced since last hour shift in kilowatt-hours |
### `history` group
Historical energy consumption, cost, and production delivered as time series.
All values are read-only.
The Tibber API only returns **completed** periods — for example, an annual query run in May 2026 returns data up to end of 2025.
The binding automatically fills the gap for the current incomplete period by aggregating data bottom-up:
daily entries are summed into the current week, weekly entries into the current month, and monthly entries into the current year.
These synthetic entries are transient and will be replaced by real API data once the period closes.
| Channel ID | Type | Description | Time Series |
|---------------------|-----------------|--------------------------------------|-------------|
| yearly-consumption | Number:Energy | Yearly energy consumption | yes |
| yearly-cost | Number:Currency | Yearly energy costs | yes |
| yearly-production | Number:Energy | Yearly energy production | yes |
| monthly-consumption | Number:Energy | Monthly energy consumption | yes |
| monthly-cost | Number:Currency | Monthly energy costs | yes |
| monthly-production | Number:Energy | Monthly energy production | yes |
| weekly-consumption | Number:Energy | Weekly energy consumption | yes |
| weekly-cost | Number:Currency | Weekly energy costs | yes |
| weekly-production | Number:Energy | Weekly energy production | yes |
| daily-consumption | Number:Energy | Daily energy consumption | yes |
| daily-cost | Number:Currency | Daily energy costs | yes |
| daily-production | Number:Energy | Daily energy production | yes |
#### Persistence Requirement
History channels deliver data as time series, which is not supported by the default [rrd4j](https://www.openhab.org/addons/persistence/rrd4j/) persistence.
Items linked to history channels **must** be configured with a persistence service that supports time series, such as [InfluxDB](https://www.openhab.org/addons/persistence/influxdb/) or [InMemory](https://www.openhab.org/addons/persistence/inmemory/).
Without a compatible persistence service, history data will not be stored correctly.
#### Initial Fetch on Item Link
When a new item is linked to a history channel, the binding performs an **initial fetch for one time window only** — for example, the last year for yearly channels or the last month for monthly channels.
This keeps the startup load low and avoids requesting the entire available history from the Tibber API at once.
To populate the full available history (all years, months, weeks, or days), trigger the [`fetchHistory`](#fetchhistory) Thing Action manually after linking the item.
#### Automatic Refresh
Once an item is linked, history data is refreshed automatically once per day at 01:00.
A full re-fetch for any time window can also be triggered at any time via the [`fetchHistory`](#fetchhistory) Thing Action.
## Thing Actions
Thing actions can be used to perform calculations on the currently available price information cached by the binding.
@@ -439,6 +483,32 @@ JSON Object `scheduleEntry`
}
```
### `fetchHistory`
Triggers a full history fetch for a specific time window and stores the result persistently.
Use this action to manually refresh history data, for example after the binding has been offline for an extended period.
#### Parameters
| Name | Type | Description | Required |
|--------|--------|-----------------------------------------------------|----------|
| window | String | Time window to fetch: `ANNUAL`, `MONTHLY`, `WEEKLY`, `DAILY` | yes |
#### Example
```java
rule "Tibber Fetch Full History"
when
System started // use your trigger
then
var actions = getActions("tibber","tibber:tibberapi:xyz")
actions.fetchHistory("ANNUAL")
actions.fetchHistory("MONTHLY")
actions.fetchHistory("WEEKLY")
actions.fetchHistory("DAILY")
end
```
## Full Example
Full example with `demo.things` and `demo.items`
@@ -478,6 +548,19 @@ Number:Energy Tibber_API_Last_Hour_Consumption "Last Hour Consu
Number:Energy Tibber_API_Total_Production "Total Production" {channel="tibber:tibberapi:xyz:statistics#total-production"}
Number:Energy Tibber_API_Daily_Production "Daily Production" {channel="tibber:tibberapi:xyz:statistics#daily-production"}
Number:Energy Tibber_API_Last_Hour_Production "Last Hour Production" {channel="tibber:tibberapi:xyz:statistics#last-hour-production"}
Number:Energy Tibber_API_Yearly_Consumption "Yearly Consumption" {channel="tibber:tibberapi:xyz:history#yearly-consumption"}
Number:Currency Tibber_API_Yearly_Cost "Yearly Cost" {channel="tibber:tibberapi:xyz:history#yearly-cost"}
Number:Energy Tibber_API_Yearly_Production "Yearly Production" {channel="tibber:tibberapi:xyz:history#yearly-production"}
Number:Energy Tibber_API_Monthly_Consumption "Monthly Consumption" {channel="tibber:tibberapi:xyz:history#monthly-consumption"}
Number:Currency Tibber_API_Monthly_Cost "Monthly Cost" {channel="tibber:tibberapi:xyz:history#monthly-cost"}
Number:Energy Tibber_API_Monthly_Production "Monthly Production" {channel="tibber:tibberapi:xyz:history#monthly-production"}
Number:Energy Tibber_API_Weekly_Consumption "Weekly Consumption" {channel="tibber:tibberapi:xyz:history#weekly-consumption"}
Number:Currency Tibber_API_Weekly_Cost "Weekly Cost" {channel="tibber:tibberapi:xyz:history#weekly-cost"}
Number:Energy Tibber_API_Weekly_Production "Weekly Production" {channel="tibber:tibberapi:xyz:history#weekly-production"}
Number:Energy Tibber_API_History_Daily_Consumption "History Daily Consumption" {channel="tibber:tibberapi:xyz:history#daily-consumption"}
Number:Currency Tibber_API_History_Daily_Cost "History Daily Cost" {channel="tibber:tibberapi:xyz:history#daily-cost"}
Number:Energy Tibber_API_History_Daily_Production "History Daily Production" {channel="tibber:tibberapi:xyz:history#daily-production"}
```
### Rule listen to day-ahead price update
@@ -27,6 +27,7 @@ import org.openhab.core.thing.ThingTypeUID;
* @author Stian Kjoglum - Initial contribution
* @author Bernd Weymann - Enhance used constants
* @author Bernd Weymann - Add Map collection for price / cost channels
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
public class TibberBindingConstants {
@@ -44,6 +45,7 @@ public class TibberBindingConstants {
public static final String CHANNEL_GROUP_PRICE = "price";
public static final String CHANNEL_GROUP_LIVE = "live";
public static final String CHANNEL_GROUP_STATISTICS = "statistics";
public static final String CHANNEL_GROUP_HISTORY = "history";
// price channels
public static final String CHANNEL_TOTAL_PRICE = "total";
@@ -79,6 +81,20 @@ public class TibberBindingConstants {
public static final String CHANNEL_DAILY_PRODUCTION = "daily-production";
public static final String CHANNEL_LAST_HOUR_PRODUCTION = "last-hour-production";
// history channels
public static final String CHANNEL_YEARLY_CONSUMPTION = "yearly-consumption";
public static final String CHANNEL_YEARLY_COST = "yearly-cost";
public static final String CHANNEL_YEARLY_PRODUCTION = "yearly-production";
public static final String CHANNEL_MONTHLY_CONSUMPTION = "monthly-consumption";
public static final String CHANNEL_MONTHLY_COST = "monthly-cost";
public static final String CHANNEL_MONTHLY_PRODUCTION = "monthly-production";
public static final String CHANNEL_WEEKLY_CONSUMPTION = "weekly-consumption";
public static final String CHANNEL_WEEKLY_COST = "weekly-cost";
public static final String CHANNEL_WEEKLY_PRODUCTION = "weekly-production";
public static final String CHANNEL_HISTORY_DAILY_CONSUMPTION = "daily-consumption";
public static final String CHANNEL_HISTORY_DAILY_COST = "daily-cost";
public static final String CHANNEL_HISTORY_DAILY_PRODUCTION = "daily-production";
// List of all events
public static final String EVENT_DAY_AHEAD_AVAILABLE = "DAY_AHEAD_AVAILABLE";
@@ -94,6 +110,8 @@ public class TibberBindingConstants {
public static final String PRICE_QUERY_RESOURCE_PATH = "/graphql/prices.graphql";
public static final String REALTIME_QUERY_RESOURCE_PATH = "/graphql/realtime.graphql";
public static final String WEBSOCKET_SUBSCRIPTION_RESOURCE_PATH = "/graphql/websocket.graphql";
public static final String CONSUMPTION_QUERY_RESOURCE_PATH = "/graphql/consumption.graphql";
public static final String PRODUCTION_QUERY_RESOURCE_PATH = "/graphql/production.graphql";
public static final String SCHEDULE_CONTAINER = "{\"size\":%s, \"cost\":%s, \"schedule\":%s}";
public static final String QUERY_CONTAINER = "{\"query\":\"%s\"}";
@@ -102,6 +120,9 @@ public class TibberBindingConstants {
public static final String DISCONNECT_MESSAGE = "{\"type\":\"connection_terminate\",\"payload\":null}";
public static final String SUBSCRIPTION_MESSAGE = "{\"id\":\"1\",\"type\":\"subscribe\",\"payload\":{\"variables\":{},\"extensions\":{},\"operationName\":null,\"query\":\"subscription { liveMeasurement(homeId:\\\"%s\\\") { timestamp power lastMeterConsumption lastMeterProduction accumulatedConsumption accumulatedConsumptionLastHour accumulatedCost currency minPower averagePower maxPower voltagePhase1 voltagePhase2 voltagePhase3 currentL1 currentL2 currentL3 powerProduction accumulatedProduction accumulatedProductionLastHour minPowerProduction maxPowerProduction }}\"}}";
public static final String[] HISTORY_CONSUMPTION_JSON_PATH = { "data", "viewer", "home", "consumption" };
public static final String[] HISTORY_PRODUCTION_JSON_PATH = { "data", "viewer", "home", "production" };
public static final String[] INITIAL_QUERY_JSON_PATH = new String[] { "data", "viewer", "home" };
public static final String[] PRICE_INFO_JSON_PATH = new String[] { "data", "viewer", "home", "currentSubscription",
"priceInfo" };
@@ -20,6 +20,7 @@ import org.openhab.binding.tibber.internal.handler.TibberHandler;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.scheduler.CronScheduler;
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;
@@ -35,6 +36,8 @@ import org.osgi.service.component.annotations.Reference;
*
* @author Stian Kjoglum - Initial contribution
* @author Bernd Weymann - Use HttpClientFactory, CronScheduler and TimeZoneProvider
* @author Bernd Weymann - Add StorageService for history persistence
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
@Component(configurationPid = "binding.tibber", service = ThingHandlerFactory.class)
@@ -42,13 +45,15 @@ public class TibberHandlerFactory extends BaseThingHandlerFactory {
private final HttpClientFactory httpFactory;
private final CronScheduler cron;
private final TimeZoneProvider timeZoneProvider;
private final StorageService storageService;
@Activate
public TibberHandlerFactory(final @Reference HttpClientFactory httpFactory, final @Reference CronScheduler cron,
final @Reference TimeZoneProvider timeZoneProvider) {
final @Reference TimeZoneProvider timeZoneProvider, final @Reference StorageService storageService) {
this.httpFactory = httpFactory;
this.cron = cron;
this.timeZoneProvider = timeZoneProvider;
this.storageService = storageService;
}
@Override
@@ -60,7 +65,8 @@ public class TibberHandlerFactory extends BaseThingHandlerFactory {
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(TIBBER_THING_TYPE)) {
return new TibberHandler(thing, httpFactory.getCommonHttpClient(), cron, bundleContext, timeZoneProvider);
return new TibberHandler(thing, httpFactory.getCommonHttpClient(), cron, bundleContext, timeZoneProvider,
storageService);
} else {
return null;
}
@@ -30,6 +30,7 @@ import org.openhab.binding.tibber.internal.dto.ScheduleEntry;
import org.openhab.binding.tibber.internal.exception.CalculationParameterException;
import org.openhab.binding.tibber.internal.exception.PriceCalculationException;
import org.openhab.binding.tibber.internal.handler.TibberHandler;
import org.openhab.binding.tibber.internal.history.TibberHistory;
import org.openhab.core.automation.annotation.ActionInput;
import org.openhab.core.automation.annotation.ActionOutput;
import org.openhab.core.automation.annotation.RuleAction;
@@ -219,6 +220,26 @@ public class TibberActions implements ThingActions {
}
}
@RuleAction(label = "@text/actionFetchHistoryLabel", description = "@text/actionFetchHistoryDescription")
public void fetchHistory(
@ActionInput(name = "window", label = "@text/actionInputWindowLabel", type = "java.lang.String") String window) {
TibberHandler thingHandler = this.thingHandler;
if (thingHandler == null) {
logger.warn("No Thing attached to Actions! Maybe OFFLINE or Thing deactivated.");
return;
}
try {
TibberHistory.TimeWindow timeWindow = TibberHistory.TimeWindow.valueOf(window.toUpperCase());
thingHandler.fetchHistory(timeWindow);
} catch (IllegalArgumentException e) {
logger.warn("Unknown history window '{}'. Valid values: ANNUAL, MONTHLY, WEEKLY, DAILY", window);
}
}
public static void fetchHistory(ThingActions actions, String window) {
((TibberActions) actions).fetchHistory(window);
}
public static Instant priceInfoStart(ThingActions actions) {
return ((TibberActions) actions).priceInfoStart();
}
@@ -21,6 +21,7 @@ import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
@@ -51,6 +52,10 @@ import org.openhab.binding.tibber.internal.action.TibberActions;
import org.openhab.binding.tibber.internal.calculator.PriceCalculator;
import org.openhab.binding.tibber.internal.config.TibberConfiguration;
import org.openhab.binding.tibber.internal.exception.PriceCalculationException;
import org.openhab.binding.tibber.internal.history.TibberHistory;
import org.openhab.binding.tibber.internal.history.TibberHistoryAggregator;
import org.openhab.binding.tibber.internal.history.TibberHistoryListener;
import org.openhab.binding.tibber.internal.history.TibberHistorySeries;
import org.openhab.binding.tibber.internal.websocket.TibberWebsocket;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.CoreItemFactory;
@@ -59,6 +64,7 @@ import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.CurrencyUnits;
import org.openhab.core.scheduler.CronScheduler;
import org.openhab.core.scheduler.ScheduledCompletableFuture;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
@@ -91,9 +97,11 @@ import com.google.gson.JsonSyntaxException;
* @author Stian Kjoglum - Initial contribution
* @author Bernd Weymann - Use common HttpClient, rework of Nullable fields
* @author Bernd Weymann - Rework initialization
* @author Bernd Weymann - Add history support
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
public class TibberHandler extends BaseThingHandler {
public class TibberHandler extends BaseThingHandler implements TibberHistoryListener {
private static final int REQUEST_TIMEOUT_SEC = 10;
private final Logger logger = LoggerFactory.getLogger(TibberHandler.class);
private final Random random = new Random();
@@ -102,17 +110,20 @@ public class TibberHandler extends BaseThingHandler {
private final CronScheduler cron;
private final Map<String, String> templates = new HashMap<>();
private final TimeZoneProvider timeZoneProvider;
private final StorageService storageService;
private final ConcurrentLinkedQueue<String> messageQueue = new ConcurrentLinkedQueue<>();
private TibberConfiguration tibberConfig = new TibberConfiguration();
private @Nullable ScheduledFuture<?> watchdog;
private @Nullable ScheduledCompletableFuture<?> cronDaily;
private @Nullable ScheduledCompletableFuture<?> cronHistory;
private @Nullable TibberWebsocket webSocket;
private @Nullable Boolean realtimeEnabled;
private @Nullable String currencyUnit;
private Object calculatorLock = new Object();
private int retryCounter = 0;
private boolean isDisposed = true;
private @Nullable TibberHistory history;
private TimeSeries priceCache = new TimeSeries(TimeSeries.Policy.REPLACE);
private TimeSeries energyCache = new TimeSeries(TimeSeries.Policy.REPLACE);
@@ -123,12 +134,13 @@ public class TibberHandler extends BaseThingHandler {
protected @Nullable PriceCalculator calculator;
public TibberHandler(Thing thing, HttpClient httpClient, CronScheduler cron, BundleContext bundleContext,
TimeZoneProvider timeZoneProvider) {
TimeZoneProvider timeZoneProvider, StorageService storageService) {
super(thing);
this.httpClient = httpClient;
this.cron = cron;
this.bundleContext = bundleContext;
this.timeZoneProvider = timeZoneProvider;
this.storageService = storageService;
}
@Override
@@ -160,6 +172,34 @@ public class TibberHandler extends BaseThingHandler {
averageCache);
break;
}
} else if (CHANNEL_GROUP_HISTORY.equals(group)) {
TibberHistory localHistory = this.history;
if (localHistory != null) {
switch (channelUID.getIdWithoutGroup()) {
case CHANNEL_YEARLY_CONSUMPTION:
case CHANNEL_YEARLY_COST:
case CHANNEL_YEARLY_PRODUCTION:
localHistory.updateHistory(TibberHistory.TimeWindow.ANNUAL, false);
break;
case CHANNEL_MONTHLY_CONSUMPTION:
case CHANNEL_MONTHLY_COST:
case CHANNEL_MONTHLY_PRODUCTION:
localHistory.updateHistory(TibberHistory.TimeWindow.MONTHLY, false);
break;
case CHANNEL_WEEKLY_CONSUMPTION:
case CHANNEL_WEEKLY_COST:
case CHANNEL_WEEKLY_PRODUCTION:
localHistory.updateHistory(TibberHistory.TimeWindow.WEEKLY, false);
break;
case CHANNEL_HISTORY_DAILY_CONSUMPTION:
case CHANNEL_HISTORY_DAILY_COST:
case CHANNEL_HISTORY_DAILY_PRODUCTION:
localHistory.updateHistory(TibberHistory.TimeWindow.DAILY, false);
break;
default:
logger.debug("Unhandled history channel: {}", channelUID.getIdWithoutGroup());
}
}
}
}
}
@@ -172,6 +212,9 @@ public class TibberHandler extends BaseThingHandler {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/status.configuration-error");
} else {
// Create TibberHistory here (not in constructor) to ensure StorageService OSGi injection is complete
history = new TibberHistory(storageService, tibberConfig.homeid, this);
history.dispose(false); // mark as active (default state is disposed=true)
updateStatus(ThingStatus.UNKNOWN);
scheduler.execute(this::doInitialize);
}
@@ -187,6 +230,12 @@ public class TibberHandler extends BaseThingHandler {
}
this.cronDaily = null;
ScheduledCompletableFuture<?> cronHistory = this.cronHistory;
if (cronHistory != null) {
cronHistory.cancel(true);
}
this.cronHistory = null;
ScheduledFuture<?> watchdog = this.watchdog;
if (watchdog != null) {
watchdog.cancel(true);
@@ -199,6 +248,12 @@ public class TibberHandler extends BaseThingHandler {
}
this.webSocket = null;
TibberHistory history = this.history;
if (history != null) {
history.dispose(true);
}
this.history = null;
super.dispose();
}
@@ -694,8 +749,265 @@ public class TibberHandler extends BaseThingHandler {
}
/**
* Tibber Actions
* {@link TibberHistoryListener} implementation.
* Called by {@link TibberHistory} when a time window has been fetched.
* When {@code series} is null the fetch is still in progress (status update only).
* When {@code series} is non-null the result is sent to all three history channels of the window,
* followed by a bottom-up aggregation pass to fill the current (incomplete) period gap.
*/
@Override
public void historyUpdated(TibberHistory.HistoryRequest request, @Nullable TibberHistorySeries series) {
TibberHistory.TimeWindow window = request.window();
if (series == null) {
// Fetch started — update status to show which window is loading
updateStatus(thing.getStatus(), thing.getStatusInfo().getStatusDetail(),
"@text/status.history-update [\"" + window.name() + "\"]");
return;
}
Instant start = Instant.MIN;
if (!request.fullUpdate()) {
start = Instant.now().minus(window.daysInWindow(), ChronoUnit.DAYS);
}
TibberHistory localHistory = this.history;
if (localHistory == null) {
logger.warn("historyUpdated called but history is null (disposed?)");
return;
}
sendHistorySeries(localHistory, window, start);
// After sending raw data: fill current-period gap bottom-up (non-blocking)
scheduler.execute(() -> enrichCurrentPeriods(localHistory));
// Restore status (clear the "loading" message)
updateStatus(thing.getStatus(), thing.getStatusInfo().getStatusDetail());
logger.info("History update for {} completed", window.name());
}
/**
* Sends the three history channels (consumption, cost, production) for the given window
* from the persistent store, filtered to entries at or after {@code start}.
*/
private void sendHistorySeries(TibberHistory history, TibberHistory.TimeWindow window, Instant start) {
TibberHistorySeries stored = history.getStoredSeries(window);
TimeSeries consumptionSeries = stored.getTimeSeries(start, TibberHistorySeries.PURPOSE_CONSUMPTION);
logger.debug("History {} consumption series size: {}", window, consumptionSeries.size());
if (consumptionSeries.size() > 0) {
sendTimeSeries(
new ChannelUID(thing.getUID(), CHANNEL_GROUP_HISTORY, window.channelPrefix() + "-consumption"),
consumptionSeries);
}
TimeSeries costSeries = stored.getTimeSeries(start, TibberHistorySeries.PURPOSE_COST);
logger.debug("History {} cost series size: {}", window, costSeries.size());
if (costSeries.size() > 0) {
sendTimeSeries(new ChannelUID(thing.getUID(), CHANNEL_GROUP_HISTORY, window.channelPrefix() + "-cost"),
costSeries);
}
TimeSeries productionSeries = stored.getTimeSeries(start, TibberHistorySeries.PURPOSE_PRODUCTION);
logger.debug("History {} production series size: {}", window, productionSeries.size());
if (productionSeries.size() > 0) {
sendTimeSeries(
new ChannelUID(thing.getUID(), CHANNEL_GROUP_HISTORY, window.channelPrefix() + "-production"),
productionSeries);
}
}
/**
* Performs a bottom-up aggregation pass to inject synthetic "current period" entries
* into each coarser resolution that would otherwise have a gap for the running period:
* <ol>
* <li>DAILY → WEEKLY (current ISO week so far)</li>
* <li>WEEKLY → MONTHLY (current month so far)</li>
* <li>MONTHLY → ANNUAL (current year so far)</li>
* </ol>
* Synthetic entries are transient — they are never written to the persistent store.
*/
private void enrichCurrentPeriods(TibberHistory localHistory) {
if (isDisposed) {
return;
}
TibberHistoryAggregator agg = new TibberHistoryAggregator(timeZoneProvider.getTimeZone());
enrichAndSend(agg, localHistory, TibberHistory.TimeWindow.DAILY, TibberHistory.TimeWindow.WEEKLY);
enrichAndSend(agg, localHistory, TibberHistory.TimeWindow.WEEKLY, TibberHistory.TimeWindow.MONTHLY);
enrichAndSend(agg, localHistory, TibberHistory.TimeWindow.MONTHLY, TibberHistory.TimeWindow.ANNUAL);
}
/**
* Loads the stored series for both windows, fills the current-period gap via the aggregator,
* and sends the enriched coarser series to the three history channels.
*
* @param agg the aggregator instance
* @param history the history store
* @param finer source resolution
* @param coarser target resolution (the one with the gap)
*/
private void enrichAndSend(TibberHistoryAggregator agg, TibberHistory history, TibberHistory.TimeWindow finer,
TibberHistory.TimeWindow coarser) {
TibberHistorySeries finerSeries = history.getStoredSeries(finer);
TibberHistorySeries coarserSeries = history.getStoredSeries(coarser);
TibberHistorySeries enriched = agg.fillCurrentPeriod(finer, coarser, finerSeries, coarserSeries);
if (enriched.equals(coarserSeries)) {
// No enrichment happened (identity return) — nothing to send
return;
}
// Send full enriched series (REPLACE policy ensures channels are up to date)
sendEnrichedSeries(enriched, coarser);
}
/**
* Sends all three history channels for the given window from an enriched (in-memory) series.
* Unlike {@link #sendHistorySeries}, this uses the provided series directly rather than
* reading from the persistent store, so that synthetic aggregation entries are included.
*/
private void sendEnrichedSeries(TibberHistorySeries enriched, TibberHistory.TimeWindow window) {
TimeSeries consumptionSeries = enriched.getTimeSeries(Instant.MIN, TibberHistorySeries.PURPOSE_CONSUMPTION);
if (consumptionSeries.size() > 0) {
sendTimeSeries(
new ChannelUID(thing.getUID(), CHANNEL_GROUP_HISTORY, window.channelPrefix() + "-consumption"),
consumptionSeries);
}
TimeSeries costSeries = enriched.getTimeSeries(Instant.MIN, TibberHistorySeries.PURPOSE_COST);
if (costSeries.size() > 0) {
sendTimeSeries(new ChannelUID(thing.getUID(), CHANNEL_GROUP_HISTORY, window.channelPrefix() + "-cost"),
costSeries);
}
TimeSeries productionSeries = enriched.getTimeSeries(Instant.MIN, TibberHistorySeries.PURPOSE_PRODUCTION);
if (productionSeries.size() > 0) {
sendTimeSeries(
new ChannelUID(thing.getUID(), CHANNEL_GROUP_HISTORY, window.channelPrefix() + "-production"),
productionSeries);
}
}
// -------------------------------------------------------------------------
// History: channel linking lifecycle
// -------------------------------------------------------------------------
@Override
public void channelLinked(ChannelUID channelUID) {
super.channelLinked(channelUID);
if (CHANNEL_GROUP_HISTORY.equals(channelUID.getGroupId())) {
logger.debug("History channel linked: {} — triggering initial fetch and starting cron", channelUID);
// On first link: full paginated fetch to load complete history.
// On subsequent links (store already populated): partial fetch is sufficient.
scheduler.execute(this::initialHistoryLoad);
startHistoryCron();
}
}
@Override
public void channelUnlinked(ChannelUID channelUID) {
super.channelUnlinked(channelUID);
if (CHANNEL_GROUP_HISTORY.equals(channelUID.getGroupId()) && !historyChannelsLinked()) {
logger.debug("Last history channel unlinked — stopping history cron");
stopHistoryCron();
}
}
/**
* Returns true when at least one channel in the history group is linked to an item.
*/
private boolean historyChannelsLinked() {
return getThing().getChannels().stream().map(Channel::getUID)
.filter(uid -> CHANNEL_GROUP_HISTORY.equals(uid.getGroupId())).anyMatch(this::isLinked);
}
/**
* Starts the daily cron job for history updates if not already running.
* Only schedules if at least one history channel is linked.
*/
private void startHistoryCron() {
if (this.cronHistory != null || !historyChannelsLinked()) {
return;
}
// Run daily at 01:00 (offset from spot-price cron to spread API load)
cronHistory = cron.schedule(this::updateHistoryChannels, "30 0 1 ? * * *");
logger.debug("History cron scheduled");
}
/**
* Stops and clears the history cron job.
*/
private void stopHistoryCron() {
ScheduledCompletableFuture<?> cronHistory = this.cronHistory;
if (cronHistory != null) {
cronHistory.cancel(true);
}
this.cronHistory = null;
logger.debug("History cron stopped");
}
/**
* Called once when the first history channel is linked.
* Issues a full update (paginated) for windows whose persistent store is still empty,
* so the complete available history is fetched. Windows that already have stored data
* get a partial update (most recent page only) instead.
*/
private void initialHistoryLoad() {
if (isDisposed || !historyChannelsLinked()) {
return;
}
TibberHistory localHistory = this.history;
if (localHistory == null) {
logger.debug("initialHistoryLoad: history not yet initialised");
return;
}
for (TibberHistory.TimeWindow window : TibberHistory.TimeWindow.values()) {
boolean hasStoredData = !localHistory.getStoredSeries(window).isEmpty();
boolean fullUpdate = !hasStoredData;
logger.debug("initialHistoryLoad: {} → {}", window, fullUpdate ? "fullUpdate" : "partialUpdate");
localHistory.updateHistory(window, fullUpdate);
}
}
/**
* Enqueues a partial update for all four time windows.
* Called by the daily cron job.
*/
private void updateHistoryChannels() {
if (isDisposed || !historyChannelsLinked()) {
return;
}
TibberHistory localHistory = this.history;
if (localHistory == null) {
logger.debug("updateHistoryChannels: history not yet initialised");
return;
}
logger.debug("Scheduling partial history update for all windows");
for (TibberHistory.TimeWindow window : TibberHistory.TimeWindow.values()) {
localHistory.updateHistory(window, false);
}
}
/**
* Explicitly fetches (full update) a single time window.
* Called by the {@code fetchHistory} ThingAction.
*
* @param window the time window to fetch
*/
public void fetchHistory(TibberHistory.TimeWindow window) {
TibberHistory localHistory = this.history;
if (localHistory == null) {
logger.warn("fetchHistory called but history is not initialised (Thing OFFLINE?)");
return;
}
logger.info("ThingAction fetchHistory triggered for window {}", window);
localHistory.updateHistory(window, true);
}
// -------------------------------------------------------------------------
// Tibber Actions
// -------------------------------------------------------------------------
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(TibberActions.class);
@@ -0,0 +1,408 @@
/*
* 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.tibber.internal.history;
import static org.openhab.binding.tibber.internal.TibberBindingConstants.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.tibber.internal.handler.TibberHandler;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* The {@link TibberHistory} fetches historical energy data (consumption, cost and production)
* from the Tibber GraphQL API and notifies registered {@link TibberHistoryListener}s when done.
* <p>
* Each TibberHandler gets its own instance with a dedicated sequential executor so that
* multiple Tibber homes never block each other's history fetches.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
public class TibberHistory {
/**
* The {@link TimeWindow} enum defines the four time windows supported by the history feature.
* It maps Tibber API resolution names to channel prefixes, entry counts, and time spans.
*/
public enum TimeWindow {
ANNUAL() {
@Override
public String channelPrefix() {
return "yearly";
}
@Override
public int getFetchCount() {
return 1;
}
@Override
public int getFullFetchPageSize() {
return 50;
}
@Override
public int daysInWindow() {
return 365;
}
},
MONTHLY() {
@Override
public String channelPrefix() {
return "monthly";
}
@Override
public int getFetchCount() {
return 12;
}
@Override
public int getFullFetchPageSize() {
return 50;
}
@Override
public int daysInWindow() {
return getFetchCount() * 31;
}
},
WEEKLY() {
@Override
public String channelPrefix() {
return "weekly";
}
@Override
public int getFetchCount() {
return 52;
}
@Override
public int getFullFetchPageSize() {
return 100;
}
@Override
public int daysInWindow() {
return getFetchCount() * 7;
}
},
DAILY() {
@Override
public String channelPrefix() {
return "daily";
}
@Override
public int getFetchCount() {
return 31;
}
@Override
public int getFullFetchPageSize() {
return 100;
}
@Override
public int daysInWindow() {
return getFetchCount();
}
};
public String channelPrefix() {
return "";
}
public int getFetchCount() {
return 0;
}
/**
* Page size used when performing a full paginated fetch
* (see {@link TibberHistory#updateHistory(TimeWindow, boolean)} with {@code fullUpdate=true}).
* Larger than {@link #getFetchCount()} to minimise the number of API round-trips
* while staying well within Tibber API timeout limits.
*/
public int getFullFetchPageSize() {
return 50;
}
public int daysInWindow() {
return 0;
}
@Override
public String toString() {
return name();
}
}
/**
* Immutable value object that pairs a {@link TimeWindow} with its update mode.
* Using a separate record avoids storing mutable state on the {@link TimeWindow} enum singletons,
* which would be unsafe when multiple Tibber homes (multiple handler instances) issue concurrent requests.
*/
public record HistoryRequest(TimeWindow window, boolean fullUpdate) {
@Override
public String toString() {
return window.name() + " (" + (fullUpdate ? "full" : "partial") + ")";
}
}
private final Logger logger = LoggerFactory.getLogger(TibberHistory.class);
private final List<String> queryTemplates = List.of(CONSUMPTION_QUERY_RESOURCE_PATH,
PRODUCTION_QUERY_RESOURCE_PATH);
private final List<TibberHistoryListener> listeners = new ArrayList<>();
private final List<HistoryRequest> workingList = new ArrayList<>();
private final Storage<String> store;
private final TibberHandler handler;
private final String homeid;
/**
* Per-instance sequential executor. Using a per-instance pool ensures that multiple
* Tibber homes (multiple TibberHandler instances) never block each other's history fetches.
*/
private final ScheduledExecutorService historyScheduler;
private boolean disposed = true;
/**
* Creates a new TibberHistory instance.
* Must be called from {@code TibberHandler.initialize()} (not from the constructor) to
* ensure OSGi @Reference injection of StorageService has already completed.
*
* @param storageService OSGi StorageService for persistent caching of history data
* @param homeid Tibber home ID used as storage key prefix
* @param handler the owning TibberHandler; used to borrow its HTTP request builder
*/
public TibberHistory(StorageService storageService, String homeid, TibberHandler handler) {
this.handler = handler;
this.homeid = homeid;
this.store = storageService.getStorage(TibberHistory.class.getName() + "-" + homeid);
this.historyScheduler = ThreadPoolManager.getPoolBasedSequentialScheduledExecutorService("TibberHistory",
homeid);
this.listeners.add(handler);
}
/**
* Marks this instance as disposed or active.
* When disposed, the working queue is cleared and no new fetches are started.
*
* @param disposed true to dispose, false to reactivate
*/
public void dispose(boolean disposed) {
this.disposed = disposed;
if (disposed) {
synchronized (workingList) {
workingList.clear();
}
}
}
/**
* Enqueues an update for the given time window.
* If the same window is already queued the request is ignored.
*
* @param window the time window to update
* @param fullUpdate {@code true} for a full paginated fetch (replaces stored data);
* {@code false} for a partial fetch (merges into stored data)
*/
public void updateHistory(TimeWindow window, boolean fullUpdate) {
synchronized (workingList) {
boolean alreadyQueued = workingList.stream().anyMatch(r -> r.window() == window);
if (alreadyQueued) {
logger.info("{} already requested", window);
return;
}
HistoryRequest request = new HistoryRequest(window, fullUpdate);
logger.info("Queuing history request: {}", request);
workingList.add(request);
historyScheduler.execute(this::getHistory);
}
}
/**
* Returns the currently stored series for the given time window directly from the persistent store.
*
* @param window the time window
* @return the stored series (may be empty if nothing has been fetched yet)
*/
public TibberHistorySeries getStoredSeries(TimeWindow window) {
return new TibberHistorySeries(store.get(homeid + "-" + window.name()));
}
/**
* Worker method that processes the head of the working queue.
* Fetches consumption and production data for the current window via paginated GraphQL queries,
* stores the result, and notifies all listeners.
*/
private void getHistory() {
HistoryRequest localRequest;
synchronized (workingList) {
if (workingList.isEmpty()) {
logger.debug("History queue is empty — nothing to fetch");
return;
}
localRequest = workingList.get(0);
logger.info("Processing {} ({} request(s) in queue)", localRequest, workingList.size());
}
// Snapshot the request fields — no further access to workingList needed until removal
final TimeWindow window = localRequest.window();
final boolean isPaginated = localRequest.fullUpdate();
// Notify listeners that a fetch is starting (series == null signals "in progress")
listeners.forEach(listener -> listener.historyUpdated(localRequest, null));
// fullUpdate: paginate through all available history pages, starting from a fresh series.
// partialUpdate: fetch only the most recent page and merge into the existing stored series.
int pageSize = isPaginated ? window.getFullFetchPageSize() : window.getFetchCount();
TibberHistorySeries series = isPaginated ? new TibberHistorySeries(null) : getStoredSeries(window);
logger.info("Starting {} fetch for {} (pageSize={}, paginated={})", isPaginated ? "full paginated" : "partial",
window, pageSize, isPaginated);
for (String templatePath : queryTemplates) {
logger.debug("Fetching {} for template {}", window, templatePath);
String cursor = EMPTY_VALUE;
boolean hasNext = false;
int retryCounter = 0;
int pageCount = 0;
do {
String queryTemplate = handler.getTemplate(templatePath);
String query = String.format(queryTemplate, homeid, window.name(), pageSize, cursor);
String response = executeRequest(query);
if (response != null) {
retryCounter = 0; // reset on success
JsonObject jsonResponse = (JsonObject) JsonParser.parseString(response);
String[] jsonPath = CONSUMPTION_QUERY_RESOURCE_PATH.equals(templatePath)
? HISTORY_CONSUMPTION_JSON_PATH
: HISTORY_PRODUCTION_JSON_PATH;
JsonObject dataObject = getJsonObjectSafely(jsonResponse, jsonPath);
if (dataObject != null) {
JsonArray edges = dataObject.getAsJsonArray("edges");
series.addData(edges);
pageCount++;
JsonObject pageInfo = dataObject.getAsJsonObject("pageInfo");
hasNext = pageInfo.get("hasPreviousPage").getAsBoolean();
if (hasNext) {
cursor = "before: \\\"" + pageInfo.get("startCursor").getAsString() + "\\\"";
} else {
cursor = EMPTY_VALUE;
}
logger.debug("page={} hasNext={} totalFetched={}", pageCount, hasNext, series.size());
} else {
logger.warn("Unexpected response structure for template {}", templatePath);
hasNext = false;
}
} else {
retryCounter++;
hasNext = retryCounter < 3; // retry up to 3 times on null response
logger.warn("No response for template {} - retry {}/3", templatePath, retryCounter);
}
// Sleep on retry (with exponential backoff) or between paginated pages (fixed 200ms courtesy delay)
if (retryCounter > 0 || (hasNext && isPaginated)) {
int sleepMs = retryCounter > 0 ? dynamicRetryTimeMs(retryCounter) : 200;
try {
Thread.sleep(sleepMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.warn("History fetch interrupted: {}", e.getMessage());
return;
}
}
} while (hasNext && isPaginated && !disposed);
logger.info("Completed template {} for {} after {} page(s), {} total entries", templatePath, window,
pageCount, series.size());
}
// Persist the merged result
store.put(homeid + "-" + window.name(), series.toString());
// Remove from queue and notify listeners with the completed series
synchronized (workingList) {
workingList.remove(0);
int remaining = workingList.size();
logger.info("Completed {}. {} request(s) remaining in queue.", window, remaining);
}
listeners.forEach(listener -> listener.historyUpdated(localRequest, series));
}
private @Nullable String executeRequest(String body) {
Request request = handler.getRequest();
String content = String.format(QUERY_CONTAINER, body);
logger.debug("History query: {}", content);
request.content(new StringContentProvider(content, "utf-8"));
try {
ContentResponse cr = request.timeout(10, TimeUnit.SECONDS).send();
int status = cr.getStatus();
String responseBody = cr.getContentAsString();
if (status == HttpStatus.OK_200) {
logger.debug("History response ({}): {}", status, responseBody);
return responseBody;
} else {
logger.warn("History API returned HTTP {}: {}", status, responseBody);
}
} catch (Exception e) {
logger.warn("History request failed: {}", e.getMessage());
}
return null;
}
private @Nullable JsonObject getJsonObjectSafely(JsonObject root, String[] path) {
JsonObject current = root;
for (String key : path) {
if (current.has(key) && current.get(key).isJsonObject()) {
current = current.getAsJsonObject(key);
} else {
logger.warn("Expected JSON key '{}' not found in history response", key);
return null;
}
}
return current;
}
private static int dynamicRetryTimeMs(int retryCount) {
int exponentialBackoff = Math.min(1024, (int) Math.pow(2.0, retryCount));
return exponentialBackoff * 1000 + (int) (Math.random() * 1000);
}
}
@@ -0,0 +1,221 @@
/*
* 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.tibber.internal.history;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.SortedMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonObject;
/**
* The {@link TibberHistoryAggregator} fills the gap in coarser time-window series
* caused by the Tibber API only returning <em>completed</em> periods.
* <p>
* For example, an ANNUAL query executed in May 2026 returns data only up to end-2025.
* This class uses already-fetched finer-resolution data (e.g. MONTHLY) to compute
* a synthetic "year-to-date" entry for the coarser window (ANNUAL) and injects it
* into a copy of the coarser series.
* <p>
* Aggregation rules:
* <ul>
* <li>MONTHLY → ANNUAL: sum all monthly entries whose timestamp falls in the current year</li>
* <li>WEEKLY → MONTHLY: sum all weekly entries whose timestamp falls in the current month</li>
* <li>DAILY → WEEKLY: sum all daily entries whose timestamp falls in the current ISO week</li>
* </ul>
* Synthetic entries are <strong>not</strong> written back to the persistent store —
* they are transient and will be superseded by real API data once the period closes.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class TibberHistoryAggregator {
private final Logger logger = LoggerFactory.getLogger(TibberHistoryAggregator.class);
private final ZoneId zoneId;
/**
* @param zoneId timezone used to determine period boundaries (year/month/week start)
*/
public TibberHistoryAggregator(ZoneId zoneId) {
this.zoneId = zoneId;
}
/**
* Computes a synthetic entry for the current (incomplete) period of the coarser window
* by summing consumption, cost and production values from the finer-resolution series.
* The result is injected into a copy of the coarser series — the original is not mutated.
*
* @param finer the finer resolution (data source)
* @param coarser the coarser resolution (target — the one with the gap)
* @param finerSeries fetched series for the finer window
* @param coarserSeries fetched series for the coarser window
* @return enriched coarser series with a synthetic entry for the current period,
* or the original coarser series unchanged if no finer data is available
*/
public TibberHistorySeries fillCurrentPeriod(TibberHistory.TimeWindow finer, TibberHistory.TimeWindow coarser,
TibberHistorySeries finerSeries, TibberHistorySeries coarserSeries) {
if (finerSeries.isEmpty()) {
logger.debug("fillCurrentPeriod: no {} data available — skipping aggregation for {}", finer, coarser);
return coarserSeries;
}
Instant now = Instant.now();
ZonedDateTime nowZdt = now.atZone(zoneId);
// Determine the start of the current (incomplete) coarser period
Instant periodStart = currentPeriodStart(coarser, nowZdt);
// Collect all finer entries that belong to the current coarser period
SortedMap<Instant, JsonObject> candidates = finerSeries.tailMap(periodStart);
if (candidates.isEmpty()) {
logger.debug("fillCurrentPeriod: no {} entries found after {} — gap stays unfilled", finer, periodStart);
return coarserSeries;
}
// Aggregate: sum consumption, cost, production
BigDecimal totalConsumption = BigDecimal.ZERO;
BigDecimal totalCost = BigDecimal.ZERO;
BigDecimal totalProduction = BigDecimal.ZERO;
@Nullable
String consumptionUnit = null;
@Nullable
String costUnit = null;
@Nullable
String productionUnit = null;
for (Map.Entry<Instant, JsonObject> entry : candidates.entrySet()) {
// Only include entries that are strictly before now (exclude the current, possibly
// incomplete sub-period — e.g. today's partial day when aggregating DAILY→WEEKLY)
if (!entry.getKey().isBefore(now.truncatedTo(ChronoUnit.HOURS))) {
continue;
}
JsonObject data = entry.getValue();
if (data.has(TibberHistorySeries.PURPOSE_CONSUMPTION)) {
String raw = data.get(TibberHistorySeries.PURPOSE_CONSUMPTION).getAsString();
ParsedQuantity pq = parseQuantity(raw);
if (pq != null) {
totalConsumption = totalConsumption.add(pq.value);
consumptionUnit = pq.unit;
}
}
if (data.has(TibberHistorySeries.PURPOSE_COST)) {
String raw = data.get(TibberHistorySeries.PURPOSE_COST).getAsString();
ParsedQuantity pq = parseQuantity(raw);
if (pq != null) {
totalCost = totalCost.add(pq.value);
costUnit = pq.unit;
}
}
if (data.has(TibberHistorySeries.PURPOSE_PRODUCTION)) {
String raw = data.get(TibberHistorySeries.PURPOSE_PRODUCTION).getAsString();
ParsedQuantity pq = parseQuantity(raw);
if (pq != null) {
totalProduction = totalProduction.add(pq.value);
productionUnit = pq.unit;
}
}
}
// Build synthetic JsonObject (same format as TibberHistorySeries stores)
JsonObject synthetic = new JsonObject();
if (consumptionUnit != null) {
synthetic.addProperty(TibberHistorySeries.PURPOSE_CONSUMPTION,
totalConsumption.setScale(4, RoundingMode.HALF_UP) + " " + consumptionUnit);
}
if (costUnit != null) {
synthetic.addProperty(TibberHistorySeries.PURPOSE_COST,
totalCost.setScale(4, RoundingMode.HALF_UP) + " " + costUnit);
}
if (productionUnit != null) {
synthetic.addProperty(TibberHistorySeries.PURPOSE_PRODUCTION,
totalProduction.setScale(4, RoundingMode.HALF_UP) + " " + productionUnit);
}
if (synthetic.size() == 0) {
logger.debug("fillCurrentPeriod: aggregation yielded no data for {} → {}", finer, coarser);
return coarserSeries;
}
// Create enriched copy (do not mutate the stored series)
TibberHistorySeries enriched = new TibberHistorySeries(coarserSeries.toString());
enriched.put(periodStart, synthetic);
logger.debug("fillCurrentPeriod: injected synthetic {} entry at {} (consumption={} cost={} production={})",
coarser, periodStart, consumptionUnit != null ? totalConsumption + " " + consumptionUnit : "n/a",
costUnit != null ? totalCost + " " + costUnit : "n/a",
productionUnit != null ? totalProduction + " " + productionUnit : "n/a");
return enriched;
}
/**
* Returns the start {@link Instant} of the current incomplete period for the given coarser window.
* <ul>
* <li>ANNUAL → first instant of the current year</li>
* <li>MONTHLY → first instant of the current month</li>
* <li>WEEKLY → first instant of the current ISO week (Monday 00:00)</li>
* <li>DAILY → first instant of the current day (00:00)</li>
* </ul>
*/
private Instant currentPeriodStart(TibberHistory.TimeWindow coarser, ZonedDateTime now) {
return switch (coarser) {
case ANNUAL -> now.withDayOfYear(1).truncatedTo(ChronoUnit.DAYS).toInstant();
case MONTHLY -> now.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS).toInstant();
case WEEKLY -> {
int dayOfWeek = now.getDayOfWeek().getValue(); // 1=Mon … 7=Sun
yield now.minusDays(dayOfWeek - 1).truncatedTo(ChronoUnit.DAYS).toInstant();
}
case DAILY -> now.truncatedTo(ChronoUnit.DAYS).toInstant();
};
}
/**
* Parses a openHAB QuantityType string such as {@code "12.345 kWh"} or {@code "3.21 EUR"}.
* Returns null if the string cannot be parsed.
*/
private @Nullable ParsedQuantity parseQuantity(String raw) {
int lastSpace = raw.lastIndexOf(' ');
if (lastSpace <= 0 || lastSpace >= raw.length() - 1) {
logger.warn("Cannot parse quantity string: '{}'", raw);
return null;
}
try {
BigDecimal value = new BigDecimal(raw.substring(0, lastSpace).trim());
String unit = raw.substring(lastSpace + 1).trim();
return new ParsedQuantity(value, unit);
} catch (NumberFormatException e) {
logger.warn("Cannot parse numeric part of quantity '{}': {}", raw, e.getMessage());
return null;
}
}
/** Simple value holder for a parsed quantity. */
private static final class ParsedQuantity {
final BigDecimal value;
final String unit;
ParsedQuantity(BigDecimal value, String unit) {
this.value = value;
this.unit = unit;
}
}
}
@@ -0,0 +1,33 @@
/*
* 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.tibber.internal.history;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link TibberHistoryListener} is notified when history data has been fetched from Tibber API.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
public interface TibberHistoryListener {
/**
* Called when history data for a given time window has been updated.
*
* @param request the history request (time window + full/partial flag)
* @param series the updated series, or null if the fetch is still in progress
*/
void historyUpdated(TibberHistory.HistoryRequest request, @Nullable TibberHistorySeries series);
}
@@ -0,0 +1,161 @@
/*
* 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.tibber.internal.history;
import java.time.Instant;
import java.util.SortedMap;
import java.util.TreeMap;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.CurrencyUnits;
import org.openhab.core.types.State;
import org.openhab.core.types.TimeSeries;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
/**
* The {@link TibberHistorySeries} provides storage for energy consumption, cost and production.
* It is a {@link TreeMap} keyed by {@link Instant} containing merged data entries from both
* the consumption and production GraphQL queries.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
public class TibberHistorySeries extends TreeMap<Instant, JsonObject> {
private static final long serialVersionUID = -6635491873715495757L;
private static final Gson GSON = new Gson();
private final Logger logger = LoggerFactory.getLogger(TibberHistorySeries.class);
public static final String DATE_TIME = "dateTime";
public static final String DATA = "data";
public static final String PURPOSE_CONSUMPTION = "consumption";
public static final String PURPOSE_COST = "cost";
public static final String PURPOSE_PRODUCTION = "production";
/**
* Creates a new TibberHistorySeries, optionally pre-populated from a stored JSON string.
*
* @param storedData JSON string previously produced by {@link #toString()}, or null for an empty series
*/
public TibberHistorySeries(@Nullable String storedData) {
if (storedData != null) {
try {
JsonArray storedArray = (JsonArray) JsonParser.parseString(storedData);
storedArray.forEach(entry -> {
Instant key = Instant.parse(entry.getAsJsonObject().get(DATE_TIME).getAsString());
JsonObject value = entry.getAsJsonObject().get(DATA).getAsJsonObject();
put(key, value);
});
} catch (JsonSyntaxException jse) {
logger.warn("Couldn't parse stored history data: {}", storedData);
}
}
}
/**
* Merges a GraphQL edges array into this series.
* Each edge node may carry consumption, cost and/or production fields.
*
* @param data the {@code edges} JsonArray from the GraphQL response
*/
public void addData(JsonArray data) {
data.forEach(element -> {
try {
JsonObject entry = element.getAsJsonObject();
JsonObject arrayEntry = entry.get("node").getAsJsonObject();
String startTime = arrayEntry.get("from").getAsString();
Instant key = Instant.parse(startTime);
JsonObject historyDataElement = get(key);
if (historyDataElement == null) {
historyDataElement = new JsonObject();
}
if (arrayEntry.has(PURPOSE_CONSUMPTION) && !arrayEntry.get(PURPOSE_CONSUMPTION).isJsonNull()) {
QuantityType<?> consumptionState = QuantityType
.valueOf(arrayEntry.get(PURPOSE_CONSUMPTION).getAsString() + " "
+ arrayEntry.get("consumptionUnit").getAsString());
historyDataElement.addProperty(PURPOSE_CONSUMPTION, consumptionState.toFullString());
}
if (arrayEntry.has(PURPOSE_COST) && !arrayEntry.get(PURPOSE_COST).isJsonNull()) {
State costState;
Unit<?> currencyUnit = CurrencyUnits.getInstance()
.getUnit(arrayEntry.get("currency").getAsString());
if (currencyUnit != null) {
costState = QuantityType.valueOf(
arrayEntry.get("cost").getAsString() + " " + arrayEntry.get("currency").getAsString());
} else {
costState = DecimalType.valueOf(arrayEntry.get("cost").getAsString());
}
historyDataElement.addProperty(PURPOSE_COST, costState.toFullString());
}
if (arrayEntry.has(PURPOSE_PRODUCTION) && !arrayEntry.get(PURPOSE_PRODUCTION).isJsonNull()) {
QuantityType<?> productionState = QuantityType
.valueOf(arrayEntry.get(PURPOSE_PRODUCTION).getAsString() + " "
+ arrayEntry.get("productionUnit").getAsString());
historyDataElement.addProperty(PURPOSE_PRODUCTION, productionState.toFullString());
}
put(key, historyDataElement);
} catch (RuntimeException e) {
logger.warn("Skipping history entry due to parse error: {} — entry: {}", e.getMessage(), element);
}
});
}
/**
* Returns a {@link TimeSeries} for the given purpose, containing only entries at or after {@code start}.
*
* @param start earliest timestamp to include (use {@link Instant#MIN} for all entries)
* @param purpose one of {@link #PURPOSE_CONSUMPTION}, {@link #PURPOSE_COST}, {@link #PURPOSE_PRODUCTION}
* @return a TimeSeries with REPLACE policy
*/
public TimeSeries getTimeSeries(Instant start, String purpose) {
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
// Defensive copy of the live-backed tailMap view to avoid ConcurrentModificationException
// if another thread merges new data into this series while we iterate.
SortedMap<Instant, JsonObject> snapshot = new TreeMap<>(this.tailMap(start));
snapshot.forEach((time, historyElement) -> {
if (historyElement.has(purpose)) {
series.add(time, QuantityType.valueOf(historyElement.get(purpose).getAsString()));
}
});
return series;
}
/**
* Serialises this series to a JSON string that can be stored and later restored
* via {@link #TibberHistorySeries(String)}.
*/
@Override
public String toString() {
JsonArray array = new JsonArray();
forEach((time, historyElement) -> {
JsonObject elem = new JsonObject();
elem.addProperty(DATE_TIME, time.toString());
elem.add(DATA, historyElement);
array.add(elem);
});
return GSON.toJson(array);
}
}
@@ -19,6 +19,32 @@ thing-type.config.tibber.tibberapi.updateHour.description = Local hour when spot
# channel group types
channel-group-type.tibber.history.label = History
channel-group-type.tibber.history.description = History for consumption, cost and production
channel-group-type.tibber.history.channel.daily-consumption.label = Daily Consumption
channel-group-type.tibber.history.channel.daily-consumption.description = Daily energy consumption
channel-group-type.tibber.history.channel.daily-cost.label = Daily Cost
channel-group-type.tibber.history.channel.daily-cost.description = Daily energy costs
channel-group-type.tibber.history.channel.daily-production.label = Daily Production
channel-group-type.tibber.history.channel.daily-production.description = Daily energy production
channel-group-type.tibber.history.channel.monthly-consumption.label = Monthly Consumption
channel-group-type.tibber.history.channel.monthly-consumption.description = Monthly energy consumption
channel-group-type.tibber.history.channel.monthly-cost.label = Monthly Cost
channel-group-type.tibber.history.channel.monthly-cost.description = Monthly energy costs
channel-group-type.tibber.history.channel.monthly-production.label = Monthly Production
channel-group-type.tibber.history.channel.monthly-production.description = Monthly energy production
channel-group-type.tibber.history.channel.weekly-consumption.label = Weekly Consumption
channel-group-type.tibber.history.channel.weekly-consumption.description = Weekly energy consumption
channel-group-type.tibber.history.channel.weekly-cost.label = Weekly Cost
channel-group-type.tibber.history.channel.weekly-cost.description = Weekly energy costs
channel-group-type.tibber.history.channel.weekly-production.label = Weekly Production
channel-group-type.tibber.history.channel.weekly-production.description = Weekly energy production
channel-group-type.tibber.history.channel.yearly-consumption.label = Yearly Consumption
channel-group-type.tibber.history.channel.yearly-consumption.description = Yearly energy consumption
channel-group-type.tibber.history.channel.yearly-cost.label = Yearly Cost
channel-group-type.tibber.history.channel.yearly-cost.description = Yearly energy costs
channel-group-type.tibber.history.channel.yearly-production.label = Yearly Production
channel-group-type.tibber.history.channel.yearly-production.description = Yearly energy production
channel-group-type.tibber.live.label = Live
channel-group-type.tibber.live.description = Live values from Tibber Pulse
channel-group-type.tibber.live.channel.average-consumption.label = Average Consumption
@@ -107,12 +133,14 @@ channel-type.tibber.price.description = Total price including taxes
channel-type.tibber.voltage.label = Voltage
channel-type.tibber.voltage.description = Voltage on the given phase
# channel group types
# things status details
channel-group-type.tibber.price.channel.spot-prices.label = Spot Prices
channel-group-type.tibber.price.channel.spot-prices.description = Spot prices for today and tomorrow
status.configuration-error = Either token or homeId missing
status.initial-call-failed = Initial call failed with status {0}
status.price-update-failed = Price update failed {0}, retry initiated
status.history-update = Fetching history for {0}
# action types
# action types (referenced via @text/ in TibberActions annotations)
actionPriceInfoStartLabel = Price Info Start
actionPriceInfoStartDescription = Earliest available price information
@@ -124,11 +152,8 @@ actionBestPricePeriodLabel = Best Price Period
actionBestPricePeriodDescription = Calculate best price for consecutive period
actionBestPriceScheduleLabel = Best Price Schedule
actionBestPriceScheduleDescription = Calculate best price non-consecutive periods as schedule
actionFetchHistoryLabel = Fetch History
actionFetchHistoryDescription = Triggers a full history fetch for the given time window (ANNUAL, MONTHLY, WEEKLY, DAILY) and stores the result persistently.
actionInputParametersLabel = Java Map or JSON encoded parameters
actionInputWindowLabel = Time window to fetch - one of: ANNUAL, MONTHLY, WEEKLY, DAILY
actionOutputResultLabel = JSON encoded result
# things status details
status.configuration-error = Either token or homeId missing
status.initial-call-failed = Initial call failed with status {0}
status.price-update-failed = Price update failed {0}, retry initiated
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="tibber"
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">
<channel-group-type id="history">
<label>History</label>
<description>History for consumption, cost and production</description>
<channels>
<channel id="yearly-consumption" typeId="energy">
<label>Yearly Consumption</label>
<description>Yearly energy consumption</description>
</channel>
<channel id="yearly-cost" typeId="cost">
<label>Yearly Cost</label>
<description>Yearly energy costs</description>
</channel>
<channel id="yearly-production" typeId="energy">
<label>Yearly Production</label>
<description>Yearly energy production</description>
</channel>
<channel id="monthly-consumption" typeId="energy">
<label>Monthly Consumption</label>
<description>Monthly energy consumption</description>
</channel>
<channel id="monthly-cost" typeId="cost">
<label>Monthly Cost</label>
<description>Monthly energy costs</description>
</channel>
<channel id="monthly-production" typeId="energy">
<label>Monthly Production</label>
<description>Monthly energy production</description>
</channel>
<channel id="weekly-consumption" typeId="energy">
<label>Weekly Consumption</label>
<description>Weekly energy consumption</description>
</channel>
<channel id="weekly-cost" typeId="cost">
<label>Weekly Cost</label>
<description>Weekly energy costs</description>
</channel>
<channel id="weekly-production" typeId="energy">
<label>Weekly Production</label>
<description>Weekly energy production</description>
</channel>
<channel id="daily-consumption" typeId="energy">
<label>Daily Consumption</label>
<description>Daily energy consumption</description>
</channel>
<channel id="daily-cost" typeId="cost">
<label>Daily Cost</label>
<description>Daily energy costs</description>
</channel>
<channel id="daily-production" typeId="energy">
<label>Daily Production</label>
<description>Daily energy production</description>
</channel>
</channels>
</channel-group-type>
</thing:thing-descriptions>
@@ -12,11 +12,12 @@
<channel-group id="price" typeId="price"/>
<channel-group id="live" typeId="live"/>
<channel-group id="statistics" typeId="statistics"/>
<channel-group id="history" typeId="history"/>
</channel-groups>
<properties>
<property name="vendor">Tibber</property>
<property name="thingTypeVersion">7</property>
<property name="thingTypeVersion">8</property>
</properties>
<config-description>
@@ -214,6 +214,69 @@
<type>tibber:event</type>
</add-channel>
</instruction-set>
<instruction-set targetVersion="8">
<add-channel id="yearly-consumption" groupIds="history">
<type>tibber:energy</type>
<label>Yearly Consumption</label>
<description>Yearly energy consumption</description>
</add-channel>
<add-channel id="yearly-cost" groupIds="history">
<type>tibber:cost</type>
<label>Yearly Cost</label>
<description>Yearly energy costs</description>
</add-channel>
<add-channel id="yearly-production" groupIds="history">
<type>tibber:energy</type>
<label>Yearly Production</label>
<description>Yearly energy production</description>
</add-channel>
<add-channel id="monthly-consumption" groupIds="history">
<type>tibber:energy</type>
<label>Monthly Consumption</label>
<description>Monthly energy consumption</description>
</add-channel>
<add-channel id="monthly-cost" groupIds="history">
<type>tibber:cost</type>
<label>Monthly Cost</label>
<description>Monthly energy costs</description>
</add-channel>
<add-channel id="monthly-production" groupIds="history">
<type>tibber:energy</type>
<label>Monthly Production</label>
<description>Monthly energy production</description>
</add-channel>
<add-channel id="weekly-consumption" groupIds="history">
<type>tibber:energy</type>
<label>Weekly Consumption</label>
<description>Weekly energy consumption</description>
</add-channel>
<add-channel id="weekly-cost" groupIds="history">
<type>tibber:cost</type>
<label>Weekly Cost</label>
<description>Weekly energy costs</description>
</add-channel>
<add-channel id="weekly-production" groupIds="history">
<type>tibber:energy</type>
<label>Weekly Production</label>
<description>Weekly energy production</description>
</add-channel>
<add-channel id="daily-consumption" groupIds="history">
<type>tibber:energy</type>
<label>Daily Consumption</label>
<description>Daily energy consumption</description>
</add-channel>
<add-channel id="daily-cost" groupIds="history">
<type>tibber:cost</type>
<label>Daily Cost</label>
<description>Daily energy costs</description>
</add-channel>
<add-channel id="daily-production" groupIds="history">
<type>tibber:energy</type>
<label>Daily Production</label>
<description>Daily energy production</description>
</add-channel>
</instruction-set>
</thing-type>
</update:update-descriptions>
@@ -0,0 +1,28 @@
{
viewer {
home(id: \"%s\") {
consumption(
resolution: %s
last: %s
%s
) {
edges {
cursor
node {
from
to
consumption
cost
consumptionUnit
currency
}
}
pageInfo {
hasPreviousPage
startCursor
count
}
}
}
}
}
@@ -0,0 +1,26 @@
{
viewer {
home(id: \"%s\") {
production(
resolution: %s
last: %s
%s
) {
edges {
cursor
node {
from
to
production
productionUnit
}
}
pageInfo {
hasPreviousPage
startCursor
count
}
}
}
}
}
@@ -20,6 +20,7 @@ import org.openhab.binding.tibber.internal.calculator.PriceCalculator;
import org.openhab.binding.tibber.internal.handler.TibberHandler;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.scheduler.CronScheduler;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Thing;
import org.osgi.framework.BundleContext;
@@ -27,18 +28,19 @@ import org.osgi.framework.BundleContext;
* The {@link TibberHandlerMock} sets the PriceCalculator for unit testing.
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
public class TibberHandlerMock extends TibberHandler {
public TibberHandlerMock(Thing thing, HttpClient httpClient, CronScheduler cron, BundleContext bundleContext,
TimeZoneProvider timeZoneProvider) {
super(thing, httpClient, cron, bundleContext, timeZoneProvider);
TimeZoneProvider timeZoneProvider, StorageService storageService) {
super(thing, httpClient, cron, bundleContext, timeZoneProvider, storageService);
}
public TibberHandlerMock() {
super(mock(Thing.class), mock(HttpClient.class), mock(CronScheduler.class), mock(BundleContext.class),
mock(TimeZoneProvider.class));
mock(TimeZoneProvider.class), mock(StorageService.class));
}
public void setPriceCalculator(PriceCalculator calc) {
@@ -36,6 +36,7 @@ import org.openhab.binding.tibber.internal.handler.TibberHandler;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.scheduler.CronScheduler;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.internal.ThingImpl;
import org.osgi.framework.BundleContext;
@@ -44,6 +45,7 @@ import org.osgi.framework.BundleContext;
* The {@link TibberHandlerTest} checks handler initialization
*
* @author Bernd Weymann - Initial contribution
* @author Bernd Weymann - Add history channel group
*/
@NonNullByDefault
public class TibberHandlerTest {
@@ -79,7 +81,7 @@ public class TibberHandlerTest {
thing.setConfiguration(config);
TibberHandlerMock handler = new TibberHandlerMock(thing, httpClientMock, mock(CronScheduler.class),
mock(BundleContext.class), mock(TimeZoneProvider.class));
mock(BundleContext.class), mock(TimeZoneProvider.class), mock(StorageService.class));
TibberHandlerCallbackMock callback = new TibberHandlerCallbackMock();
handler.setCallback(callback);
Map<String, Object> initialResponse = new HashMap<>();