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).
|
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).
|
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
|
## Supported Things
|
||||||
|
|
||||||
@@ -124,6 +125,49 @@ All values are read-only.
|
|||||||
| daily-production | Number:Energy | Net energy produced since midnight in kilowatt-hours |
|
| 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 |
|
| 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
|
||||||
|
|
||||||
Thing actions can be used to perform calculations on the currently available price information cached by the binding.
|
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
|
||||||
|
|
||||||
Full example with `demo.things` and `demo.items`
|
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_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_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_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
|
### Rule listen to day-ahead price update
|
||||||
|
|||||||
+21
@@ -27,6 +27,7 @@ import org.openhab.core.thing.ThingTypeUID;
|
|||||||
* @author Stian Kjoglum - Initial contribution
|
* @author Stian Kjoglum - Initial contribution
|
||||||
* @author Bernd Weymann - Enhance used constants
|
* @author Bernd Weymann - Enhance used constants
|
||||||
* @author Bernd Weymann - Add Map collection for price / cost channels
|
* @author Bernd Weymann - Add Map collection for price / cost channels
|
||||||
|
* @author Bernd Weymann - Add history channel group
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class TibberBindingConstants {
|
public class TibberBindingConstants {
|
||||||
@@ -44,6 +45,7 @@ public class TibberBindingConstants {
|
|||||||
public static final String CHANNEL_GROUP_PRICE = "price";
|
public static final String CHANNEL_GROUP_PRICE = "price";
|
||||||
public static final String CHANNEL_GROUP_LIVE = "live";
|
public static final String CHANNEL_GROUP_LIVE = "live";
|
||||||
public static final String CHANNEL_GROUP_STATISTICS = "statistics";
|
public static final String CHANNEL_GROUP_STATISTICS = "statistics";
|
||||||
|
public static final String CHANNEL_GROUP_HISTORY = "history";
|
||||||
|
|
||||||
// price channels
|
// price channels
|
||||||
public static final String CHANNEL_TOTAL_PRICE = "total";
|
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_DAILY_PRODUCTION = "daily-production";
|
||||||
public static final String CHANNEL_LAST_HOUR_PRODUCTION = "last-hour-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
|
// List of all events
|
||||||
public static final String EVENT_DAY_AHEAD_AVAILABLE = "DAY_AHEAD_AVAILABLE";
|
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 PRICE_QUERY_RESOURCE_PATH = "/graphql/prices.graphql";
|
||||||
public static final String REALTIME_QUERY_RESOURCE_PATH = "/graphql/realtime.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 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 SCHEDULE_CONTAINER = "{\"size\":%s, \"cost\":%s, \"schedule\":%s}";
|
||||||
public static final String QUERY_CONTAINER = "{\"query\":\"%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 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 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[] INITIAL_QUERY_JSON_PATH = new String[] { "data", "viewer", "home" };
|
||||||
public static final String[] PRICE_INFO_JSON_PATH = new String[] { "data", "viewer", "home", "currentSubscription",
|
public static final String[] PRICE_INFO_JSON_PATH = new String[] { "data", "viewer", "home", "currentSubscription",
|
||||||
"priceInfo" };
|
"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.i18n.TimeZoneProvider;
|
||||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||||
import org.openhab.core.scheduler.CronScheduler;
|
import org.openhab.core.scheduler.CronScheduler;
|
||||||
|
import org.openhab.core.storage.StorageService;
|
||||||
import org.openhab.core.thing.Thing;
|
import org.openhab.core.thing.Thing;
|
||||||
import org.openhab.core.thing.ThingTypeUID;
|
import org.openhab.core.thing.ThingTypeUID;
|
||||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||||
@@ -35,6 +36,8 @@ import org.osgi.service.component.annotations.Reference;
|
|||||||
*
|
*
|
||||||
* @author Stian Kjoglum - Initial contribution
|
* @author Stian Kjoglum - Initial contribution
|
||||||
* @author Bernd Weymann - Use HttpClientFactory, CronScheduler and TimeZoneProvider
|
* @author Bernd Weymann - Use HttpClientFactory, CronScheduler and TimeZoneProvider
|
||||||
|
* @author Bernd Weymann - Add StorageService for history persistence
|
||||||
|
* @author Bernd Weymann - Add history channel group
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
@Component(configurationPid = "binding.tibber", service = ThingHandlerFactory.class)
|
@Component(configurationPid = "binding.tibber", service = ThingHandlerFactory.class)
|
||||||
@@ -42,13 +45,15 @@ public class TibberHandlerFactory extends BaseThingHandlerFactory {
|
|||||||
private final HttpClientFactory httpFactory;
|
private final HttpClientFactory httpFactory;
|
||||||
private final CronScheduler cron;
|
private final CronScheduler cron;
|
||||||
private final TimeZoneProvider timeZoneProvider;
|
private final TimeZoneProvider timeZoneProvider;
|
||||||
|
private final StorageService storageService;
|
||||||
|
|
||||||
@Activate
|
@Activate
|
||||||
public TibberHandlerFactory(final @Reference HttpClientFactory httpFactory, final @Reference CronScheduler cron,
|
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.httpFactory = httpFactory;
|
||||||
this.cron = cron;
|
this.cron = cron;
|
||||||
this.timeZoneProvider = timeZoneProvider;
|
this.timeZoneProvider = timeZoneProvider;
|
||||||
|
this.storageService = storageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -60,7 +65,8 @@ public class TibberHandlerFactory extends BaseThingHandlerFactory {
|
|||||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||||
if (thingTypeUID.equals(TIBBER_THING_TYPE)) {
|
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 {
|
} else {
|
||||||
return null;
|
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.CalculationParameterException;
|
||||||
import org.openhab.binding.tibber.internal.exception.PriceCalculationException;
|
import org.openhab.binding.tibber.internal.exception.PriceCalculationException;
|
||||||
import org.openhab.binding.tibber.internal.handler.TibberHandler;
|
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.ActionInput;
|
||||||
import org.openhab.core.automation.annotation.ActionOutput;
|
import org.openhab.core.automation.annotation.ActionOutput;
|
||||||
import org.openhab.core.automation.annotation.RuleAction;
|
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) {
|
public static Instant priceInfoStart(ThingActions actions) {
|
||||||
return ((TibberActions) actions).priceInfoStart();
|
return ((TibberActions) actions).priceInfoStart();
|
||||||
}
|
}
|
||||||
|
|||||||
+315
-3
@@ -21,6 +21,7 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
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.calculator.PriceCalculator;
|
||||||
import org.openhab.binding.tibber.internal.config.TibberConfiguration;
|
import org.openhab.binding.tibber.internal.config.TibberConfiguration;
|
||||||
import org.openhab.binding.tibber.internal.exception.PriceCalculationException;
|
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.binding.tibber.internal.websocket.TibberWebsocket;
|
||||||
import org.openhab.core.i18n.TimeZoneProvider;
|
import org.openhab.core.i18n.TimeZoneProvider;
|
||||||
import org.openhab.core.library.CoreItemFactory;
|
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.library.unit.CurrencyUnits;
|
||||||
import org.openhab.core.scheduler.CronScheduler;
|
import org.openhab.core.scheduler.CronScheduler;
|
||||||
import org.openhab.core.scheduler.ScheduledCompletableFuture;
|
import org.openhab.core.scheduler.ScheduledCompletableFuture;
|
||||||
|
import org.openhab.core.storage.StorageService;
|
||||||
import org.openhab.core.thing.Channel;
|
import org.openhab.core.thing.Channel;
|
||||||
import org.openhab.core.thing.ChannelUID;
|
import org.openhab.core.thing.ChannelUID;
|
||||||
import org.openhab.core.thing.Thing;
|
import org.openhab.core.thing.Thing;
|
||||||
@@ -91,9 +97,11 @@ import com.google.gson.JsonSyntaxException;
|
|||||||
* @author Stian Kjoglum - Initial contribution
|
* @author Stian Kjoglum - Initial contribution
|
||||||
* @author Bernd Weymann - Use common HttpClient, rework of Nullable fields
|
* @author Bernd Weymann - Use common HttpClient, rework of Nullable fields
|
||||||
* @author Bernd Weymann - Rework initialization
|
* @author Bernd Weymann - Rework initialization
|
||||||
|
* @author Bernd Weymann - Add history support
|
||||||
|
* @author Bernd Weymann - Add history channel group
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class TibberHandler extends BaseThingHandler {
|
public class TibberHandler extends BaseThingHandler implements TibberHistoryListener {
|
||||||
private static final int REQUEST_TIMEOUT_SEC = 10;
|
private static final int REQUEST_TIMEOUT_SEC = 10;
|
||||||
private final Logger logger = LoggerFactory.getLogger(TibberHandler.class);
|
private final Logger logger = LoggerFactory.getLogger(TibberHandler.class);
|
||||||
private final Random random = new Random();
|
private final Random random = new Random();
|
||||||
@@ -102,17 +110,20 @@ public class TibberHandler extends BaseThingHandler {
|
|||||||
private final CronScheduler cron;
|
private final CronScheduler cron;
|
||||||
private final Map<String, String> templates = new HashMap<>();
|
private final Map<String, String> templates = new HashMap<>();
|
||||||
private final TimeZoneProvider timeZoneProvider;
|
private final TimeZoneProvider timeZoneProvider;
|
||||||
|
private final StorageService storageService;
|
||||||
|
|
||||||
private final ConcurrentLinkedQueue<String> messageQueue = new ConcurrentLinkedQueue<>();
|
private final ConcurrentLinkedQueue<String> messageQueue = new ConcurrentLinkedQueue<>();
|
||||||
private TibberConfiguration tibberConfig = new TibberConfiguration();
|
private TibberConfiguration tibberConfig = new TibberConfiguration();
|
||||||
private @Nullable ScheduledFuture<?> watchdog;
|
private @Nullable ScheduledFuture<?> watchdog;
|
||||||
private @Nullable ScheduledCompletableFuture<?> cronDaily;
|
private @Nullable ScheduledCompletableFuture<?> cronDaily;
|
||||||
|
private @Nullable ScheduledCompletableFuture<?> cronHistory;
|
||||||
private @Nullable TibberWebsocket webSocket;
|
private @Nullable TibberWebsocket webSocket;
|
||||||
private @Nullable Boolean realtimeEnabled;
|
private @Nullable Boolean realtimeEnabled;
|
||||||
private @Nullable String currencyUnit;
|
private @Nullable String currencyUnit;
|
||||||
private Object calculatorLock = new Object();
|
private Object calculatorLock = new Object();
|
||||||
private int retryCounter = 0;
|
private int retryCounter = 0;
|
||||||
private boolean isDisposed = true;
|
private boolean isDisposed = true;
|
||||||
|
private @Nullable TibberHistory history;
|
||||||
|
|
||||||
private TimeSeries priceCache = new TimeSeries(TimeSeries.Policy.REPLACE);
|
private TimeSeries priceCache = new TimeSeries(TimeSeries.Policy.REPLACE);
|
||||||
private TimeSeries energyCache = 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;
|
protected @Nullable PriceCalculator calculator;
|
||||||
|
|
||||||
public TibberHandler(Thing thing, HttpClient httpClient, CronScheduler cron, BundleContext bundleContext,
|
public TibberHandler(Thing thing, HttpClient httpClient, CronScheduler cron, BundleContext bundleContext,
|
||||||
TimeZoneProvider timeZoneProvider) {
|
TimeZoneProvider timeZoneProvider, StorageService storageService) {
|
||||||
super(thing);
|
super(thing);
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
this.cron = cron;
|
this.cron = cron;
|
||||||
this.bundleContext = bundleContext;
|
this.bundleContext = bundleContext;
|
||||||
this.timeZoneProvider = timeZoneProvider;
|
this.timeZoneProvider = timeZoneProvider;
|
||||||
|
this.storageService = storageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -160,6 +172,34 @@ public class TibberHandler extends BaseThingHandler {
|
|||||||
averageCache);
|
averageCache);
|
||||||
break;
|
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,
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
"@text/status.configuration-error");
|
"@text/status.configuration-error");
|
||||||
} else {
|
} 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);
|
updateStatus(ThingStatus.UNKNOWN);
|
||||||
scheduler.execute(this::doInitialize);
|
scheduler.execute(this::doInitialize);
|
||||||
}
|
}
|
||||||
@@ -187,6 +230,12 @@ public class TibberHandler extends BaseThingHandler {
|
|||||||
}
|
}
|
||||||
this.cronDaily = null;
|
this.cronDaily = null;
|
||||||
|
|
||||||
|
ScheduledCompletableFuture<?> cronHistory = this.cronHistory;
|
||||||
|
if (cronHistory != null) {
|
||||||
|
cronHistory.cancel(true);
|
||||||
|
}
|
||||||
|
this.cronHistory = null;
|
||||||
|
|
||||||
ScheduledFuture<?> watchdog = this.watchdog;
|
ScheduledFuture<?> watchdog = this.watchdog;
|
||||||
if (watchdog != null) {
|
if (watchdog != null) {
|
||||||
watchdog.cancel(true);
|
watchdog.cancel(true);
|
||||||
@@ -199,6 +248,12 @@ public class TibberHandler extends BaseThingHandler {
|
|||||||
}
|
}
|
||||||
this.webSocket = null;
|
this.webSocket = null;
|
||||||
|
|
||||||
|
TibberHistory history = this.history;
|
||||||
|
if (history != null) {
|
||||||
|
history.dispose(true);
|
||||||
|
}
|
||||||
|
this.history = null;
|
||||||
|
|
||||||
super.dispose();
|
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
|
@Override
|
||||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||||
return Set.of(TibberActions.class);
|
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 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.label = Live
|
||||||
channel-group-type.tibber.live.description = Live values from Tibber Pulse
|
channel-group-type.tibber.live.description = Live values from Tibber Pulse
|
||||||
channel-group-type.tibber.live.channel.average-consumption.label = Average Consumption
|
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.label = Voltage
|
||||||
channel-type.tibber.voltage.description = Voltage on the given phase
|
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
|
status.configuration-error = Either token or homeId missing
|
||||||
channel-group-type.tibber.price.channel.spot-prices.description = Spot prices for today and tomorrow
|
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
|
actionPriceInfoStartLabel = Price Info Start
|
||||||
actionPriceInfoStartDescription = Earliest available price information
|
actionPriceInfoStartDescription = Earliest available price information
|
||||||
@@ -124,11 +152,8 @@ actionBestPricePeriodLabel = Best Price Period
|
|||||||
actionBestPricePeriodDescription = Calculate best price for consecutive period
|
actionBestPricePeriodDescription = Calculate best price for consecutive period
|
||||||
actionBestPriceScheduleLabel = Best Price Schedule
|
actionBestPriceScheduleLabel = Best Price Schedule
|
||||||
actionBestPriceScheduleDescription = Calculate best price non-consecutive periods as 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
|
actionInputParametersLabel = Java Map or JSON encoded parameters
|
||||||
|
actionInputWindowLabel = Time window to fetch - one of: ANNUAL, MONTHLY, WEEKLY, DAILY
|
||||||
actionOutputResultLabel = JSON encoded result
|
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="price" typeId="price"/>
|
||||||
<channel-group id="live" typeId="live"/>
|
<channel-group id="live" typeId="live"/>
|
||||||
<channel-group id="statistics" typeId="statistics"/>
|
<channel-group id="statistics" typeId="statistics"/>
|
||||||
|
<channel-group id="history" typeId="history"/>
|
||||||
</channel-groups>
|
</channel-groups>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<property name="vendor">Tibber</property>
|
<property name="vendor">Tibber</property>
|
||||||
<property name="thingTypeVersion">7</property>
|
<property name="thingTypeVersion">8</property>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<config-description>
|
<config-description>
|
||||||
|
|||||||
@@ -214,6 +214,69 @@
|
|||||||
<type>tibber:event</type>
|
<type>tibber:event</type>
|
||||||
</add-channel>
|
</add-channel>
|
||||||
</instruction-set>
|
</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>
|
</thing-type>
|
||||||
|
|
||||||
</update:update-descriptions>
|
</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.binding.tibber.internal.handler.TibberHandler;
|
||||||
import org.openhab.core.i18n.TimeZoneProvider;
|
import org.openhab.core.i18n.TimeZoneProvider;
|
||||||
import org.openhab.core.scheduler.CronScheduler;
|
import org.openhab.core.scheduler.CronScheduler;
|
||||||
|
import org.openhab.core.storage.StorageService;
|
||||||
import org.openhab.core.thing.Thing;
|
import org.openhab.core.thing.Thing;
|
||||||
import org.osgi.framework.BundleContext;
|
import org.osgi.framework.BundleContext;
|
||||||
|
|
||||||
@@ -27,18 +28,19 @@ import org.osgi.framework.BundleContext;
|
|||||||
* The {@link TibberHandlerMock} sets the PriceCalculator for unit testing.
|
* The {@link TibberHandlerMock} sets the PriceCalculator for unit testing.
|
||||||
*
|
*
|
||||||
* @author Bernd Weymann - Initial contribution
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
* @author Bernd Weymann - Add history channel group
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class TibberHandlerMock extends TibberHandler {
|
public class TibberHandlerMock extends TibberHandler {
|
||||||
|
|
||||||
public TibberHandlerMock(Thing thing, HttpClient httpClient, CronScheduler cron, BundleContext bundleContext,
|
public TibberHandlerMock(Thing thing, HttpClient httpClient, CronScheduler cron, BundleContext bundleContext,
|
||||||
TimeZoneProvider timeZoneProvider) {
|
TimeZoneProvider timeZoneProvider, StorageService storageService) {
|
||||||
super(thing, httpClient, cron, bundleContext, timeZoneProvider);
|
super(thing, httpClient, cron, bundleContext, timeZoneProvider, storageService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TibberHandlerMock() {
|
public TibberHandlerMock() {
|
||||||
super(mock(Thing.class), mock(HttpClient.class), mock(CronScheduler.class), mock(BundleContext.class),
|
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) {
|
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.config.core.Configuration;
|
||||||
import org.openhab.core.i18n.TimeZoneProvider;
|
import org.openhab.core.i18n.TimeZoneProvider;
|
||||||
import org.openhab.core.scheduler.CronScheduler;
|
import org.openhab.core.scheduler.CronScheduler;
|
||||||
|
import org.openhab.core.storage.StorageService;
|
||||||
import org.openhab.core.thing.ThingStatus;
|
import org.openhab.core.thing.ThingStatus;
|
||||||
import org.openhab.core.thing.internal.ThingImpl;
|
import org.openhab.core.thing.internal.ThingImpl;
|
||||||
import org.osgi.framework.BundleContext;
|
import org.osgi.framework.BundleContext;
|
||||||
@@ -44,6 +45,7 @@ import org.osgi.framework.BundleContext;
|
|||||||
* The {@link TibberHandlerTest} checks handler initialization
|
* The {@link TibberHandlerTest} checks handler initialization
|
||||||
*
|
*
|
||||||
* @author Bernd Weymann - Initial contribution
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
* @author Bernd Weymann - Add history channel group
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class TibberHandlerTest {
|
public class TibberHandlerTest {
|
||||||
@@ -79,7 +81,7 @@ public class TibberHandlerTest {
|
|||||||
thing.setConfiguration(config);
|
thing.setConfiguration(config);
|
||||||
|
|
||||||
TibberHandlerMock handler = new TibberHandlerMock(thing, httpClientMock, mock(CronScheduler.class),
|
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();
|
TibberHandlerCallbackMock callback = new TibberHandlerCallbackMock();
|
||||||
handler.setCallback(callback);
|
handler.setCallback(callback);
|
||||||
Map<String, Object> initialResponse = new HashMap<>();
|
Map<String, Object> initialResponse = new HashMap<>();
|
||||||
|
|||||||
Reference in New Issue
Block a user