From 6ebf20f18355b492e8c7734cc6978611b5d7c3ca Mon Sep 17 00:00:00 2001 From: lolodomo Date: Sun, 18 Sep 2022 17:52:18 +0200 Subject: [PATCH] [ecowatt] Initial contribution (#13404) * [ecowatt] Initial contribution This binding uses the Ecowatt API to expose clear signals to adopt the right gestures and to ensure a good supply of electricity for all in France. Close #13351 Signed-off-by: Laurent Garnier * Update bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/thing/thing-types.xml Signed-off-by: Fabian Wolter Signed-off-by: Laurent Garnier Signed-off-by: Fabian Wolter Co-authored-by: Fabian Wolter --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + bundles/org.openhab.binding.ecowatt/NOTICE | 13 + bundles/org.openhab.binding.ecowatt/README.md | 71 +++++ bundles/org.openhab.binding.ecowatt/pom.xml | 17 ++ .../src/main/feature/feature.xml | 9 + .../internal/EcowattBindingConstants.java | 36 +++ .../internal/EcowattHandlerFactory.java | 77 ++++++ .../configuration/EcowattConfiguration.java | 27 ++ .../exception/EcowattApiLimitException.java | 37 +++ .../internal/handler/EcowattHandler.java | 245 ++++++++++++++++++ .../internal/restapi/EcowattApiResponse.java | 93 +++++++ .../internal/restapi/EcowattDaySignals.java | 63 +++++ .../internal/restapi/EcowattHourSignal.java | 31 +++ .../internal/restapi/EcowattRestApi.java | 141 ++++++++++ .../main/resources/OH-INF/binding/binding.xml | 10 + .../resources/OH-INF/i18n/ecowatt.properties | 43 +++ .../resources/OH-INF/thing/thing-types.xml | 55 ++++ bundles/pom.xml | 1 + 19 files changed, 975 insertions(+) create mode 100644 bundles/org.openhab.binding.ecowatt/NOTICE create mode 100644 bundles/org.openhab.binding.ecowatt/README.md create mode 100644 bundles/org.openhab.binding.ecowatt/pom.xml create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattBindingConstants.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattHandlerFactory.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/configuration/EcowattConfiguration.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/exception/EcowattApiLimitException.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/handler/EcowattHandler.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattApiResponse.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattDaySignals.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattHourSignal.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattRestApi.java create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/i18n/ecowatt.properties create mode 100644 bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/thing/thing-types.xml diff --git a/CODEOWNERS b/CODEOWNERS index 316b6341670..86feb8041de 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -79,6 +79,7 @@ /bundles/org.openhab.binding.dwdunwetter/ @limdul79 /bundles/org.openhab.binding.ecobee/ @mhilbush /bundles/org.openhab.binding.ecotouch/ @sibbi77 +/bundles/org.openhab.binding.ecowatt/ @lolodomo /bundles/org.openhab.binding.ekey/ @hmerk /bundles/org.openhab.binding.electroluxair/ @jannegpriv /bundles/org.openhab.binding.elerotransmitterstick/ @vbier diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index fbadb8d5142..1b76eaccb38 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -391,6 +391,11 @@ org.openhab.binding.ecotouch ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.ecowatt + ${project.version} + org.openhab.addons.bundles org.openhab.binding.ekey diff --git a/bundles/org.openhab.binding.ecowatt/NOTICE b/bundles/org.openhab.binding.ecowatt/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/NOTICE @@ -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 diff --git a/bundles/org.openhab.binding.ecowatt/README.md b/bundles/org.openhab.binding.ecowatt/README.md new file mode 100644 index 00000000000..6567b7ca39c --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/README.md @@ -0,0 +1,71 @@ +# Ecowatt Binding + +This binding uses the Ecowatt API to expose clear signals to adopt the right gestures and to ensure a good supply of electricity for all in France. + +You can find more information about Ecowatt on this [site](https://www.monecowatt.fr). + +## Supported Things + +This binding supports only one thing type: `signals`. + +## Discovery + +Discovery is not supported. +You have to add the thing manually. + +## Prerequisites before configuration + +You must create an account and an application on the RTE portal to obtain the OAuth2 credentials required to access the API. + +1. Open this [page](https://data.rte-france.com/catalog/-/api/consumption/Ecowatt/v4.0), find the "Ecowatt" tile and click on the "Abonnez-vous à l'API" button. +2. Create an account by following the instructions (you will receive an email to validate your new account). +3. Once logged in, create an application by entering a name (for example "openHAB Integration"), choosing "Web Server" as type, entering any description of your choice and finally clicking on the "Valider" button. +4. You will then see your application details, in particular the "ID client" and "ID Secret" information which you will need later to set up your binding thing. + +## Binding Configuration + +There are no overall binding configuration settings that need to be set. +All settings are through thing configuration parameters. + +## Thing Configuration + +| Name | Type | Description | Required | +|-----------|---------|-----------------------------------------------------------------------|----------| +| idClient | text | ID client provided with the application you created in the RTE portal | yes | +| idSecret | text | ID secret provided with the application you created in the RTE portal | yes | + +## Channels + +All channels are read-only. + +| Channel | Type | Description | +|-------------------|--------|------------------------------------------------------------------| +| todaySignal | Number | The signal relating to the forecast consumption level for today. Values are 1 for normal consumption, 2 for strained electrical system and 3 for very strained electrical system. | +| tomorrowSignal | Number | The signal relating to the forecast consumption level for tomorrow. Values are 1 for normal consumption, 2 for strained electrical system and 3 for very strained electrical system. | +| currentHourSignal | Number | The signal relating to the forecast consumption level for the current hour. Values are 1 for normal consumption, 2 for strained electrical system and 3 for very strained electrical system. | + +## Full Example + +example.things: + +``` +Thing ecowatt:signals:signals "Ecowatt Signals" [ idClient="xxxxx", idSecret="yyyyy"] +``` + +example.items: + +``` +Number TodaySignal "Today [%s]" { channel="ecowatt:signals:signals:todaySignal" } +Number TomorrowSignal "Tomorrow [%s]" { channel="ecowatt:signals:signals:tomorrowSignal" } +Number CurrentHourSignal "Current hour [%s]" { channel="ecowatt:signals:signals:currentHourSignal" } +``` + +example.sitemap: + +``` + Frame label="Ecowatt" { + Default item=TodaySignal + Default item=TomorrowSignal + Default item=CurrentHourSignal + } +``` diff --git a/bundles/org.openhab.binding.ecowatt/pom.xml b/bundles/org.openhab.binding.ecowatt/pom.xml new file mode 100644 index 00000000000..6b0fff0eb27 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.4.0-SNAPSHOT + + + org.openhab.binding.ecowatt + + openHAB Add-ons :: Bundles :: Ecowatt Binding + + diff --git a/bundles/org.openhab.binding.ecowatt/src/main/feature/feature.xml b/bundles/org.openhab.binding.ecowatt/src/main/feature/feature.xml new file mode 100644 index 00000000000..7f18af571c0 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.ecowatt/${project.version} + + diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattBindingConstants.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattBindingConstants.java new file mode 100644 index 00000000000..91f35dae9d8 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattBindingConstants.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link EcowattBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattBindingConstants { + + private static final String BINDING_ID = "ecowatt"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_SIGNALS = new ThingTypeUID(BINDING_ID, "signals"); + + // List of all Channel ids + public static final String CHANNEL_TODAY_SIGNAL = "todaySignal"; + public static final String CHANNEL_TOMORROW_SIGNAL = "tomorrowSignal"; + public static final String CHANNEL_CURRENT_HOUR_SIGNAL = "currentHourSignal"; +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattHandlerFactory.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattHandlerFactory.java new file mode 100644 index 00000000000..b290e59e7c5 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/EcowattHandlerFactory.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal; + +import static org.openhab.binding.ecowatt.internal.EcowattBindingConstants.THING_TYPE_SIGNALS; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.ecowatt.internal.handler.EcowattHandler; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link EcowattHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.ecowatt", service = ThingHandlerFactory.class) +public class EcowattHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SIGNALS); + + private final OAuthFactory oAuthFactory; + private final HttpClient httpClient; + private final TranslationProvider i18nProvider; + private final TimeZoneProvider timeZoneProvider; + + @Activate + public EcowattHandlerFactory(@Reference OAuthFactory oAuthFactory, @Reference HttpClientFactory httpClientFactory, + final @Reference TranslationProvider i18nProvider, final @Reference TimeZoneProvider timeZoneProvider) { + this.oAuthFactory = oAuthFactory; + this.httpClient = httpClientFactory.getCommonHttpClient(); + this.i18nProvider = i18nProvider; + this.timeZoneProvider = timeZoneProvider; + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_SIGNALS.equals(thingTypeUID)) { + return new EcowattHandler(thing, oAuthFactory, httpClient, i18nProvider, timeZoneProvider); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/configuration/EcowattConfiguration.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/configuration/EcowattConfiguration.java new file mode 100644 index 00000000000..903160a8e50 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/configuration/EcowattConfiguration.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link EcowattConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattConfiguration { + + public String idClient = ""; + public String idSecret = ""; +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/exception/EcowattApiLimitException.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/exception/EcowattApiLimitException.java new file mode 100644 index 00000000000..171e513d4d0 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/exception/EcowattApiLimitException.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.i18n.CommunicationException; + +/** + * An exception used when the API limit is reached + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattApiLimitException extends CommunicationException { + private static final long serialVersionUID = 1L; + private int retryAfter; + + public EcowattApiLimitException(int retryAfter, String message, @Nullable Object @Nullable... msgParams) { + super(message, msgParams); + this.retryAfter = retryAfter; + } + + public int getRetryAfter() { + return retryAfter; + } +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/handler/EcowattHandler.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/handler/EcowattHandler.java new file mode 100644 index 00000000000..d6530b9b3d7 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/handler/EcowattHandler.java @@ -0,0 +1,245 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal.handler; + +import static org.openhab.binding.ecowatt.internal.EcowattBindingConstants.*; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.ecowatt.internal.configuration.EcowattConfiguration; +import org.openhab.binding.ecowatt.internal.exception.EcowattApiLimitException; +import org.openhab.binding.ecowatt.internal.restapi.EcowattApiResponse; +import org.openhab.binding.ecowatt.internal.restapi.EcowattDaySignals; +import org.openhab.binding.ecowatt.internal.restapi.EcowattRestApi; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.cache.ExpiringCache; +import org.openhab.core.i18n.CommunicationException; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link EcowattHandler} is responsible for updating the state of the channels + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(EcowattHandler.class); + + private final OAuthFactory oAuthFactory; + private final HttpClient httpClient; + private final TranslationProvider i18nProvider; + private final TimeZoneProvider timeZoneProvider; + private final Bundle bundle; + + private @Nullable EcowattRestApi api; + private ExpiringCache cachedApiResponse = new ExpiringCache<>(Duration.ofHours(4), + this::getApiResponse); // cache the API response during 4 hours + + private @Nullable ScheduledFuture updateJob; + + public EcowattHandler(Thing thing, OAuthFactory oAuthFactory, HttpClient httpClient, + TranslationProvider i18nProvider, TimeZoneProvider timeZoneProvider) { + super(thing); + this.oAuthFactory = oAuthFactory; + this.httpClient = httpClient; + this.i18nProvider = i18nProvider; + this.timeZoneProvider = timeZoneProvider; + this.bundle = FrameworkUtil.getBundle(this.getClass()); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command == RefreshType.REFRESH) { + updateChannel(channelUID.getId()); + } + } + + @Override + public void initialize() { + EcowattConfiguration config = getConfigAs(EcowattConfiguration.class); + + final String idClient = config.idClient; + final String idSecret = config.idSecret; + + if (idClient.isBlank() || idSecret.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.config-error-unset-parameters"); + } else { + api = new EcowattRestApi(oAuthFactory, httpClient, thing.getUID().getAsString(), idClient, idSecret); + updateStatus(ThingStatus.UNKNOWN); + scheduleNextUpdate(0, true); + } + } + + @Override + public void dispose() { + stopScheduledJob(); + EcowattRestApi localApi = api; + if (localApi != null) { + localApi.dispose(); + api = null; + } + } + + /** + * Schedule the next update of channels. + * + * After this update is run, a new update will be rescheduled, either just after the API is reachable again or at + * the beginning of the following hour. + * + * @param delayInSeconds the delay in seconds before running the next update + * @param retryIfApiLimitReached true if a retry is expected when the update fails due to reached API limit + */ + private void scheduleNextUpdate(long delayInSeconds, boolean retryIfApiLimitReached) { + logger.debug("scheduleNextUpdate delay={}s retryIfLimitReached={}", delayInSeconds, retryIfApiLimitReached); + updateJob = scheduler.schedule(() -> { + int retryDelay = updateChannels(retryIfApiLimitReached); + long delayNextUpdate; + if (retryDelay > 0) { + // Schedule a new update just after the API is reachable again + logger.debug("retryDelay {}", retryDelay); + delayNextUpdate = retryDelay; + } else { + // Schedule a new update at the beginning of the following hour + final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime beginningNextHour = now.plusHours(1).truncatedTo(ChronoUnit.HOURS); + delayNextUpdate = ChronoUnit.SECONDS.between(now, beginningNextHour); + } + // Add 3s of additional delay for security... + delayNextUpdate += 3; + scheduleNextUpdate(delayNextUpdate, retryDelay == 0); + }, delayInSeconds, TimeUnit.SECONDS); + } + + private void stopScheduledJob() { + ScheduledFuture job = updateJob; + if (job != null) { + job.cancel(true); + updateJob = null; + } + } + + private EcowattApiResponse getApiResponse() { + EcowattRestApi localApi = api; + if (localApi == null) { + return new EcowattApiResponse(); + } + + EcowattApiResponse response; + try { + response = localApi.getSignals(); + } catch (CommunicationException e) { + Throwable cause = e.getCause(); + if (cause != null) { + logger.warn("{}: {}", e.getMessage(bundle, i18nProvider), cause.getMessage()); + } else { + logger.warn("{}", e.getMessage(bundle, i18nProvider)); + } + response = new EcowattApiResponse(e); + } + return response; + } + + private int updateChannels(boolean retryIfApiLimitReached) { + return updateChannel(null, retryIfApiLimitReached); + } + + private void updateChannel(String channelId) { + updateChannel(channelId, false); + } + + private synchronized int updateChannel(@Nullable String channelId, boolean retryIfApiLimitReached) { + logger.debug("updateChannel channelId={}, retryIfApiLimitReached={}", channelId, retryIfApiLimitReached); + int retryDelay = 0; + EcowattApiResponse response = cachedApiResponse.getValue(); + if (response == null || !response.succeeded()) { + CommunicationException exception = response == null ? null : response.getException(); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + exception == null ? null : exception.getRawMessage()); + + // Invalidate the cache to be sure the next request will trigger the API + cachedApiResponse.invalidateValue(); + + if (retryIfApiLimitReached && exception instanceof EcowattApiLimitException + && ((EcowattApiLimitException) exception).getRetryAfter() > 0) { + // Will retry when the API is available again (just after the limit expired) + retryDelay = ((EcowattApiLimitException) exception).getRetryAfter(); + } + } else { + updateStatus(ThingStatus.ONLINE); + } + + ZonedDateTime now = ZonedDateTime.now(timeZoneProvider.getTimeZone()); + logger.debug("now {}", now.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)); + if ((channelId == null || CHANNEL_TODAY_SIGNAL.equals(channelId)) && isLinked(CHANNEL_TODAY_SIGNAL)) { + updateState(CHANNEL_TODAY_SIGNAL, getDaySignalState(response, now)); + } + if ((channelId == null || CHANNEL_TOMORROW_SIGNAL.equals(channelId)) && isLinked(CHANNEL_TOMORROW_SIGNAL)) { + updateState(CHANNEL_TOMORROW_SIGNAL, getDaySignalState(response, now.plusDays(1))); + } + if ((channelId == null || CHANNEL_CURRENT_HOUR_SIGNAL.equals(channelId)) + && isLinked(CHANNEL_CURRENT_HOUR_SIGNAL)) { + updateState(CHANNEL_CURRENT_HOUR_SIGNAL, getHourSignalState(response, now)); + } + + return retryDelay; + } + + private State getDaySignalState(@Nullable EcowattApiResponse response, ZonedDateTime dateTime) { + EcowattDaySignals signals = response == null ? null : response.getDaySignals(dateTime); + return signals != null && signals.getDaySignal() >= 1 && signals.getDaySignal() <= 3 + ? new DecimalType(signals.getDaySignal()) + : UnDefType.UNDEF; + } + + private State getHourSignalState(@Nullable EcowattApiResponse response, ZonedDateTime dateTime) { + EcowattDaySignals signals = response == null ? null : response.getDaySignals(dateTime); + ZonedDateTime day = signals == null ? null : signals.getDay(); + if (signals != null && day != null) { + // Move the current time to the same offset as the data returned by the API to get and use the right current + // hour index in these data + int hour = dateTime.withZoneSameInstant(day.getZone()).getHour(); + int value = signals.getHourSignal(hour); + logger.debug("hour {} value {}", hour, value); + if (value >= 1 && value <= 3) { + return new DecimalType(value); + } + } + return UnDefType.UNDEF; + } +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattApiResponse.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattApiResponse.java new file mode 100644 index 00000000000..b146e9a6d2e --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattApiResponse.java @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal.restapi; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.i18n.CommunicationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link EcowattApiResponse} class contains fields mapping the response to the Ecowatt API request /signals. + * + * It also includes an exception field to be set in case the API request fails. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattApiResponse { + private final Logger logger = LoggerFactory.getLogger(EcowattApiResponse.class); + + public @Nullable List signals; + private @Nullable CommunicationException exception; + + public EcowattApiResponse() { + this.signals = null; + this.exception = null; + } + + public EcowattApiResponse(@Nullable List signals) { + this.signals = signals; + this.exception = null; + } + + public EcowattApiResponse(CommunicationException exception) { + this.signals = null; + this.exception = exception; + } + + /** + * Search the data for the day of the given date and time + * + * @param dateTime a date and time + * @return the data for the searched day or null if no data is found for this day + */ + public @Nullable EcowattDaySignals getDaySignals(ZonedDateTime dateTime) { + List localSignals = signals; + if (localSignals != null) { + for (EcowattDaySignals daySignals : localSignals) { + ZonedDateTime zdt = daySignals.getDay(); + if (zdt != null) { + // Adjust date/times to the same offset/zone + ZonedDateTime dateTime2 = dateTime.withZoneSameInstant(zdt.getZone()); + logger.trace("zdt {} offset {} - dateTime2 {} offset {}", + zdt.format(DateTimeFormatter.ISO_ZONED_DATE_TIME), zdt.getOffset(), + dateTime2.format(DateTimeFormatter.ISO_ZONED_DATE_TIME), dateTime2.getOffset()); + // Check if the two date/times are in the same day + if (zdt.truncatedTo(ChronoUnit.DAYS).toInstant() + .equals(dateTime2.truncatedTo(ChronoUnit.DAYS).toInstant())) { + logger.debug("getDaySignals for {} returns signal {} : {} ( {} )", + dateTime.format(DateTimeFormatter.ISO_ZONED_DATE_TIME), daySignals.getDaySignal(), + daySignals.getDayMessage(), zdt.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)); + return daySignals; + } + } + } + } + return null; + } + + public boolean succeeded() { + return signals != null; + } + + public @Nullable CommunicationException getException() { + return exception; + } +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattDaySignals.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattDaySignals.java new file mode 100644 index 00000000000..009a3830019 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattDaySignals.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal.restapi; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +/** + * The {@link EcowattDaySignals} class contains fields mapping the content of each value of JSON table "signals" inside + * the API response + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattDaySignals { + @SerializedName("GenerationFichier") + public @Nullable ZonedDateTime fileTimestamp; + @SerializedName("jour") + public @Nullable ZonedDateTime day; + @SerializedName("dvalue") + public int value; + public @Nullable String message; + public @Nullable List values; + + public @Nullable ZonedDateTime getDay() { + return day; + } + + public int getDaySignal() { + return value; + } + + public @Nullable String getDayMessage() { + return message; + } + + public int getHourSignal(int hour) { + List localValues = values; + if (localValues != null) { + for (EcowattHourSignal hourSignal : localValues) { + if (hourSignal.hour == hour) { + return hourSignal.value; + } + } + } + return 0; + } +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattHourSignal.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattHourSignal.java new file mode 100644 index 00000000000..bc23c3cdb6f --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattHourSignal.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal.restapi; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * The {@link EcowattHourSignal} class contains fields mapping the content of each value of JSON table "values" inside + * the API response + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattHourSignal { + @SerializedName("pas") + public int hour = -1; + @SerializedName("hvalue") + public int value; +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattRestApi.java b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattRestApi.java new file mode 100644 index 00000000000..084274ae7cb --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/java/org/openhab/binding/ecowatt/internal/restapi/EcowattRestApi.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2010-2022 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.ecowatt.internal.restapi; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +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.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.ecowatt.internal.exception.EcowattApiLimitException; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthException; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.openhab.core.i18n.CommunicationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link EcowattRestApi} is responsible for handling all communication with the Ecowatt REST API + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class EcowattRestApi { + + private static final String ECOWATT_API_TOKEN_URL = "https://digital.iservices.rte-france.com/token/oauth/"; + private static final String ECOWATT_API_GET_SIGNALS_URL = "https://digital.iservices.rte-france.com/open_api/ecowatt/v4/signals"; + + private final Logger logger = LoggerFactory.getLogger(EcowattRestApi.class); + + private final OAuthFactory oAuthFactory; + private final HttpClient httpClient; + private final Gson gson; + private OAuthClientService authService; + private String authServiceHandle; + + public EcowattRestApi(OAuthFactory oAuthFactory, HttpClient httpClient, String authServiceHandle, String idClient, + String idSecret) { + this.oAuthFactory = oAuthFactory; + this.httpClient = httpClient; + GsonBuilder gsonBuilder = new GsonBuilder(); + gson = gsonBuilder.registerTypeAdapter(ZonedDateTime.class, + (JsonDeserializer) (json, type, jsonDeserializationContext) -> OffsetDateTime + .parse(json.getAsJsonPrimitive().getAsString()).toZonedDateTime()) + .create(); + this.authService = oAuthFactory.createOAuthClientService(authServiceHandle, ECOWATT_API_TOKEN_URL, null, + idClient, idSecret, null, true); + this.authServiceHandle = authServiceHandle; + } + + public EcowattApiResponse getSignals() throws CommunicationException, EcowattApiLimitException { + logger.debug("API request signals"); + String token = authenticate().getAccessToken(); + + final Request request = httpClient.newRequest(ECOWATT_API_GET_SIGNALS_URL).method(HttpMethod.GET) + .header(HttpHeader.AUTHORIZATION, "Bearer " + token).timeout(10, TimeUnit.SECONDS); + + ContentResponse response; + try { + response = request.send(); + } catch (TimeoutException | ExecutionException e) { + throw new CommunicationException("@text/exception.api-request-failed", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CommunicationException("@text/exception.api-request-failed", e); + } + + int statusCode = response.getStatus(); + + if (statusCode == HttpStatus.TOO_MANY_REQUESTS_429) { + int retryAfter = -1; + if (response.getHeaders().contains(HttpHeader.RETRY_AFTER)) { + try { + retryAfter = Integer.parseInt(response.getHeaders().get(HttpHeader.RETRY_AFTER)); + } catch (NumberFormatException e) { + } + } + throw new EcowattApiLimitException(retryAfter, "@text/exception.api-limit-reached"); + } else if (statusCode != HttpStatus.OK_200) { + throw new CommunicationException("@text/exception.api-request-failed-params", statusCode, + response.getContentAsString()); + } + + try { + EcowattApiResponse deserializedResp = gson.fromJson(response.getContentAsString(), + EcowattApiResponse.class); + if (deserializedResp == null) { + throw new CommunicationException("@text/exception.empty-api-response"); + } + return deserializedResp; + } catch (JsonSyntaxException e) { + throw new CommunicationException("@text/exception.parsing-api-response-failed", e); + } + } + + private AccessTokenResponse authenticate() throws CommunicationException { + try { + AccessTokenResponse result = authService.getAccessTokenResponse(); + if (result == null || result.isExpired(Instant.now(), 120)) { + logger.debug("Authentication required"); + result = authService.getAccessTokenByClientCredentials(null); + } + logger.debug("Token {} of type {} created on {} expiring after {} seconds", result.getAccessToken(), + result.getTokenType(), result.getCreatedOn(), result.getExpiresIn()); + return result; + } catch (OAuthException | IOException | OAuthResponseException e) { + throw new CommunicationException("@text/exception.authentication-failed", e); + } + } + + public void dispose() { + oAuthFactory.ungetOAuthService(authServiceHandle); + } +} diff --git a/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 00000000000..1b07d8283f2 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,10 @@ + + + + Ecowatt Binding + This binding uses the Ecowatt API to expose clear signals to adopt the right gestures and to ensure a good + supply of electricity for all in Frances. + + diff --git a/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/i18n/ecowatt.properties b/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/i18n/ecowatt.properties new file mode 100644 index 00000000000..742223ac718 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/i18n/ecowatt.properties @@ -0,0 +1,43 @@ +# binding + +binding.ecowatt.name = Ecowatt Binding +binding.ecowatt.description = This binding uses the Ecowatt API to expose clear signals to adopt the right gestures and to ensure a good supply of electricity for all in Frances. + +# thing types + +thing-type.ecowatt.signals.label = Electricity Forecast +thing-type.ecowatt.signals.description = The French electricity consumption forecasts +thing-type.ecowatt.signals.channel.currentHourSignal.label = Current Hour Signal +thing-type.ecowatt.signals.channel.currentHourSignal.description = The signal relating to the forecast consumption level for the current hour. Values are 1 for normal consumption, 2 for strained electrical system and 3 for very strained electrical system. +thing-type.ecowatt.signals.channel.todaySignal.label = Today Signal +thing-type.ecowatt.signals.channel.todaySignal.description = The signal relating to the forecast consumption level for today. Values are 1 for normal consumption, 2 for strained electrical system and 3 for very strained electrical system. +thing-type.ecowatt.signals.channel.tomorrowSignal.label = Tomorrow Signal +thing-type.ecowatt.signals.channel.tomorrowSignal.description = The signal relating to the forecast consumption level for tomorrow. Values are 1 for normal consumption, 2 for strained electrical system and 3 for very strained electrical system. + +# thing types config + +thing-type.config.ecowatt.signals.idClient.label = ID Client +thing-type.config.ecowatt.signals.idClient.description = ID client provided with the application you created in the RTE portal. +thing-type.config.ecowatt.signals.idSecret.label = ID Secret +thing-type.config.ecowatt.signals.idSecret.description = ID secret provided with the application you created in the RTE portal. + +# channel types + +channel-type.ecowatt.signal.label = Consumption Signal +channel-type.ecowatt.signal.description = The signal relating to the forecast consumption level. Values are 1 for normal consumption, 2 for strained electrical system and 3 for very strained electrical system. +channel-type.ecowatt.signal.state.option.1 = Green (normal consumption) +channel-type.ecowatt.signal.state.option.2 = Orange (strained electrical system) +channel-type.ecowatt.signal.state.option.3 = Red (very strained electrical system) + +# thing status descriptions + +offline.config-error-unset-parameters = Id client and/or id secret configuration parameters is not set + +# exceptions + +exception.authentication-failed = Authentication to the API failed +exception.api-request-failed = REST API request failed +exception.api-request-failed-params = REST API request failed: statusCode={0}, message={1} +exception.empty-api-response = API response is empty +exception.parsing-api-response-failed = Parsing of the API response failed +exception.api-limit-reached = API limit reached; will retry later diff --git a/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..43da2596a62 --- /dev/null +++ b/bundles/org.openhab.binding.ecowatt/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,55 @@ + + + + + + The French electricity consumption forecasts + + + + + The signal relating to the forecast consumption level for today. Values are 1 for normal consumption, 2 + for strained electrical system and 3 for very strained electrical system. + + + + The signal relating to the forecast consumption level for tomorrow. Values are 1 for normal + consumption, 2 for strained electrical system and 3 for very strained electrical system. + + + + The signal relating to the forecast consumption level for the current hour. Values are 1 for normal + consumption, 2 for strained electrical system and 3 for very strained electrical system. + + + + + + + ID client provided with the application you created in the RTE portal. + + + password + + ID secret provided with the application you created in the RTE portal. + + + + + + Number + + The signal relating to the forecast consumption level. Values are 1 for normal consumption, 2 for + strained electrical system and 3 for very strained electrical system. + + + + + + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 99ec72c728c..6f121148bab 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -112,6 +112,7 @@ org.openhab.binding.easee org.openhab.binding.ecobee org.openhab.binding.ecotouch + org.openhab.binding.ecowatt org.openhab.binding.ekey org.openhab.binding.electroluxair org.openhab.binding.elerotransmitterstick