Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[energidataservice] Initial contribution #14376

Merged
merged 17 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,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 @@ -451,6 +451,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