mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[tibber] Introduce history values (#20699)
* initial commit Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+21
@@ -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" };
|
||||
|
||||
+8
-2
@@ -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;
|
||||
}
|
||||
|
||||
+21
@@ -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();
|
||||
}
|
||||
|
||||
+315
-3
@@ -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);
|
||||
|
||||
+408
@@ -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);
|
||||
}
|
||||
}
|
||||
+221
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
+161
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
-10
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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<>();
|
||||
|
||||
Reference in New Issue
Block a user