Skip to content

Commit

Permalink
[energidataservice] Initial contribution (#14376)
Browse files Browse the repository at this point in the history
* Initial contribution

Signed-off-by: Jacob Laursen <[email protected]>

* Remove Value-Added Tax

Signed-off-by: Jacob Laursen <[email protected]>

* Migrate naming convention

Signed-off-by: Jacob Laursen <[email protected]>

* Add channel configuration example

Signed-off-by: Jacob Laursen <[email protected]>

* Remove current prefixes for forward compatibility with timestamped items

Signed-off-by: Jacob Laursen <[email protected]>

* Add filter for another grid company

Signed-off-by: Jacob Laursen <[email protected]>

* Use ISO 3166-1 alpha-2 codes in lowercase for XSD compliance

Signed-off-by: Jacob Laursen <[email protected]>

* Fix error handling for deserializers

Signed-off-by: Jacob Laursen <[email protected]>

* Fix compliance with RFC 9110 section 10.1.5

Signed-off-by: Jacob Laursen <[email protected]>

* Add JavaScript example code

Signed-off-by: Jacob Laursen <[email protected]>

* Refactor List to Collection and use iterators

Signed-off-by: Jacob Laursen <[email protected]>

* Add filter for another grid company

Signed-off-by: Jacob Laursen <[email protected]>

* Extend cached history to 24 hours

Signed-off-by: Jacob Laursen <[email protected]>

* Remove filter for expired GLN

Signed-off-by: Jacob Laursen <[email protected]>

* Fix typos

Signed-off-by: Jacob Laursen <[email protected]>

* Improve descriptions

Signed-off-by: Jacob Laursen <[email protected]>

* Improve logging

Signed-off-by: Jacob Laursen <[email protected]>

---------

Signed-off-by: Jacob Laursen <[email protected]>
  • Loading branch information
jlaur authored Jul 3, 2023
1 parent 4ddb3ce commit 6cfb1e2
Show file tree
Hide file tree
Showing 64 changed files with 9,685 additions and 0 deletions.
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
/bundles/org.openhab.binding.elerotransmitterstick/ @vbier
/bundles/org.openhab.binding.elroconnects/ @mherwege
/bundles/org.openhab.binding.energenie/ @hmerk
/bundles/org.openhab.binding.energidataservice/ @jlaur
/bundles/org.openhab.binding.enigma2/ @gdolfen
/bundles/org.openhab.binding.enocean/ @fruggy83
/bundles/org.openhab.binding.enphase/ @Hilbrand
Expand Down
5 changes: 5 additions & 0 deletions bom/openhab-addons/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,11 @@
<artifactId>org.openhab.binding.energenie</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.energidataservice</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.enigma2</artifactId>
Expand Down
13 changes: 13 additions & 0 deletions bundles/org.openhab.binding.energidataservice/NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.

* Project home: https://www.openhab.org

== Declared Project Licenses

This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.

== Source Code

https://github.com/openhab/openhab-addons
472 changes: 472 additions & 0 deletions bundles/org.openhab.binding.energidataservice/README.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions bundles/org.openhab.binding.energidataservice/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.0.0-SNAPSHOT</version>
</parent>

<artifactId>org.openhab.binding.energidataservice</artifactId>

<name>openHAB Add-ons :: Bundles :: Energi Data Service Binding</name>

<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.energidataservice-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>

<feature name="openhab-binding-energidataservice" description="Energi Data Service Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.energidataservice/${project.version}</bundle>
</feature>
</features>
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* Copyright (c) 2010-2023 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;

import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Collection;
import java.util.Currency;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpFields;
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.DateQueryParameter;
import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
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;
import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecords;
import org.openhab.binding.energidataservice.internal.api.serialization.InstantDeserializer;
import org.openhab.binding.energidataservice.internal.api.serialization.LocalDateTimeDeserializer;
import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
import org.openhab.core.i18n.TimeZoneProvider;
import org.osgi.framework.FrameworkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;

/**
* The {@link ApiController} is responsible for interacting with Energi Data Service.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
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";
private static final String FILTER_KEY_GLN_NUMBER = "GLN_Number";
private static final String FILTER_KEY_NOTE = "Note";

private static final String HEADER_REMAINING_CALLS = "RemainingCalls";
private static final String HEADER_TOTAL_CALLS = "TotalCalls";

private final Logger logger = LoggerFactory.getLogger(ApiController.class);
private final Gson gson = new GsonBuilder() //
.registerTypeAdapter(Instant.class, new InstantDeserializer()) //
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeDeserializer()) //
.create();
private final HttpClient httpClient;
private final TimeZoneProvider timeZoneProvider;
private final String userAgent;

public ApiController(HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
this.httpClient = httpClient;
this.timeZoneProvider = timeZoneProvider;
userAgent = "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
}

/**
* Retrieve spot prices for requested area and in requested {@link Currency}.
*
* @param priceArea Usually DK1 or DK2
* @param currency DKK or EUR
* @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 pairs of hour start and price in requested currency.
* @throws InterruptedException
* @throws DataServiceException
*/
public ElspotpriceRecord[] getSpotPrices(String priceArea, Currency currency, DateQueryParameter start,
Map<String, String> properties) throws InterruptedException, DataServiceException {
if (!SUPPORTED_CURRENCIES.contains(currency)) {
throw new IllegalArgumentException("Invalid currency " + currency.getCurrencyCode());
}

Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_SPOT_PRICES)
.param("start", start.toString()) //
.param("filter", "{\"" + FILTER_KEY_PRICE_AREA + "\":\"" + priceArea + "\"}") //
.param("columns", "HourUTC,SpotPrice" + currency) //
.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);

ElspotpriceRecords records = gson.fromJson(responseContent, ElspotpriceRecords.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(ElspotpriceRecord[]::new);
} catch (JsonSyntaxException e) {
throw new DataServiceException("Error parsing response", e);
} catch (TimeoutException | ExecutionException e) {
throw new DataServiceException(e);
}
}

private void updatePropertiesFromResponse(ContentResponse response, Map<String, String> properties) {
HttpFields headers = response.getHeaders();
String remainingCalls = headers.get(HEADER_REMAINING_CALLS);
if (remainingCalls != null) {
properties.put(PROPERTY_REMAINING_CALLS, remainingCalls);
}
String totalCalls = headers.get(HEADER_TOTAL_CALLS);
if (totalCalls != null) {
properties.put(PROPERTY_TOTAL_CALLS, totalCalls);
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
properties.put(PROPERTY_LAST_CALL, LocalDateTime.now(timeZoneProvider.getTimeZone()).format(formatter));
}

/**
* Retrieve datahub pricelists for requested GLN and charge type/charge type code.
*
* @param globalLocationNumber Global Location Number of the Charge Owner
* @param chargeType Charge type (Subscription, Fee or Tariff).
* @param tariffFilter Tariff filter (charge type codes and notes).
* @param properties Map of properties which will be updated with metadata from headers
* @return Price list for requested GLN and note.
* @throws InterruptedException
* @throws DataServiceException
*/
public Collection<DatahubPricelistRecord> getDatahubPriceLists(GlobalLocationNumber globalLocationNumber,
ChargeType chargeType, DatahubTariffFilter tariffFilter, Map<String, String> properties)
throws InterruptedException, DataServiceException {
String columns = "ValidFrom,ValidTo,ChargeTypeCode";
for (int i = 1; i < 25; i++) {
columns += ",Price" + i;
}

Map<String, Collection<String>> filterMap = new HashMap<>(Map.of( //
FILTER_KEY_GLN_NUMBER, List.of(globalLocationNumber.toString()), //
FILTER_KEY_CHARGE_TYPE, List.of(chargeType.toString())));

Collection<String> chargeTypeCodes = tariffFilter.getChargeTypeCodesAsStrings();
if (!chargeTypeCodes.isEmpty()) {
filterMap.put(FILTER_KEY_CHARGE_TYPE_CODE, chargeTypeCodes);
}

Collection<String> notes = tariffFilter.getNotes();
if (!notes.isEmpty()) {
filterMap.put(FILTER_KEY_NOTE, notes);
}

Request request = httpClient.newRequest(ENDPOINT + DATASET_PATH + DATASET_NAME_DATAHUB_PRICELIST)
.param("filter", mapToFilter(filterMap)) //
.param("columns", columns) //
.agent(userAgent) //
.method(HttpMethod.GET);

DateQueryParameter dateQueryParameter = tariffFilter.getDateQueryParameter();
if (!dateQueryParameter.isEmpty()) {
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);

DatahubPricelistRecords records = gson.fromJson(responseContent, DatahubPricelistRecords.class);
if (records == null) {
throw new DataServiceException("Error parsing response");
}

if (records.limit() > 0 && records.limit() < records.total()) {
logger.warn("{} price list records available, but only {} returned.", records.total(), records.limit());
}

if (Objects.isNull(records.records())) {
return List.of();
}

return Arrays.stream(records.records()).filter(Objects::nonNull).toList();
} catch (JsonSyntaxException e) {
throw new DataServiceException("Error parsing response", e);
} catch (TimeoutException | ExecutionException e) {
throw new DataServiceException(e);
}
}

private String mapToFilter(Map<String, Collection<String>> map) {
return "{" + map.entrySet().stream().map(
e -> "\"" + e.getKey() + "\":[\"" + e.getValue().stream().collect(Collectors.joining("\",\"")) + "\"]")
.collect(Collectors.joining(",")) + "}";
}
}
Loading

0 comments on commit 6cfb1e2

Please sign in to comment.