]> git.basschouten.com Git - openhab-addons.git/commitdiff
Add CO2 emission channels (#16330)
authorJacob Laursen <jacob-github@vindvejr.dk>
Fri, 15 Mar 2024 11:26:42 +0000 (12:26 +0100)
committerGitHub <noreply@github.com>
Fri, 15 Mar 2024 11:26:42 +0000 (12:26 +0100)
Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
12 files changed:
bundles/org.openhab.binding.energidataservice/README.md
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/ApiController.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/EnergiDataServiceBindingConstants.java
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/Dataset.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/dto/CO2EmissionRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/dto/CO2EmissionRecords.java [new file with mode: 0644]
bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java
bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/i18n/energidataservice.properties
bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/thing/channel-groups.xml
bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/thing/channel-types.xml
bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/thing/thing-service.xml
bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/update/instructions.xml

index cff534c805a9b4aeaa98181f99d5d3187213688b..05e5ab6ae54fe690740eea02a5107596911cb5b9 100644 (file)
@@ -47,22 +47,24 @@ It will not impact channels, see [Electricity Tax](#electricity-tax) for further
 
 ### Channel Group `electricity`
 
-| Channel                  | Type               | Description                                                                    | Advanced |
-|--------------------------|--------------------|--------------------------------------------------------------------------------|----------|
-| spot-price               | Number:EnergyPrice | Spot price in DKK or EUR per kWh                                               | no       |
-| grid-tariff              | Number:EnergyPrice | Grid tariff in DKK per kWh. Only available when `gridCompanyGLN` is configured | no       |
-| system-tariff            | Number:EnergyPrice | System tariff in DKK per kWh                                                   | no       |
-| transmission-grid-tariff | Number:EnergyPrice | Transmission grid tariff in DKK per kWh                                        | no       |
-| electricity-tax          | Number:EnergyPrice | Electricity tax in DKK per kWh                                                 | no       |
-| reduced-electricity-tax  | Number:EnergyPrice | Reduced electricity tax in DKK per kWh. For electric heating customers only    | no       |
+| Channel                  | Type                     | Description                                                                            |
+|--------------------------|--------------------------|----------------------------------------------------------------------------------------|
+| spot-price               | Number:EnergyPrice       | Spot price in DKK or EUR per kWh                                                       |
+| grid-tariff              | Number:EnergyPrice       | Grid tariff in DKK per kWh. Only available when `gridCompanyGLN` is configured         |
+| system-tariff            | Number:EnergyPrice       | System tariff in DKK per kWh                                                           |
+| transmission-grid-tariff | Number:EnergyPrice       | Transmission grid tariff in DKK per kWh                                                | 
+| electricity-tax          | Number:EnergyPrice       | Electricity tax in DKK per kWh                                                         |
+| reduced-electricity-tax  | Number:EnergyPrice       | Reduced electricity tax in DKK per kWh. For electric heating customers only            |
+| co2-emission-prognosis   | Number:EmissionIntensity | Estimated prognosis for CO₂ emission following the day-ahead market in g/kWh           |
+| co2-emission-realtime    | Number:EmissionIntensity | Near up-to-date history for CO₂ emission from electricity consumed in Denmark in g/kWh |
 
 _Please note:_ There is no channel providing the total price.
 Instead, create a group item with `SUM` as aggregate function and add the individual price items as children.
 This has the following advantages:
 
-- Full customization possible: Freely choose the channels which should be included in the total.
-- An additional item containing the kWh fee from your electricity supplier can be added also.
-- Spot price can be configured in EUR while tariffs are in DKK.
+- Full customization possible: Freely choose the channels which should be included in the total (even between different bindings).
+- Spot price can be configured in EUR while tariffs are in DKK (and currency conversions are performed outside the binding).
+- An additional item containing the kWh fee from your electricity supplier can be added also (and it can be dynamic).
 
 If you want electricity tax included in your total price, please add either `electricity-tax` or `reduced-electricity-tax` to the group - depending on which one applies.
 See [Electricity Tax](#electricity-tax) for further information.
@@ -141,6 +143,17 @@ This reduced rate is made available through channel `reduced-electricity-tax`.
 The binding cannot determine or manage rate variations as they depend on metering data.
 Usually `reduced-electricity-tax` is preferred when using electricity for heating.
 
+#### CO₂ Emissions
+
+Data for the CO₂ emission channels is published as time series with a resolution of 5 minutes.
+
+Channel `co2-emission-realtime` provides near up-to-date historic emission and is refreshed every 5 minutes.
+When the binding is started, or a new item is linked, or a linked item receives an update command, historic data for the last 24 hours is provided in addition to the current value.
+
+Channel `co2-emission-prognosis` provides estimated prognosis for future emissions and is refreshed every 15 minutes.
+Depending on the time of the day, an update of the prognosis may include estimates for more than 9 hours, but every update will have at least 9 hours into the future.
+A persistence configuration is required for this channel.
+
 ## Thing Actions
 
 Thing actions can be used to perform calculations as well as import prices directly into rules without relying on persistence.
index 42fa0a62174f02842ccd73f202f03eeff687c68e..ee80274a59e64f3573d56f42ac5f7e7afbc56f0e 100644 (file)
@@ -38,8 +38,11 @@ import org.eclipse.jetty.http.HttpMethod;
 import org.eclipse.jetty.http.HttpStatus;
 import org.openhab.binding.energidataservice.internal.api.ChargeType;
 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
+import org.openhab.binding.energidataservice.internal.api.Dataset;
 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
+import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecord;
+import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecords;
 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecords;
 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
@@ -66,9 +69,6 @@ public class ApiController {
     private static final String ENDPOINT = "https://api.energidataservice.dk/";
     private static final String DATASET_PATH = "dataset/";
 
-    private static final String DATASET_NAME_SPOT_PRICES = "Elspotprices";
-    private static final String DATASET_NAME_DATAHUB_PRICELIST = "DatahubPricelist";
-
     private static final String FILTER_KEY_PRICE_AREA = "PriceArea";
     private static final String FILTER_KEY_CHARGE_TYPE = "ChargeType";
     private static final String FILTER_KEY_CHARGE_TYPE_CODE = "ChargeTypeCode";
@@ -111,7 +111,7 @@ public class ApiController {
             throw new IllegalArgumentException("Invalid currency " + currency.getCurrencyCode());
         }
 
-        Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_SPOT_PRICES)
+        Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + Dataset.SpotPrices)
                 .timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) //
                 .param("start", start.toString()) //
                 .param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
@@ -119,23 +119,8 @@ public class ApiController {
                 .agent(userAgent) //
                 .method(HttpMethod.GET);
 
-        logger.trace("GET request for {}", request.getURI());
-
         try {
-            ContentResponse response = request.send();
-
-            updatePropertiesFromResponse(response, properties);
-
-            int status = response.getStatus();
-            if (!HttpStatus.isSuccess(status)) {
-                throw new DataServiceException("The request failed with HTTP error " + status, status);
-            }
-            String responseContent = response.getContentAsString();
-            if (responseContent.isEmpty()) {
-                throw new DataServiceException("Empty response");
-            }
-            logger.trace("Response content: '{}'", responseContent);
-
+            String responseContent = sendRequest(request, properties);
             ElspotpriceRecords records = gson.fromJson(responseContent, ElspotpriceRecords.class);
             if (records == null) {
                 throw new DataServiceException("Error parsing response");
@@ -153,6 +138,27 @@ public class ApiController {
         }
     }
 
+    private String sendRequest(Request request, Map<String, String> properties)
+            throws TimeoutException, ExecutionException, InterruptedException, DataServiceException {
+        logger.trace("GET request for {}", request.getURI());
+
+        ContentResponse response = request.send();
+
+        updatePropertiesFromResponse(response, properties);
+
+        int status = response.getStatus();
+        if (!HttpStatus.isSuccess(status)) {
+            throw new DataServiceException("The request failed with HTTP error " + status, status);
+        }
+        String responseContent = response.getContentAsString();
+        if (responseContent.isEmpty()) {
+            throw new DataServiceException("Empty response");
+        }
+        logger.trace("Response content: '{}'", responseContent);
+
+        return responseContent;
+    }
+
     private void updatePropertiesFromResponse(ContentResponse response, Map<String, String> properties) {
         HttpFields headers = response.getHeaders();
         String remainingCalls = headers.get(HEADER_REMAINING_CALLS);
@@ -200,7 +206,7 @@ public class ApiController {
             filterMap.put(FILTER_KEY_NOTE, notes);
         }
 
-        Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_DATAHUB_PRICELIST)
+        Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + Dataset.DatahubPricelist)
                 .timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) //
                 .param("filter", mapToFilter(filterMap)) //
                 .param("columns", columns) //
@@ -212,23 +218,8 @@ public class ApiController {
             request = request.param("start", dateQueryParameter.toString());
         }
 
-        logger.trace("GET request for {}", request.getURI());
-
         try {
-            ContentResponse response = request.send();
-
-            updatePropertiesFromResponse(response, properties);
-
-            int status = response.getStatus();
-            if (!HttpStatus.isSuccess(status)) {
-                throw new DataServiceException("The request failed with HTTP error " + status, status);
-            }
-            String responseContent = response.getContentAsString();
-            if (responseContent.isEmpty()) {
-                throw new DataServiceException("Empty response");
-            }
-            logger.trace("Response content: '{}'", responseContent);
-
+            String responseContent = sendRequest(request, properties);
             DatahubPricelistRecords records = gson.fromJson(responseContent, DatahubPricelistRecords.class);
             if (records == null) {
                 throw new DataServiceException("Error parsing response");
@@ -255,4 +246,48 @@ public class ApiController {
                 e -> "\"" + e.getKey() + "\":[\"" + e.getValue().stream().collect(Collectors.joining("\",\"")) + "\"]")
                 .collect(Collectors.joining(",")) + "}";
     }
+
+    /**
+     * Retrieve CO2 emissions for requested area.
+     *
+     * @param dataset Dataset to obtain
+     * @param priceArea Usually DK1 or DK2
+     * @param start Specifies the start point of the period for the data request
+     * @param properties Map of properties which will be updated with metadata from headers
+     * @return Records with 5 minute periods and emissions in g/kWh.
+     * @throws InterruptedException
+     * @throws DataServiceException
+     */
+    public CO2EmissionRecord[] getCo2Emissions(Dataset dataset, String priceArea, DateQueryParameter start,
+            Map<String, String> properties) throws InterruptedException, DataServiceException {
+        if (dataset != Dataset.CO2Emission && dataset != Dataset.CO2EmissionPrognosis) {
+            throw new IllegalArgumentException("Invalid dataset " + dataset + " for getting CO2 emissions");
+        }
+        Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + dataset)
+                .timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS) //
+                .param("start", start.toString()) //
+                .param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
+                .param("columns", "Minutes5UTC,CO2Emission") //
+                .param("sort", "Minutes5UTC DESC") //
+                .agent(userAgent) //
+                .method(HttpMethod.GET);
+
+        try {
+            String responseContent = sendRequest(request, properties);
+            CO2EmissionRecords records = gson.fromJson(responseContent, CO2EmissionRecords.class);
+            if (records == null) {
+                throw new DataServiceException("Error parsing response");
+            }
+
+            if (records.total() == 0 || Objects.isNull(records.records()) || records.records().length == 0) {
+                throw new DataServiceException("No records");
+            }
+
+            return Arrays.stream(records.records()).filter(Objects::nonNull).toArray(CO2EmissionRecord[]::new);
+        } catch (JsonSyntaxException e) {
+            throw new DataServiceException("Error parsing response", e);
+        } catch (TimeoutException | ExecutionException e) {
+            throw new DataServiceException(e);
+        }
+    }
 }
index 4555a335b578e8d6e015b3c5d8d87ff0e10308a9..7147e82294812f1b55e8bc25fcd2167b03b9402e 100644 (file)
@@ -52,6 +52,10 @@ public class EnergiDataServiceBindingConstants {
             + ChannelUID.CHANNEL_GROUP_SEPARATOR + "reduced-electricity-tax";
     public static final String CHANNEL_TRANSMISSION_GRID_TARIFF = CHANNEL_GROUP_ELECTRICITY
             + ChannelUID.CHANNEL_GROUP_SEPARATOR + "transmission-grid-tariff";
+    public static final String CHANNEL_CO2_EMISSION_PROGNOSIS = CHANNEL_GROUP_ELECTRICITY
+            + ChannelUID.CHANNEL_GROUP_SEPARATOR + "co2-emission-prognosis";
+    public static final String CHANNEL_CO2_EMISSION_REALTIME = CHANNEL_GROUP_ELECTRICITY
+            + ChannelUID.CHANNEL_GROUP_SEPARATOR + "co2-emission-realtime";
 
     public static final Set<String> ELECTRICITY_CHANNELS = Set.of(CHANNEL_SPOT_PRICE, CHANNEL_GRID_TARIFF,
             CHANNEL_SYSTEM_TARIFF, CHANNEL_TRANSMISSION_GRID_TARIFF, CHANNEL_ELECTRICITY_TAX,
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/Dataset.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/Dataset.java
new file mode 100644 (file)
index 0000000..cd4e457
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.api;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Energi Data Service dataset.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public enum Dataset {
+    SpotPrices("Elspotprices"),
+    DatahubPricelist("DatahubPricelist"),
+    CO2Emission("CO2Emis"),
+    CO2EmissionPrognosis("CO2EmisProg");
+
+    private final String name;
+
+    Dataset(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public String toString() {
+        return name;
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/dto/CO2EmissionRecord.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/dto/CO2EmissionRecord.java
new file mode 100644 (file)
index 0000000..a555492
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.api.dto;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Record as part of {@link CO2EmissionRecords} from Energi Data Service.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public record CO2EmissionRecord(@SerializedName("Minutes5UTC") Instant start,
+        @SerializedName("CO2Emission") BigDecimal emission) {
+
+    public Instant end() {
+        return start.plus(5, ChronoUnit.MINUTES);
+    }
+}
diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/dto/CO2EmissionRecords.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/api/dto/CO2EmissionRecords.java
new file mode 100644 (file)
index 0000000..67f59c9
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.energidataservice.internal.api.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Received {@link CO2EmissionRecords} from Energi Data Service.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public record CO2EmissionRecords(int total, String filters, int limit, String dataset, CO2EmissionRecord[] records) {
+}
index 967322ad0db8b2e31e70b108d7feefd843344885..2b2baba2c55524adc324671065897fe13b1c3535 100644 (file)
@@ -47,9 +47,11 @@ import org.openhab.binding.energidataservice.internal.api.ChargeType;
 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
+import org.openhab.binding.energidataservice.internal.api.Dataset;
 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
+import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecord;
 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
@@ -61,6 +63,7 @@ import org.openhab.core.i18n.TimeZoneProvider;
 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.library.unit.Units;
 import org.openhab.core.thing.Channel;
 import org.openhab.core.thing.ChannelUID;
 import org.openhab.core.thing.Thing;
@@ -85,6 +88,9 @@ import org.slf4j.LoggerFactory;
 @NonNullByDefault
 public class EnergiDataServiceHandler extends BaseThingHandler {
 
+    private static final Duration emissionPrognosisJobInterval = Duration.ofMinutes(15);
+    private static final Duration emissionRealtimeJobInterval = Duration.ofMinutes(5);
+
     private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
     private final TimeZoneProvider timeZoneProvider;
     private final ApiController apiController;
@@ -92,7 +98,10 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
 
     private EnergiDataServiceConfiguration config;
     private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
-    private @Nullable ScheduledFuture<?> refreshFuture;
+    private boolean realtimeEmissionsFetchedFirstTime = false;
+    private @Nullable ScheduledFuture<?> refreshPriceFuture;
+    private @Nullable ScheduledFuture<?> refreshEmissionPrognosisFuture;
+    private @Nullable ScheduledFuture<?> refreshEmissionRealtimeFuture;
     private @Nullable ScheduledFuture<?> priceUpdateFuture;
 
     public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
@@ -111,8 +120,14 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
             return;
         }
 
-        if (ELECTRICITY_CHANNELS.contains(channelUID.getId())) {
+        String channelId = channelUID.getId();
+        if (ELECTRICITY_CHANNELS.contains(channelId)) {
             refreshElectricityPrices();
+        } else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) {
+            rescheduleEmissionPrognosisJob();
+        } else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) {
+            realtimeEmissionsFetchedFirstTime = false;
+            rescheduleEmissionRealtimeJob();
         }
     }
 
@@ -140,15 +155,32 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
 
         updateStatus(ThingStatus.UNKNOWN);
 
-        refreshFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
+        refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
+
+        if (isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
+            rescheduleEmissionPrognosisJob();
+        }
+        if (isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
+            rescheduleEmissionRealtimeJob();
+        }
     }
 
     @Override
     public void dispose() {
-        ScheduledFuture<?> refreshFuture = this.refreshFuture;
-        if (refreshFuture != null) {
-            refreshFuture.cancel(true);
-            this.refreshFuture = null;
+        ScheduledFuture<?> refreshPriceFuture = this.refreshPriceFuture;
+        if (refreshPriceFuture != null) {
+            refreshPriceFuture.cancel(true);
+            this.refreshPriceFuture = null;
+        }
+        ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
+        if (refreshEmissionPrognosisFuture != null) {
+            refreshEmissionPrognosisFuture.cancel(true);
+            this.refreshEmissionPrognosisFuture = null;
+        }
+        ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
+        if (refreshEmissionRealtimeFuture != null) {
+            refreshEmissionRealtimeFuture.cancel(true);
+            this.refreshEmissionRealtimeFuture = null;
         }
         ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
         if (priceUpdateFuture != null) {
@@ -164,6 +196,30 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
         return Set.of(EnergiDataServiceActions.class);
     }
 
+    @Override
+    public void channelUnlinked(ChannelUID channelUID) {
+        super.channelUnlinked(channelUID);
+
+        if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelUID.getId()) && !isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
+            logger.debug("No more items linked to channel '{}', stopping emission prognosis refresh job",
+                    channelUID.getId());
+            ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
+            if (refreshEmissionPrognosisFuture != null) {
+                refreshEmissionPrognosisFuture.cancel(true);
+                this.refreshEmissionPrognosisFuture = null;
+            }
+        } else if (CHANNEL_CO2_EMISSION_REALTIME.contains(channelUID.getId())
+                && !isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
+            logger.debug("No more items linked to channel '{}', stopping realtime emission refresh job",
+                    channelUID.getId());
+            ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
+            if (refreshEmissionRealtimeFuture != null) {
+                refreshEmissionRealtimeFuture.cancel(true);
+                this.refreshEmissionRealtimeFuture = null;
+            }
+        }
+    }
+
     private void refreshElectricityPrices() {
         RetryStrategy retryPolicy;
         try {
@@ -208,7 +264,7 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
             return;
         }
 
-        rescheduleRefreshJob(retryPolicy);
+        reschedulePriceRefreshJob(retryPolicy);
     }
 
     private void downloadSpotPrices() throws InterruptedException, DataServiceException {
@@ -299,6 +355,79 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
                 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
     }
 
+    private void refreshCo2EmissionPrognosis() {
+        try {
+            updateCo2Emissions(Dataset.CO2EmissionPrognosis, CHANNEL_CO2_EMISSION_PROGNOSIS,
+                    DateQueryParameter.of(DateQueryParameterType.UTC_NOW, Duration.ofMinutes(-5)));
+            updateStatus(ThingStatus.ONLINE);
+        } catch (DataServiceException e) {
+            if (e.getHttpStatus() != 0) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+                        HttpStatus.getCode(e.getHttpStatus()).getMessage());
+            } else {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+            }
+            if (e.getCause() != null) {
+                logger.debug("Error retrieving CO2 emission prognosis", e);
+            }
+        } catch (InterruptedException e) {
+            logger.debug("Emission prognosis refresh job interrupted");
+            Thread.currentThread().interrupt();
+            return;
+        }
+    }
+
+    private void refreshCo2EmissionRealtime() {
+        try {
+            updateCo2Emissions(Dataset.CO2Emission, CHANNEL_CO2_EMISSION_REALTIME,
+                    DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
+                            realtimeEmissionsFetchedFirstTime ? Duration.ofMinutes(-5) : Duration.ofHours(-24)));
+            realtimeEmissionsFetchedFirstTime = true;
+            updateStatus(ThingStatus.ONLINE);
+        } catch (DataServiceException e) {
+            if (e.getHttpStatus() != 0) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+                        HttpStatus.getCode(e.getHttpStatus()).getMessage());
+            } else {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+            }
+            if (e.getCause() != null) {
+                logger.debug("Error retrieving CO2 realtime emissions", e);
+            }
+        } catch (InterruptedException e) {
+            logger.debug("Emission realtime refresh job interrupted");
+            Thread.currentThread().interrupt();
+            return;
+        }
+    }
+
+    private void updateCo2Emissions(Dataset dataset, String channelId, DateQueryParameter dateQueryParameter)
+            throws InterruptedException, DataServiceException {
+        Map<String, String> properties = editProperties();
+        CO2EmissionRecord[] emissionRecords = apiController.getCo2Emissions(dataset, config.priceArea,
+                dateQueryParameter, properties);
+        updateProperties(properties);
+
+        TimeSeries timeSeries = new TimeSeries(REPLACE);
+        Instant now = Instant.now();
+
+        if (dataset == Dataset.CO2Emission && emissionRecords.length > 0) {
+            // Records are sorted descending, first record is current.
+            updateState(channelId, new QuantityType<>(emissionRecords[0].emission(), Units.GRAM_PER_KILOWATT_HOUR));
+        }
+
+        for (CO2EmissionRecord emissionRecord : emissionRecords) {
+            State state = new QuantityType<>(emissionRecord.emission(), Units.GRAM_PER_KILOWATT_HOUR);
+            timeSeries.add(emissionRecord.start(), state);
+
+            if (dataset == Dataset.CO2EmissionPrognosis && now.compareTo(emissionRecord.start()) >= 0
+                    && now.compareTo(emissionRecord.end()) < 0) {
+                updateState(channelId, state);
+            }
+        }
+        sendTimeSeries(channelId, timeSeries);
+    }
+
     private void updatePrices() {
         cacheManager.cleanup();
 
@@ -472,19 +601,19 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
     }
 
-    private void rescheduleRefreshJob(RetryStrategy retryPolicy) {
+    private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
         // Preserve state of previous retry policy when configuration is the same.
         if (!retryPolicy.equals(this.retryPolicy)) {
             this.retryPolicy = retryPolicy;
         }
 
-        ScheduledFuture<?> refreshJob = this.refreshFuture;
+        ScheduledFuture<?> refreshJob = this.refreshPriceFuture;
 
         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
-        this.refreshFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
+        this.refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
                 TimeUnit.SECONDS);
-        logger.debug("Refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
+        logger.debug("Price refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
@@ -493,4 +622,28 @@ public class EnergiDataServiceHandler extends BaseThingHandler {
             refreshJob.cancel(true);
         }
     }
+
+    private void rescheduleEmissionPrognosisJob() {
+        logger.debug("Scheduling emission prognosis refresh job now and every {}", emissionPrognosisJobInterval);
+
+        ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
+        if (refreshEmissionPrognosisFuture != null) {
+            refreshEmissionPrognosisFuture.cancel(true);
+        }
+
+        this.refreshEmissionPrognosisFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionPrognosis, 0,
+                emissionPrognosisJobInterval.toSeconds(), TimeUnit.SECONDS);
+    }
+
+    private void rescheduleEmissionRealtimeJob() {
+        logger.debug("Scheduling emission realtime refresh job now and every {}", emissionRealtimeJobInterval);
+
+        ScheduledFuture<?> refreshEmissionFuture = this.refreshEmissionRealtimeFuture;
+        if (refreshEmissionFuture != null) {
+            refreshEmissionFuture.cancel(true);
+        }
+
+        this.refreshEmissionRealtimeFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionRealtime, 0,
+                emissionRealtimeJobInterval.toSeconds(), TimeUnit.SECONDS);
+    }
 }
index f54cbd7cc0c2ba4d827fe139696a883f0230ba52..516b6752fe62bb890595d966b49cbd7f48fabab6 100644 (file)
@@ -58,6 +58,10 @@ thing-type.config.energidataservice.service.reducedElectricityTax.description =
 
 channel-group-type.energidataservice.electricity.label = Electricity
 channel-group-type.energidataservice.electricity.description = Channels related to electricity
+channel-group-type.energidataservice.electricity.channel.co2-emission-prognosis.label = CO₂ Emission Prognosis
+channel-group-type.energidataservice.electricity.channel.co2-emission-prognosis.description = Estimated prognosis for CO₂ emission following the day-ahead market in g/kWh.
+channel-group-type.energidataservice.electricity.channel.co2-emission-realtime.label = CO₂ Emission Realtime
+channel-group-type.energidataservice.electricity.channel.co2-emission-realtime.description = Near up-to-date history for CO₂ emission from electricity consumed in Denmark in g/kWh.
 channel-group-type.energidataservice.electricity.channel.electricity-tax.label = Electricity Tax
 channel-group-type.energidataservice.electricity.channel.electricity-tax.description = Electricity tax in DKK per kWh.
 channel-group-type.energidataservice.electricity.channel.grid-tariff.label = Grid Tariff
@@ -73,6 +77,8 @@ channel-group-type.energidataservice.electricity.channel.transmission-grid-tarif
 
 # channel types
 
+channel-type.energidataservice.co2-emission.label = CO₂ Emission
+channel-type.energidataservice.co2-emission.description = CO₂ emission in g/kWh.
 channel-type.energidataservice.datahub-price.label = Datahub Price
 channel-type.energidataservice.datahub-price.description = Datahub price.
 channel-type.energidataservice.spot-price.label = Spot Price
index 44cef47c1a0b4ab731d8555f64e21659458f9d65..add4ee51c210b10a293c13d792b643932ca3cb7a 100644 (file)
                                <label>Reduced Electricity Tax</label>
                                <description>Reduced electricity tax in DKK per kWh. For electric heating customers only.</description>
                        </channel>
+                       <channel id="co2-emission-prognosis" typeId="co2-emission">
+                               <label>CO₂ Emission Prognosis</label>
+                               <description>Estimated prognosis for CO₂ emission following the day-ahead market in g/kWh.</description>
+                       </channel>
+                       <channel id="co2-emission-realtime" typeId="co2-emission">
+                               <label>CO₂ Emission Realtime</label>
+                               <description>Near up-to-date history for CO₂ emission from electricity consumed in Denmark in g/kWh.</description>
+                       </channel>
                </channels>
        </channel-group-type>
 
index fe055d40ea7f98853b3b3afdeafef91aa95a0368..1509d177dbb4e985e65ddf211ffc2e448d31b6fa 100644 (file)
                <config-description-ref uri="channel-type:energidataservice:datahub-price"/>
        </channel-type>
 
+       <channel-type id="co2-emission">
+               <item-type>Number:EmissionIntensity</item-type>
+               <label>CO₂ Emission</label>
+               <description>CO₂ emission in g/kWh.</description>
+               <category>Smoke</category>
+               <state readOnly="true" pattern="%.1f %unit%"></state>
+       </channel-type>
+
 </thing:thing-descriptions>
index bda2a09c9a82bbf63b8e9490c5173d983ccd5ea0..fca5f24f6a978cb4873fc78805232f6e3e2a62d5 100644 (file)
@@ -14,7 +14,7 @@
                </channel-groups>
 
                <properties>
-                       <property name="thingTypeVersion">4</property>
+                       <property name="thingTypeVersion">5</property>
                </properties>
 
                <config-description-ref uri="thing-type:energidataservice:service"/>
index 1d71298194bdf5b66c391775d9a22262f0d46d6f..1366e1898b5fb3c4a4bbaf6b978aa3dfa5bd0ce2 100644 (file)
                        <remove-channel id="hourly-prices" groupIds="electricity"/>
                </instruction-set>
 
+               <instruction-set targetVersion="5">
+                       <add-channel id="co2-emission-prognosis" groupIds="electricity">
+                               <type>energidataservice:co2-emission</type>
+                               <label>CO₂ Emission Prognosis</label>
+                               <description>Estimated prognosis for CO₂ emission following the day-ahead market in g/kWh.</description>
+                       </add-channel>
+                       <add-channel id="co2-emission-realtime" groupIds="electricity">
+                               <type>energidataservice:co2-emission</type>
+                               <label>CO₂ Emission Realtime</label>
+                               <description>Near up-to-date history for CO₂ emission from electricity consumed in Denmark in g/kWh.</description>
+                       </add-channel>
+               </instruction-set>
+
        </thing-type>
 
 </update:update-descriptions>