Skip to content

Commit 1f2f6cc

Browse files
committed
[solaredge] Add Monitoring API V2 support
Signed-off-by: Ronny Grun <ronny.grun@t-online.de>
1 parent 7568051 commit 1f2f6cc

33 files changed

Lines changed: 2808 additions & 63 deletions

bundles/org.openhab.binding.solaredge/README.md

Lines changed: 152 additions & 25 deletions
Large diffs are not rendered by default.

bundles/org.openhab.binding.solaredge/src/main/java/org/openhab/binding/solaredge/internal/SolarEdgeBindingConstants.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,25 @@ public class SolarEdgeBindingConstants {
9292
public static final String PUBLIC_DATA_API_END_TIME_FIELD = "endTime";
9393
public static final String PUBLIC_DATA_API_TIME_UNIT_FIELD = "timeUnit";
9494

95+
// PUBLIC API V2 CONSTANTS
96+
public static final String PUBLIC_DATA_API_V2_URL = "https://monitoringapi.solaredge.com/v2/sites/";
97+
public static final String PUBLIC_DATA_API_V2_POWER_SUFFIX = "/power";
98+
public static final String PUBLIC_DATA_API_V2_ENERGY_SUFFIX = "/energy";
99+
public static final String PUBLIC_DATA_API_V2_METER_TELEMETRY_SUFFIX = "/meters/telemetry";
100+
public static final String PUBLIC_DATA_API_V2_STORAGE_TELEMETRY_SUFFIX = "/storage/telemetry";
101+
public static final String PUBLIC_DATA_API_V2_KEY_HEADER = "X-API-Key";
102+
public static final String PUBLIC_DATA_API_V2_AUTHORIZE_URL = "https://connect.solaredge.com/authorize";
103+
public static final String PUBLIC_DATA_API_V2_TOKEN_URL = "https://monitoringapi.solaredge.com/v2/oauth2/token";
104+
public static final String PROPERTY_OAUTH_AUTHORIZATION_URL = "oauthAuthorizationUrl";
105+
public static final String PROPERTY_API_CALLS_LAST_30_DAYS = "apiCallsLast30Days";
106+
public static final String PROPERTY_API_RATE_LIMIT_MINUTE = "apiRateLimitMinute";
107+
public static final String PROPERTY_API_RATE_LIMIT_REMAINING_MINUTE = "apiRateLimitRemainingMinute";
108+
public static final String PROPERTY_API_RATE_LIMIT_RETRY_AFTER = "apiRateLimitRetryAfter";
109+
public static final String PUBLIC_DATA_API_V2_FROM_FIELD = "from";
110+
public static final String PUBLIC_DATA_API_V2_TO_FIELD = "to";
111+
public static final String PUBLIC_DATA_API_V2_RESOLUTION_FIELD = "resolution";
112+
public static final String PUBLIC_DATA_API_V2_UNIT_FIELD = "unit";
113+
95114
// constants
96115
public static final String BEGIN_OF_DAY_TIME = "00:00:00";
97116
public static final String END_OF_DAY_TIME = "23:59:59";
@@ -100,7 +119,7 @@ public class SolarEdgeBindingConstants {
100119
// web request constants
101120
public static final long WEB_REQUEST_PUBLIC_API_DAY_LIMIT = 300;
102121
public static final long WEB_REQUEST_INITIAL_DELAY = TimeUnit.SECONDS.toMillis(30);
103-
public static final long WEB_REQUEST_INTERVAL = TimeUnit.SECONDS.toMillis(5);
122+
public static final long WEB_REQUEST_INTERVAL = TimeUnit.SECONDS.toMillis(7);
104123
public static final int WEB_REQUEST_QUEUE_MAX_SIZE = 20;
105124

106125
// Status Keys

bundles/org.openhab.binding.solaredge/src/main/java/org/openhab/binding/solaredge/internal/SolarEdgeHandlerFactory.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@
1717
import org.eclipse.jdt.annotation.NonNullByDefault;
1818
import org.eclipse.jdt.annotation.Nullable;
1919
import org.eclipse.jetty.client.HttpClient;
20+
import org.openhab.binding.solaredge.internal.connector.PublicApiV2RequestCounter;
2021
import org.openhab.binding.solaredge.internal.handler.SolarEdgeGenericHandler;
22+
import org.openhab.binding.solaredge.internal.oauth.SolarEdgeOAuthClient;
23+
import org.openhab.binding.solaredge.internal.oauth.SolarEdgeOAuthServlet;
2124
import org.openhab.core.io.net.http.HttpClientFactory;
25+
import org.openhab.core.storage.Storage;
26+
import org.openhab.core.storage.StorageService;
2227
import org.openhab.core.thing.Thing;
2328
import org.openhab.core.thing.ThingTypeUID;
2429
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
@@ -46,10 +51,15 @@ public class SolarEdgeHandlerFactory extends BaseThingHandlerFactory {
4651
* the shared http client
4752
*/
4853
private final HttpClient httpClient;
54+
private final StorageService storageService;
55+
private final SolarEdgeOAuthServlet oAuthServlet;
4956

5057
@Activate
51-
public SolarEdgeHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
58+
public SolarEdgeHandlerFactory(@Reference HttpClientFactory httpClientFactory,
59+
@Reference StorageService storageService, @Reference SolarEdgeOAuthServlet oAuthServlet) {
5260
this.httpClient = httpClientFactory.getCommonHttpClient();
61+
this.storageService = storageService;
62+
this.oAuthServlet = oAuthServlet;
5363
}
5464

5565
@Override
@@ -62,11 +72,21 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
6272
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
6373

6474
if (thingTypeUID.equals(THING_TYPE_GENERIC)) {
65-
return new SolarEdgeGenericHandler(thing, httpClient);
75+
Storage<String> storage = storageService.getStorage(thing.getUID().toString(),
76+
String.class.getClassLoader());
77+
return new SolarEdgeGenericHandler(thing, httpClient, new SolarEdgeOAuthClient(httpClient, storage),
78+
new PublicApiV2RequestCounter(storage), oAuthServlet);
6679
} else {
6780
logger.warn("Unsupported Thing-Type: {}", thingTypeUID.getAsString());
6881
}
6982

7083
return null;
7184
}
85+
86+
@Override
87+
protected void removeHandler(ThingHandler thingHandler) {
88+
if (thingHandler instanceof SolarEdgeGenericHandler solarEdgeHandler) {
89+
oAuthServlet.unregister(solarEdgeHandler);
90+
}
91+
}
7292
}

bundles/org.openhab.binding.solaredge/src/main/java/org/openhab/binding/solaredge/internal/command/AbstractCommand.java

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020
import java.net.URI;
2121
import java.net.UnknownHostException;
2222
import java.nio.ByteBuffer;
23+
import java.util.Objects;
2324
import java.util.concurrent.TimeUnit;
2425
import java.util.concurrent.TimeoutException;
26+
import java.util.function.Consumer;
27+
import java.util.function.Supplier;
2528

2629
import org.eclipse.jdt.annotation.NonNullByDefault;
2730
import org.eclipse.jdt.annotation.Nullable;
@@ -31,6 +34,8 @@
3134
import org.eclipse.jetty.client.util.BufferingResponseListener;
3235
import org.eclipse.jetty.http.HttpStatus;
3336
import org.eclipse.jetty.http.HttpStatus.Code;
37+
import org.openhab.binding.solaredge.internal.config.PublicApiAuthentication;
38+
import org.openhab.binding.solaredge.internal.config.PublicApiVersion;
3439
import org.openhab.binding.solaredge.internal.config.SolarEdgeConfiguration;
3540
import org.openhab.binding.solaredge.internal.connector.CommunicationStatus;
3641
import org.openhab.binding.solaredge.internal.connector.StatusUpdateListener;
@@ -72,6 +77,10 @@ public abstract class AbstractCommand extends BufferingResponseListener implemen
7277
* listener to provide updates to the WebInterface class
7378
*/
7479
private final StatusUpdateListener listener;
80+
private final Supplier<String> publicApiV2CredentialSupplier;
81+
private final Runnable publicApiV2CredentialInvalidator;
82+
private final Runnable publicApiV2RequestRecorder;
83+
private final Consumer<Response> publicApiV2RateLimitListener;
7584

7685
/**
7786
* the constructor
@@ -81,9 +90,37 @@ public abstract class AbstractCommand extends BufferingResponseListener implemen
8190
*
8291
*/
8392
public AbstractCommand(SolarEdgeConfiguration config, StatusUpdateListener listener) {
93+
this(config, listener, config::getTokenOrApiKey, () -> {
94+
}, () -> {
95+
}, response -> {
96+
});
97+
}
98+
99+
protected AbstractCommand(SolarEdgeConfiguration config, StatusUpdateListener listener,
100+
Supplier<String> publicApiV2CredentialSupplier) {
101+
this(config, listener, publicApiV2CredentialSupplier, () -> {
102+
}, () -> {
103+
}, response -> {
104+
});
105+
}
106+
107+
protected AbstractCommand(SolarEdgeConfiguration config, StatusUpdateListener listener,
108+
Supplier<String> publicApiV2CredentialSupplier, Runnable publicApiV2CredentialInvalidator) {
109+
this(config, listener, publicApiV2CredentialSupplier, publicApiV2CredentialInvalidator, () -> {
110+
}, response -> {
111+
});
112+
}
113+
114+
protected AbstractCommand(SolarEdgeConfiguration config, StatusUpdateListener listener,
115+
Supplier<String> publicApiV2CredentialSupplier, Runnable publicApiV2CredentialInvalidator,
116+
Runnable publicApiV2RequestRecorder, Consumer<Response> publicApiV2RateLimitListener) {
84117
this.communicationStatus = new CommunicationStatus();
85118
this.config = config;
86119
this.listener = listener;
120+
this.publicApiV2CredentialSupplier = publicApiV2CredentialSupplier;
121+
this.publicApiV2CredentialInvalidator = publicApiV2CredentialInvalidator;
122+
this.publicApiV2RequestRecorder = publicApiV2RequestRecorder;
123+
this.publicApiV2RateLimitListener = publicApiV2RateLimitListener;
87124
this.gson = new Gson();
88125
}
89126

@@ -93,9 +130,14 @@ public AbstractCommand(SolarEdgeConfiguration config, StatusUpdateListener liste
93130
@Override
94131
public final void onSuccess(Response response) {
95132
super.onSuccess(response);
96-
if (response != null) {
97-
communicationStatus.setHttpCode(HttpStatus.getCode(response.getStatus()));
98-
logger.debug("HTTP response {}", response.getStatus());
133+
communicationStatus.setHttpCode(HttpStatus.getCode(response.getStatus()));
134+
logger.debug("HTTP response {}", response.getStatus());
135+
if (!config.isUsePrivateApi() && PublicApiVersion.V2.equals(config.getPublicApiVersion())) {
136+
publicApiV2RateLimitListener.accept(response);
137+
}
138+
if (response.getStatus() == HttpStatus.UNAUTHORIZED_401
139+
&& PublicApiAuthentication.OAUTH.equals(config.getPublicApiAuthentication())) {
140+
publicApiV2CredentialInvalidator.run();
99141
}
100142
}
101143

@@ -109,7 +151,7 @@ public final void onFailure(@Nullable Response response, @Nullable Throwable fai
109151
}
110152
if (failure != null) {
111153
logger.debug("Request failed: {}", failure.toString());
112-
communicationStatus.setError((Exception) failure);
154+
communicationStatus.setError(failure instanceof Exception exception ? exception : new Exception(failure));
113155

114156
if (failure instanceof SocketTimeoutException || failure instanceof TimeoutException) {
115157
communicationStatus.setHttpCode(Code.REQUEST_TIMEOUT);
@@ -126,7 +168,8 @@ public final void onFailure(@Nullable Response response, @Nullable Throwable fai
126168
@Override
127169
public void onContent(Response response, ByteBuffer content) {
128170
super.onContent(response, content);
129-
logger.debug("received content, length: {}", getContentAsString().length());
171+
String receivedContent = getContentAsString();
172+
logger.debug("received content, length: {}", receivedContent == null ? 0 : receivedContent.length());
130173
}
131174

132175
@Override
@@ -142,11 +185,20 @@ public void performAction(HttpClient asyncclient) {
142185
c.setDomain(PRIVATE_API_TOKEN_COOKIE_DOMAIN);
143186
c.setPath(PRIVATE_API_TOKEN_COOKIE_PATH);
144187
cookieStore.add(URI.create(getURL()), c);
188+
} else if (PublicApiVersion.V2.equals(config.getPublicApiVersion())) {
189+
String credential = Objects.requireNonNull(publicApiV2CredentialSupplier.get());
190+
if (PublicApiAuthentication.OAUTH.equals(config.getPublicApiAuthentication())) {
191+
request.header("Authorization", "Bearer " + credential);
192+
} else {
193+
request.header(PUBLIC_DATA_API_V2_KEY_HEADER, credential);
194+
}
145195
} else {
146-
// this is only relevant when using public API
147196
request.param(PUBLIC_DATA_API_KEY_FIELD, config.getTokenOrApiKey());
148197
}
149198

199+
if (!config.isUsePrivateApi() && PublicApiVersion.V2.equals(config.getPublicApiVersion())) {
200+
publicApiV2RequestRecorder.run();
201+
}
150202
prepareRequest(request).send(this);
151203
}
152204

@@ -157,6 +209,14 @@ public CommunicationStatus getCommunicationStatus() {
157209
return communicationStatus;
158210
}
159211

212+
/** Returns whether a failed request may succeed when retried shortly afterwards. */
213+
protected final boolean isRetryable() {
214+
return switch (communicationStatus.getHttpCode()) {
215+
case REQUEST_TIMEOUT, INTERNAL_SERVER_ERROR, BAD_GATEWAY, SERVICE_UNAVAILABLE, GATEWAY_TIMEOUT -> true;
216+
default -> false;
217+
};
218+
}
219+
160220
/**
161221
* updates status of the registered listener.
162222
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.solaredge.internal.command;
14+
15+
import static org.openhab.binding.solaredge.internal.SolarEdgeBindingConstants.*;
16+
17+
import java.nio.charset.StandardCharsets;
18+
import java.time.DayOfWeek;
19+
import java.time.OffsetDateTime;
20+
import java.time.temporal.ChronoUnit;
21+
import java.time.temporal.TemporalAdjusters;
22+
23+
import org.eclipse.jdt.annotation.NonNullByDefault;
24+
import org.eclipse.jdt.annotation.Nullable;
25+
import org.eclipse.jetty.client.api.Request;
26+
import org.eclipse.jetty.client.api.Result;
27+
import org.eclipse.jetty.http.HttpMethod;
28+
import org.eclipse.jetty.http.HttpStatus;
29+
import org.openhab.binding.solaredge.internal.connector.StatusUpdateListener;
30+
import org.openhab.binding.solaredge.internal.handler.SolarEdgeHandler;
31+
import org.openhab.binding.solaredge.internal.model.AggregatePeriod;
32+
import org.openhab.binding.solaredge.internal.model.MeasurementsResponsePublicApiV2;
33+
import org.openhab.binding.solaredge.internal.model.MeasurementsResponseTransformerPublicApiV2;
34+
35+
/**
36+
* Retrieves production energy from the SolarEdge Monitoring API V2 Basic Monitoring API.
37+
*
38+
* @author Ronny Grun - Initial contribution
39+
*/
40+
@NonNullByDefault
41+
public class AggregateDataUpdatePublicApiV2 extends AbstractCommand {
42+
43+
private final SolarEdgeHandler handler;
44+
private final boolean yearly;
45+
private final MeasurementsResponseTransformerPublicApiV2 transformer;
46+
private int retries;
47+
48+
public AggregateDataUpdatePublicApiV2(SolarEdgeHandler handler, boolean yearly, StatusUpdateListener listener) {
49+
super(handler.getConfiguration(), listener, handler::getPublicApiV2Credential,
50+
handler::invalidatePublicApiV2Credential, handler::recordPublicApiV2Request,
51+
response -> handler.updatePublicApiV2RateLimit(response.getHeaders().get("x-ratelimit-limit-minute"),
52+
response.getHeaders().get("x-ratelimit-remaining-minute"),
53+
response.getHeaders().get("Retry-After")));
54+
this.handler = handler;
55+
this.yearly = yearly;
56+
this.transformer = new MeasurementsResponseTransformerPublicApiV2(handler);
57+
}
58+
59+
@Override
60+
protected Request prepareRequest(Request requestToPrepare) {
61+
OffsetDateTime now = OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS);
62+
OffsetDateTime from = yearly ? aggregateStart(now, AggregatePeriod.YEAR) : earliestRecentAggregateStart(now);
63+
return requestToPrepare.followRedirects(false).method(HttpMethod.GET)
64+
.param(PUBLIC_DATA_API_V2_FROM_FIELD, from.toString())
65+
.param(PUBLIC_DATA_API_V2_TO_FIELD, now.toString())
66+
.param(PUBLIC_DATA_API_V2_RESOLUTION_FIELD, yearly ? "MONTH" : "DAY")
67+
.param(PUBLIC_DATA_API_V2_UNIT_FIELD, "WH");
68+
}
69+
70+
@Override
71+
protected String getURL() {
72+
return PUBLIC_DATA_API_V2_URL + config.getSolarId() + PUBLIC_DATA_API_V2_ENERGY_SUFFIX;
73+
}
74+
75+
@Override
76+
public void onComplete(@Nullable Result result) {
77+
if (!HttpStatus.Code.OK.equals(getCommunicationStatus().getHttpCode())) {
78+
if (isRetryable() && retries++ < MAX_RETRIES) {
79+
handler.getWebInterface().enqueueCommand(this);
80+
return;
81+
}
82+
} else {
83+
String json = getContentAsString(StandardCharsets.UTF_8);
84+
if (json != null) {
85+
MeasurementsResponsePublicApiV2 response = fromJson(json, MeasurementsResponsePublicApiV2.class);
86+
if (response != null) {
87+
OffsetDateTime now = OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS);
88+
AggregatePeriod[] periods = yearly ? new AggregatePeriod[] { AggregatePeriod.YEAR }
89+
: new AggregatePeriod[] { AggregatePeriod.DAY, AggregatePeriod.WEEK,
90+
AggregatePeriod.MONTH };
91+
for (AggregatePeriod period : periods) {
92+
OffsetDateTime from = aggregateStart(now, period);
93+
handler.updateChannelStatus(transformer.transformEnergy(response, period, from));
94+
handler.updatePublicApiV2AggregateProduction(period, transformer.totalValue(response, from));
95+
}
96+
}
97+
}
98+
}
99+
updateListenerStatus();
100+
}
101+
102+
private static OffsetDateTime earliestRecentAggregateStart(OffsetDateTime now) {
103+
OffsetDateTime week = aggregateStart(now, AggregatePeriod.WEEK);
104+
OffsetDateTime month = aggregateStart(now, AggregatePeriod.MONTH);
105+
return week.isBefore(month) ? week : month;
106+
}
107+
108+
private static OffsetDateTime aggregateStart(OffsetDateTime now, AggregatePeriod period) {
109+
return switch (period) {
110+
case DAY -> now.truncatedTo(ChronoUnit.DAYS);
111+
case WEEK -> now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).truncatedTo(ChronoUnit.DAYS);
112+
case MONTH -> now.with(TemporalAdjusters.firstDayOfMonth()).truncatedTo(ChronoUnit.DAYS);
113+
case YEAR -> now.with(TemporalAdjusters.firstDayOfYear()).truncatedTo(ChronoUnit.DAYS);
114+
};
115+
}
116+
}

0 commit comments

Comments
 (0)