2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.hydrawise.internal.api.graphql;
15 import java.io.BufferedReader;
16 import java.io.IOException;
17 import java.io.InputStream;
18 import java.io.InputStreamReader;
19 import java.lang.reflect.Type;
20 import java.util.Optional;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.TimeoutException;
23 import java.util.concurrent.atomic.AtomicInteger;
24 import java.util.stream.Collectors;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.eclipse.jetty.client.api.ContentResponse;
30 import org.eclipse.jetty.client.api.Response;
31 import org.eclipse.jetty.client.util.StringContentProvider;
32 import org.eclipse.jetty.http.HttpMethod;
33 import org.openhab.binding.hydrawise.internal.api.HydrawiseAuthenticationException;
34 import org.openhab.binding.hydrawise.internal.api.HydrawiseCommandException;
35 import org.openhab.binding.hydrawise.internal.api.HydrawiseConnectionException;
36 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ControllerStatus;
37 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Forecast;
38 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Mutation;
39 import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse;
40 import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse.MutationResponseStatus;
41 import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse.StatusCode;
42 import org.openhab.binding.hydrawise.internal.api.graphql.dto.QueryRequest;
43 import org.openhab.binding.hydrawise.internal.api.graphql.dto.QueryResponse;
44 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ScheduledRuns;
45 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Sensor;
46 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Zone;
47 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ZoneRun;
48 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
49 import org.openhab.core.auth.client.oauth2.OAuthClientService;
50 import org.openhab.core.auth.client.oauth2.OAuthException;
51 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.google.gson.FieldNamingPolicy;
56 import com.google.gson.Gson;
57 import com.google.gson.GsonBuilder;
58 import com.google.gson.JsonDeserializationContext;
59 import com.google.gson.JsonDeserializer;
60 import com.google.gson.JsonElement;
61 import com.google.gson.JsonParseException;
65 * @author Dan Cunningham - Initial contribution
69 public class HydrawiseGraphQLClient {
70 private final Logger logger = LoggerFactory.getLogger(HydrawiseGraphQLClient.class);
72 private final Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
73 .registerTypeAdapter(Zone.class, new ResponseDeserializer<Zone>())
74 .registerTypeAdapter(ScheduledRuns.class, new ResponseDeserializer<ScheduledRuns>())
75 .registerTypeAdapter(ZoneRun.class, new ResponseDeserializer<ZoneRun>())
76 .registerTypeAdapter(Forecast.class, new ResponseDeserializer<Forecast>())
77 .registerTypeAdapter(Sensor.class, new ResponseDeserializer<Forecast>())
78 .registerTypeAdapter(ControllerStatus.class, new ResponseDeserializer<ControllerStatus>()).create();
80 private static final String GRAPH_URL = "https://app.hydrawise.com/api/v2/graph";
81 private static final String MUTATION_START_ZONE = "startZone(zoneId: %d) { status }";
82 private static final String MUTATION_START_ZONE_CUSTOM = "startZone(zoneId: %d, customRunDuration: %d) { status }";
83 private static final String MUTATION_START_ALL_ZONES = "startAllZones(controllerId: %d){ status }";
84 private static final String MUTATION_START_ALL_ZONES_CUSTOM = "startAllZones(controllerId: %d, markRunAsScheduled: false, customRunDuration: %d ){ status }";
85 private static final String MUTATION_STOP_ZONE = "stopZone(zoneId: %d) { status }";
86 private static final String MUTATION_STOP_ALL_ZONES = "stopAllZones(controllerId: %d){ status }";
87 private static final String MUTATION_SUSPEND_ZONE = "suspendZone(zoneId: %d, until: \"%s\"){ status }";
88 private static final String MUTATION_SUSPEND_ALL_ZONES = "suspendAllZones(controllerId: %d, until: \"%s\"){ status }";
89 private static final String MUTATION_RESUME_ZONE = "resumeZone(zoneId: %d){ status }";
90 private static final String MUTATION_RESUME_ALL_ZONES = "resumeAllZones(controllerId: %d){ status }";
92 private final HttpClient httpClient;
93 private final OAuthClientService oAuthService;
94 private String queryString = "";
96 public HydrawiseGraphQLClient(HttpClient httpClient, OAuthClientService oAuthService) {
97 this.httpClient = httpClient;
98 this.oAuthService = oAuthService;
102 * Sends a GrapQL query for controller data
104 * @return QueryResponse
105 * @throws HydrawiseConnectionException
106 * @throws HydrawiseAuthenticationException
108 public @Nullable QueryResponse queryControllers()
109 throws HydrawiseConnectionException, HydrawiseAuthenticationException {
112 query = new QueryRequest(getQueryString());
113 } catch (IOException e) {
114 throw new HydrawiseConnectionException(e);
116 String queryJson = gson.toJson(query);
117 String response = sendGraphQLQuery(queryJson);
118 return gson.fromJson(response, QueryResponse.class);
122 * Stops a given relay
125 * @throws HydrawiseConnectionException
126 * @throws HydrawiseAuthenticationException
127 * @throws HydrawiseCommandException
129 public void stopRelay(int relayId)
130 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
131 sendGraphQLMutation(String.format(MUTATION_STOP_ZONE, relayId));
135 * Stops all relays on a given controller
137 * @param controllerId
138 * @throws HydrawiseConnectionException
139 * @throws HydrawiseAuthenticationException
140 * @throws HydrawiseCommandException
142 public void stopAllRelays(int controllerId)
143 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
144 sendGraphQLMutation(String.format(MUTATION_STOP_ALL_ZONES, controllerId));
148 * Runs a relay for the default amount of time
151 * @throws HydrawiseConnectionException
152 * @throws HydrawiseAuthenticationException
153 * @throws HydrawiseCommandException
155 public void runRelay(int relayId)
156 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
157 sendGraphQLMutation(String.format(MUTATION_START_ZONE, relayId));
161 * Runs a relay for the given amount of seconds
165 * @throws HydrawiseConnectionException
166 * @throws HydrawiseAuthenticationException
167 * @throws HydrawiseCommandException
169 public void runRelay(int relayId, int seconds)
170 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
171 sendGraphQLMutation(String.format(MUTATION_START_ZONE_CUSTOM, relayId, seconds));
175 * Run all relays on a given controller for the default amount of time
177 * @param controllerId
178 * @throws HydrawiseConnectionException
179 * @throws HydrawiseAuthenticationException
180 * @throws HydrawiseCommandException
182 public void runAllRelays(int controllerId)
183 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
184 sendGraphQLMutation(String.format(MUTATION_START_ALL_ZONES, controllerId));
188 * Run all relays on a given controller for the amount of seconds
190 * @param controllerId
192 * @throws HydrawiseConnectionException
193 * @throws HydrawiseAuthenticationException
194 * @throws HydrawiseCommandException
196 public void runAllRelays(int controllerId, int seconds)
197 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
198 sendGraphQLMutation(String.format(MUTATION_START_ALL_ZONES_CUSTOM, controllerId, seconds));
202 * Suspends a given relay
205 * @throws HydrawiseConnectionException
206 * @throws HydrawiseAuthenticationException
207 * @throws HydrawiseCommandException
209 public void suspendRelay(int relayId, String until)
210 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
211 sendGraphQLMutation(String.format(MUTATION_SUSPEND_ZONE, relayId, until));
215 * Resumes a given relay
218 * @throws HydrawiseConnectionException
219 * @throws HydrawiseAuthenticationException
220 * @throws HydrawiseCommandException
222 public void resumeRelay(int relayId)
223 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
224 sendGraphQLMutation(String.format(MUTATION_RESUME_ZONE, relayId));
228 * Suspend all relays on a given controller for an amount of seconds
230 * @param controllerId
232 * @throws HydrawiseConnectionException
233 * @throws HydrawiseAuthenticationException
234 * @throws HydrawiseCommandException
236 public void suspendAllRelays(int controllerId, String until)
237 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
238 sendGraphQLMutation(String.format(MUTATION_SUSPEND_ALL_ZONES, controllerId, until));
242 * Resumes all relays on a given controller
244 * @param controllerId
245 * @throws HydrawiseConnectionException
246 * @throws HydrawiseAuthenticationException
247 * @throws HydrawiseCommandException
249 public void resumeAllRelays(int controllerId)
250 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
251 sendGraphQLMutation(String.format(MUTATION_RESUME_ALL_ZONES, controllerId));
254 private String sendGraphQLQuery(String content)
255 throws HydrawiseConnectionException, HydrawiseAuthenticationException {
256 return sendGraphQLRequest(content);
259 private void sendGraphQLMutation(String content)
260 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
261 Mutation mutation = new Mutation(content);
262 logger.debug("Sending Mutation {}", gson.toJson(mutation).toString());
263 String response = sendGraphQLRequest(gson.toJson(mutation).toString());
264 logger.debug("Mutation response {}", response);
265 MutationResponse mResponse = gson.fromJson(response, MutationResponse.class);
266 if (mResponse == null) {
267 throw new HydrawiseCommandException("Malformed response: " + response);
269 Optional<MutationResponseStatus> status = mResponse.data.values().stream().findFirst();
270 if (!status.isPresent()) {
271 throw new HydrawiseCommandException("Unknown response: " + response);
273 if (status.get().status != StatusCode.OK) {
274 throw new HydrawiseCommandException("Command Status: " + status.get().status.name());
278 private String sendGraphQLRequest(String content)
279 throws HydrawiseConnectionException, HydrawiseAuthenticationException {
280 logger.trace("Sending Request: {}", content);
281 ContentResponse response;
282 final AtomicInteger responseCode = new AtomicInteger(0);
283 final StringBuilder responseMessage = new StringBuilder();
285 AccessTokenResponse token = oAuthService.getAccessTokenResponse();
287 throw new HydrawiseAuthenticationException("Login required");
289 response = httpClient.newRequest(GRAPH_URL).method(HttpMethod.POST)
290 .content(new StringContentProvider(content), "application/json")
291 .header("Authorization", token.getTokenType() + " " + token.getAccessToken())
292 .onResponseFailure(new Response.FailureListener() {
294 public void onFailure(@Nullable Response response, @Nullable Throwable failure) {
295 int status = response != null ? response.getStatus() : -1;
296 String reason = response != null ? response.getReason() : "Null response";
297 logger.trace("onFailure code: {} message: {}", status, reason);
298 responseCode.set(status);
299 responseMessage.append(reason);
302 String stringResponse = response.getContentAsString();
303 logger.trace("Received Response: {}", stringResponse);
304 return stringResponse;
305 } catch (InterruptedException | TimeoutException | OAuthException | IOException e) {
306 logger.debug("Could not send request", e);
307 throw new HydrawiseConnectionException(e);
308 } catch (OAuthResponseException e) {
309 throw new HydrawiseAuthenticationException(e.getMessage());
310 } catch (ExecutionException e) {
311 // Hydrawise returns back a 40x status, but without a valid Realm , so jetty throws an exception,
312 // this allows us to catch this in a callback and handle accordingly
313 switch (responseCode.get()) {
316 throw new HydrawiseAuthenticationException(responseMessage.toString());
318 throw new HydrawiseConnectionException(e);
323 private String getQueryString() throws IOException {
324 if (queryString.isBlank()) {
325 try (InputStream inputStream = HydrawiseGraphQLClient.class.getClassLoader()
326 .getResourceAsStream("query.graphql");
327 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
328 queryString = bufferedReader.lines().collect(Collectors.joining("\n"));
334 class ResponseDeserializer<T> implements JsonDeserializer<T> {
337 public T deserialize(JsonElement je, Type type, JsonDeserializationContext jdc) throws JsonParseException {
338 return new Gson().fromJson(je, type);