2 * Copyright (c) 2010-2024 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.eclipse.jetty.http.HttpStatus;
34 import org.openhab.binding.hydrawise.internal.api.HydrawiseAuthenticationException;
35 import org.openhab.binding.hydrawise.internal.api.HydrawiseCommandException;
36 import org.openhab.binding.hydrawise.internal.api.HydrawiseConnectionException;
37 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ControllerStatus;
38 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Forecast;
39 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Mutation;
40 import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse;
41 import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse.MutationResponseStatus;
42 import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse.StatusCode;
43 import org.openhab.binding.hydrawise.internal.api.graphql.dto.QueryRequest;
44 import org.openhab.binding.hydrawise.internal.api.graphql.dto.QueryResponse;
45 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ScheduledRuns;
46 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Sensor;
47 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Zone;
48 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ZoneRun;
49 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
50 import org.openhab.core.auth.client.oauth2.OAuthClientService;
51 import org.openhab.core.auth.client.oauth2.OAuthException;
52 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
56 import com.google.gson.FieldNamingPolicy;
57 import com.google.gson.Gson;
58 import com.google.gson.GsonBuilder;
59 import com.google.gson.JsonDeserializationContext;
60 import com.google.gson.JsonDeserializer;
61 import com.google.gson.JsonElement;
62 import com.google.gson.JsonParseException;
63 import com.google.gson.JsonSyntaxException;
67 * @author Dan Cunningham - Initial contribution
71 public class HydrawiseGraphQLClient {
72 private final Logger logger = LoggerFactory.getLogger(HydrawiseGraphQLClient.class);
74 private final Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
75 .registerTypeAdapter(Zone.class, new ResponseDeserializer<Zone>())
76 .registerTypeAdapter(ScheduledRuns.class, new ResponseDeserializer<ScheduledRuns>())
77 .registerTypeAdapter(ZoneRun.class, new ResponseDeserializer<ZoneRun>())
78 .registerTypeAdapter(Forecast.class, new ResponseDeserializer<Forecast>())
79 .registerTypeAdapter(Sensor.class, new ResponseDeserializer<Forecast>())
80 .registerTypeAdapter(ControllerStatus.class, new ResponseDeserializer<ControllerStatus>()).create();
82 private static final String GRAPH_URL = "https://app.hydrawise.com/api/v2/graph";
83 private static final String MUTATION_START_ZONE = "startZone(zoneId: %d) { status }";
84 private static final String MUTATION_START_ZONE_CUSTOM = "startZone(zoneId: %d, customRunDuration: %d) { status }";
85 private static final String MUTATION_START_ALL_ZONES = "startAllZones(controllerId: %d){ status }";
86 private static final String MUTATION_START_ALL_ZONES_CUSTOM = "startAllZones(controllerId: %d, markRunAsScheduled: false, customRunDuration: %d ){ status }";
87 private static final String MUTATION_STOP_ZONE = "stopZone(zoneId: %d) { status }";
88 private static final String MUTATION_STOP_ALL_ZONES = "stopAllZones(controllerId: %d){ status }";
89 private static final String MUTATION_SUSPEND_ZONE = "suspendZone(zoneId: %d, until: \"%s\"){ status }";
90 private static final String MUTATION_SUSPEND_ALL_ZONES = "suspendAllZones(controllerId: %d, until: \"%s\"){ status }";
91 private static final String MUTATION_RESUME_ZONE = "resumeZone(zoneId: %d){ status }";
92 private static final String MUTATION_RESUME_ALL_ZONES = "resumeAllZones(controllerId: %d){ status }";
94 private final HttpClient httpClient;
95 private final OAuthClientService oAuthService;
96 private String queryString = "";
98 public HydrawiseGraphQLClient(HttpClient httpClient, OAuthClientService oAuthService) {
99 this.httpClient = httpClient;
100 this.oAuthService = oAuthService;
104 * Sends a GrapQL query for controller data
106 * @return QueryResponse
107 * @throws HydrawiseConnectionException
108 * @throws HydrawiseAuthenticationException
110 public @Nullable QueryResponse queryControllers()
111 throws HydrawiseConnectionException, HydrawiseAuthenticationException {
113 QueryRequest query = new QueryRequest(getQueryString());
114 String queryJson = gson.toJson(query);
115 String response = sendGraphQLQuery(queryJson);
117 return gson.fromJson(response, QueryResponse.class);
118 } catch (JsonSyntaxException e) {
119 throw new HydrawiseConnectionException("Invalid Response: " + response);
121 } catch (IOException e) {
122 throw new HydrawiseConnectionException(e);
127 * Stops a given relay
130 * @throws HydrawiseConnectionException
131 * @throws HydrawiseAuthenticationException
132 * @throws HydrawiseCommandException
134 public void stopRelay(int relayId)
135 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
136 sendGraphQLMutation(String.format(MUTATION_STOP_ZONE, relayId));
140 * Stops all relays on a given controller
142 * @param controllerId
143 * @throws HydrawiseConnectionException
144 * @throws HydrawiseAuthenticationException
145 * @throws HydrawiseCommandException
147 public void stopAllRelays(int controllerId)
148 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
149 sendGraphQLMutation(String.format(MUTATION_STOP_ALL_ZONES, controllerId));
153 * Runs a relay for the default amount of time
156 * @throws HydrawiseConnectionException
157 * @throws HydrawiseAuthenticationException
158 * @throws HydrawiseCommandException
160 public void runRelay(int relayId)
161 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
162 sendGraphQLMutation(String.format(MUTATION_START_ZONE, relayId));
166 * Runs a relay for the given amount of seconds
170 * @throws HydrawiseConnectionException
171 * @throws HydrawiseAuthenticationException
172 * @throws HydrawiseCommandException
174 public void runRelay(int relayId, int seconds)
175 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
176 sendGraphQLMutation(String.format(MUTATION_START_ZONE_CUSTOM, relayId, seconds));
180 * Run all relays on a given controller for the default amount of time
182 * @param controllerId
183 * @throws HydrawiseConnectionException
184 * @throws HydrawiseAuthenticationException
185 * @throws HydrawiseCommandException
187 public void runAllRelays(int controllerId)
188 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
189 sendGraphQLMutation(String.format(MUTATION_START_ALL_ZONES, controllerId));
193 * Run all relays on a given controller for the amount of seconds
195 * @param controllerId
197 * @throws HydrawiseConnectionException
198 * @throws HydrawiseAuthenticationException
199 * @throws HydrawiseCommandException
201 public void runAllRelays(int controllerId, int seconds)
202 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
203 sendGraphQLMutation(String.format(MUTATION_START_ALL_ZONES_CUSTOM, controllerId, seconds));
207 * Suspends a given relay
210 * @throws HydrawiseConnectionException
211 * @throws HydrawiseAuthenticationException
212 * @throws HydrawiseCommandException
214 public void suspendRelay(int relayId, String until)
215 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
216 sendGraphQLMutation(String.format(MUTATION_SUSPEND_ZONE, relayId, until));
220 * Resumes a given relay
223 * @throws HydrawiseConnectionException
224 * @throws HydrawiseAuthenticationException
225 * @throws HydrawiseCommandException
227 public void resumeRelay(int relayId)
228 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
229 sendGraphQLMutation(String.format(MUTATION_RESUME_ZONE, relayId));
233 * Suspend all relays on a given controller for an amount of seconds
235 * @param controllerId
237 * @throws HydrawiseConnectionException
238 * @throws HydrawiseAuthenticationException
239 * @throws HydrawiseCommandException
241 public void suspendAllRelays(int controllerId, String until)
242 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
243 sendGraphQLMutation(String.format(MUTATION_SUSPEND_ALL_ZONES, controllerId, until));
247 * Resumes all relays on a given controller
249 * @param controllerId
250 * @throws HydrawiseConnectionException
251 * @throws HydrawiseAuthenticationException
252 * @throws HydrawiseCommandException
254 public void resumeAllRelays(int controllerId)
255 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
256 sendGraphQLMutation(String.format(MUTATION_RESUME_ALL_ZONES, controllerId));
259 private String sendGraphQLQuery(String content)
260 throws HydrawiseConnectionException, HydrawiseAuthenticationException {
261 return sendGraphQLRequest(content);
264 private void sendGraphQLMutation(String content)
265 throws HydrawiseConnectionException, HydrawiseAuthenticationException, HydrawiseCommandException {
266 Mutation mutation = new Mutation(content);
267 logger.debug("Sending Mutation {}", gson.toJson(mutation));
268 String response = sendGraphQLRequest(gson.toJson(mutation));
269 logger.debug("Mutation response {}", response);
271 MutationResponse mResponse = gson.fromJson(response, MutationResponse.class);
272 if (mResponse == null) {
273 throw new HydrawiseCommandException("Malformed response: " + response);
275 Optional<MutationResponseStatus> status = mResponse.data.values().stream().findFirst();
276 if (status.isEmpty()) {
277 throw new HydrawiseCommandException("Unknown response: " + response);
279 if (status.get().status != StatusCode.OK) {
280 throw new HydrawiseCommandException("Command Status: " + status.get().status.name());
282 } catch (JsonSyntaxException e) {
283 throw new HydrawiseConnectionException("Invalid Response: " + response);
287 private String sendGraphQLRequest(String content)
288 throws HydrawiseConnectionException, HydrawiseAuthenticationException {
289 logger.trace("Sending Request: {}", content);
290 ContentResponse response;
291 final AtomicInteger responseCode = new AtomicInteger(0);
292 final StringBuilder responseMessage = new StringBuilder();
294 AccessTokenResponse token = oAuthService.getAccessTokenResponse();
296 throw new HydrawiseAuthenticationException("Login required");
298 response = httpClient.newRequest(GRAPH_URL).method(HttpMethod.POST)
299 .content(new StringContentProvider(content), "application/json")
300 .header("Authorization", token.getTokenType() + " " + token.getAccessToken())
301 .onResponseFailure(new Response.FailureListener() {
303 public void onFailure(@Nullable Response response, @Nullable Throwable failure) {
304 int status = response != null ? response.getStatus() : -1;
305 String reason = response != null ? response.getReason() : "Null response";
306 logger.trace("onFailure code: {} message: {}", status, reason);
307 responseCode.set(status);
308 responseMessage.append(reason);
311 String stringResponse = response.getContentAsString();
312 logger.trace("Received Response: {}", stringResponse);
313 int statusCode = response.getStatus();
314 if (!HttpStatus.isSuccess(statusCode)) {
315 throw new HydrawiseConnectionException(
316 "Request failed with HTTP status code: " + statusCode + " response: " + stringResponse);
318 return stringResponse;
319 } catch (InterruptedException | TimeoutException | OAuthException | IOException e) {
320 logger.debug("Could not send request", e);
321 throw new HydrawiseConnectionException(e);
322 } catch (OAuthResponseException e) {
323 throw new HydrawiseAuthenticationException(e.getMessage());
324 } catch (ExecutionException e) {
325 // Hydrawise returns back a 40x status, but without a valid Realm , so jetty throws an exception,
326 // this allows us to catch this in a callback and handle accordingly
327 logger.debug("ExecutionException", e);
328 logger.debug("ExecutionException {} {}", responseCode.get(), responseMessage);
329 switch (responseCode.get()) {
332 throw new HydrawiseAuthenticationException(responseMessage.toString());
334 throw new HydrawiseConnectionException(e);
339 private String getQueryString() throws IOException {
340 if (queryString.isBlank()) {
341 try (InputStream inputStream = HydrawiseGraphQLClient.class.getClassLoader()
342 .getResourceAsStream("query.graphql");
343 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
344 queryString = bufferedReader.lines().collect(Collectors.joining("\n"));
350 class ResponseDeserializer<T> implements JsonDeserializer<T> {
353 public T deserialize(JsonElement je, Type type, JsonDeserializationContext jdc) throws JsonParseException {
354 return new Gson().fromJson(je, type);