]> git.basschouten.com Git - openhab-addons.git/blob
1b2af7870fa0429cd96207a5fcf817c4d50e4538
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.hdpowerview.internal;
14
15 import java.time.Duration;
16 import java.time.Instant;
17 import java.util.List;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.TimeUnit;
20 import java.util.concurrent.TimeoutException;
21
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.eclipse.jetty.client.HttpClient;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.eclipse.jetty.client.util.StringContentProvider;
28 import org.eclipse.jetty.http.HttpHeader;
29 import org.eclipse.jetty.http.HttpMethod;
30 import org.eclipse.jetty.http.HttpStatus;
31 import org.openhab.binding.hdpowerview.internal.dto.Color;
32 import org.openhab.binding.hdpowerview.internal.dto.HubFirmware;
33 import org.openhab.binding.hdpowerview.internal.dto.Scene;
34 import org.openhab.binding.hdpowerview.internal.dto.SceneCollection;
35 import org.openhab.binding.hdpowerview.internal.dto.ScheduledEvent;
36 import org.openhab.binding.hdpowerview.internal.dto.ShadeData;
37 import org.openhab.binding.hdpowerview.internal.dto.ShadePosition;
38 import org.openhab.binding.hdpowerview.internal.dto.SurveyData;
39 import org.openhab.binding.hdpowerview.internal.dto.UserData;
40 import org.openhab.binding.hdpowerview.internal.dto.requests.RepeaterBlinking;
41 import org.openhab.binding.hdpowerview.internal.dto.requests.RepeaterColor;
42 import org.openhab.binding.hdpowerview.internal.dto.requests.ShadeCalibrate;
43 import org.openhab.binding.hdpowerview.internal.dto.requests.ShadeJog;
44 import org.openhab.binding.hdpowerview.internal.dto.requests.ShadeMove;
45 import org.openhab.binding.hdpowerview.internal.dto.requests.ShadeStop;
46 import org.openhab.binding.hdpowerview.internal.dto.responses.FirmwareVersion;
47 import org.openhab.binding.hdpowerview.internal.dto.responses.Repeater;
48 import org.openhab.binding.hdpowerview.internal.dto.responses.RepeaterData;
49 import org.openhab.binding.hdpowerview.internal.dto.responses.Repeaters;
50 import org.openhab.binding.hdpowerview.internal.dto.responses.SceneCollections;
51 import org.openhab.binding.hdpowerview.internal.dto.responses.Scenes;
52 import org.openhab.binding.hdpowerview.internal.dto.responses.ScheduledEvents;
53 import org.openhab.binding.hdpowerview.internal.dto.responses.Shade;
54 import org.openhab.binding.hdpowerview.internal.dto.responses.Shades;
55 import org.openhab.binding.hdpowerview.internal.dto.responses.Survey;
56 import org.openhab.binding.hdpowerview.internal.dto.responses.UserDataResponse;
57 import org.openhab.binding.hdpowerview.internal.exceptions.HubInvalidResponseException;
58 import org.openhab.binding.hdpowerview.internal.exceptions.HubMaintenanceException;
59 import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
60 import org.openhab.binding.hdpowerview.internal.exceptions.HubShadeTimeoutException;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
63
64 import com.google.gson.Gson;
65 import com.google.gson.JsonElement;
66 import com.google.gson.JsonObject;
67 import com.google.gson.JsonParseException;
68 import com.google.gson.JsonParser;
69
70 /**
71  * JAX-RS targets for communicating with an HD PowerView hub
72  *
73  * @author Andy Lintner - Initial contribution
74  * @author Andrew Fiddian-Green - Added support for secondary rail positions
75  * @author Jacob Laursen - Added support for scene groups and automations
76  */
77 @NonNullByDefault
78 public class HDPowerViewWebTargets {
79     private static final int REQUEST_TIMEOUT_MS = 30_000;
80
81     private final Logger logger = LoggerFactory.getLogger(HDPowerViewWebTargets.class);
82
83     /*
84      * the hub returns a 423 error (resource locked) daily just after midnight;
85      * which means it is temporarily undergoing maintenance; so we use "soft"
86      * exception handling during the five minute maintenance period after a 423
87      * error is received
88      */
89     private final Duration maintenancePeriod = Duration.ofMinutes(5);
90     private Instant maintenanceScheduledEnd = Instant.MIN;
91
92     private final String base;
93     private final String firmwareVersion;
94     private final String shades;
95     private final String sceneActivate;
96     private final String scenes;
97     private final String sceneCollectionActivate;
98     private final String sceneCollections;
99     private final String scheduledEvents;
100     private final String repeaters;
101     private final String userData;
102
103     private final Gson gson = new Gson();
104     private final HttpClient httpClient;
105
106     /**
107      * helper class for passing http url query parameters
108      */
109     public static class Query {
110         private final String key;
111         private final String value;
112
113         private Query(String key, String value) {
114             this.key = key;
115             this.value = value;
116         }
117
118         public static Query of(String key, String value) {
119             return new Query(key, value);
120         }
121
122         public String getKey() {
123             return key;
124         }
125
126         public String getValue() {
127             return value;
128         }
129
130         @Override
131         public String toString() {
132             return String.format("?%s=%s", key, value);
133         }
134     }
135
136     /**
137      * Initialize the web targets
138      *
139      * @param httpClient the HTTP client (the binding)
140      * @param ipAddress the IP address of the server (the hub)
141      */
142     public HDPowerViewWebTargets(HttpClient httpClient, String ipAddress) {
143         base = "http://" + ipAddress + "/api/";
144         shades = base + "shades/";
145         firmwareVersion = base + "fwversion";
146         sceneActivate = base + "scenes";
147         scenes = base + "scenes/";
148
149         // Hub v1 only supports "scenecollections". Hub v2 will redirect to "sceneCollections".
150         sceneCollectionActivate = base + "scenecollections";
151         sceneCollections = base + "scenecollections/";
152
153         scheduledEvents = base + "scheduledevents";
154         repeaters = base + "repeaters/";
155         userData = base + "userdata";
156
157         this.httpClient = httpClient;
158     }
159
160     /**
161      * Fetches a JSON package with firmware information for the hub.
162      *
163      * @return FirmwareVersions class instance
164      * @throws HubInvalidResponseException if response is invalid
165      * @throws HubProcessingException if there is any processing error
166      * @throws HubMaintenanceException if the hub is down for maintenance
167      */
168     public HubFirmware getFirmwareVersions()
169             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
170         String json = invoke(HttpMethod.GET, firmwareVersion, null, null);
171         try {
172             FirmwareVersion firmwareVersion = gson.fromJson(json, FirmwareVersion.class);
173             if (firmwareVersion == null) {
174                 throw new HubInvalidResponseException("Missing firmware response");
175             }
176             HubFirmware firmwareVersions = firmwareVersion.firmware;
177             if (firmwareVersions == null) {
178                 throw new HubInvalidResponseException("Missing 'firmware' element");
179             }
180             return firmwareVersions;
181         } catch (JsonParseException e) {
182             throw new HubInvalidResponseException("Error parsing firmware response", e);
183         }
184     }
185
186     /**
187      * Fetches a JSON package with user data information for the hub.
188      *
189      * @return {@link UserData} class instance
190      * @throws HubInvalidResponseException if response is invalid
191      * @throws HubProcessingException if there is any processing error
192      * @throws HubMaintenanceException if the hub is down for maintenance
193      */
194     public UserData getUserData() throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
195         String json = invoke(HttpMethod.GET, userData, null, null);
196         try {
197             UserDataResponse userDataResponse = gson.fromJson(json, UserDataResponse.class);
198             if (userDataResponse == null) {
199                 throw new HubInvalidResponseException("Missing userData response");
200             }
201             UserData userData = userDataResponse.userData;
202             if (userData == null) {
203                 throw new HubInvalidResponseException("Missing 'userData' element");
204             }
205             return userData;
206         } catch (JsonParseException e) {
207             throw new HubInvalidResponseException("Error parsing userData response", e);
208         }
209     }
210
211     /**
212      * Fetches a JSON package that describes all shades in the hub, and wraps it in
213      * a Shades class instance
214      *
215      * @return Shades class instance
216      * @throws HubInvalidResponseException if response is invalid
217      * @throws HubProcessingException if there is any processing error
218      * @throws HubMaintenanceException if the hub is down for maintenance
219      */
220     public Shades getShades() throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
221         String json = invoke(HttpMethod.GET, shades, null, null);
222         try {
223             Shades shades = gson.fromJson(json, Shades.class);
224             if (shades == null) {
225                 throw new HubInvalidResponseException("Missing shades response");
226             }
227             List<ShadeData> shadeData = shades.shadeData;
228             if (shadeData == null) {
229                 throw new HubInvalidResponseException("Missing 'shades.shadeData' element");
230             }
231             return shades;
232         } catch (JsonParseException e) {
233             throw new HubInvalidResponseException("Error parsing shades response", e);
234         }
235     }
236
237     /**
238      * Instructs the hub to move a specific shade
239      *
240      * @param shadeId id of the shade to be moved
241      * @param position instance of ShadePosition containing the new position
242      * @return ShadeData class instance (with new position)
243      * @throws HubInvalidResponseException if response is invalid
244      * @throws HubProcessingException if there is any processing error
245      * @throws HubMaintenanceException if the hub is down for maintenance
246      * @throws HubShadeTimeoutException if the shade did not respond to a request
247      */
248     public ShadeData moveShade(int shadeId, ShadePosition position) throws HubInvalidResponseException,
249             HubProcessingException, HubMaintenanceException, HubShadeTimeoutException {
250         String jsonRequest = gson.toJson(new ShadeMove(position));
251         String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
252         return shadeDataFromJson(jsonResponse);
253     }
254
255     private ShadeData shadeDataFromJson(String json) throws HubInvalidResponseException, HubShadeTimeoutException {
256         try {
257             Shade shade = gson.fromJson(json, Shade.class);
258             if (shade == null) {
259                 throw new HubInvalidResponseException("Missing shade response");
260             }
261             ShadeData shadeData = shade.shade;
262             if (shadeData == null) {
263                 throw new HubInvalidResponseException("Missing 'shade.shade' element");
264             }
265             if (Boolean.TRUE.equals(shadeData.timedOut)) {
266                 throw new HubShadeTimeoutException("Timeout when sending request to the shade");
267             }
268             return shadeData;
269         } catch (JsonParseException e) {
270             throw new HubInvalidResponseException("Error parsing shade response", e);
271         }
272     }
273
274     /**
275      * Instructs the hub to stop movement of a specific shade
276      *
277      * @param shadeId id of the shade to be stopped
278      * @return ShadeData class instance (new position cannot be relied upon)
279      * @throws HubInvalidResponseException if response is invalid
280      * @throws HubProcessingException if there is any processing error
281      * @throws HubMaintenanceException if the hub is down for maintenance
282      * @throws HubShadeTimeoutException if the shade did not respond to a request
283      */
284     public ShadeData stopShade(int shadeId) throws HubInvalidResponseException, HubProcessingException,
285             HubMaintenanceException, HubShadeTimeoutException {
286         String jsonRequest = gson.toJson(new ShadeStop());
287         String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
288         return shadeDataFromJson(jsonResponse);
289     }
290
291     /**
292      * Instructs the hub to jog a specific shade
293      *
294      * @param shadeId id of the shade to be jogged
295      * @return ShadeData class instance
296      * @throws HubInvalidResponseException if response is invalid
297      * @throws HubProcessingException if there is any processing error
298      * @throws HubMaintenanceException if the hub is down for maintenance
299      * @throws HubShadeTimeoutException if the shade did not respond to a request
300      */
301     public ShadeData jogShade(int shadeId) throws HubInvalidResponseException, HubProcessingException,
302             HubMaintenanceException, HubShadeTimeoutException {
303         String jsonRequest = gson.toJson(new ShadeJog());
304         String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
305         return shadeDataFromJson(jsonResponse);
306     }
307
308     /**
309      * Instructs the hub to calibrate a specific shade
310      *
311      * @param shadeId id of the shade to be calibrated
312      * @return ShadeData class instance
313      * @throws HubInvalidResponseException if response is invalid
314      * @throws HubProcessingException if there is any processing error
315      * @throws HubMaintenanceException if the hub is down for maintenance
316      * @throws HubShadeTimeoutException if the shade did not respond to a request
317      */
318     public ShadeData calibrateShade(int shadeId) throws HubInvalidResponseException, HubProcessingException,
319             HubMaintenanceException, HubShadeTimeoutException {
320         String jsonRequest = gson.toJson(new ShadeCalibrate());
321         String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
322         return shadeDataFromJson(jsonResponse);
323     }
324
325     /**
326      * Fetches a JSON package that describes all scenes in the hub, and wraps it in
327      * a Scenes class instance
328      *
329      * @return Scenes class instance
330      * @throws HubInvalidResponseException if response is invalid
331      * @throws HubProcessingException if there is any processing error
332      * @throws HubMaintenanceException if the hub is down for maintenance
333      */
334     public Scenes getScenes() throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
335         String json = invoke(HttpMethod.GET, scenes, null, null);
336         try {
337             Scenes scenes = gson.fromJson(json, Scenes.class);
338             if (scenes == null) {
339                 throw new HubInvalidResponseException("Missing scenes response");
340             }
341             List<Scene> sceneData = scenes.sceneData;
342             if (sceneData == null) {
343                 throw new HubInvalidResponseException("Missing 'scenes.sceneData' element");
344             }
345             return scenes;
346         } catch (JsonParseException e) {
347             throw new HubInvalidResponseException("Error parsing scenes response", e);
348         }
349     }
350
351     /**
352      * Instructs the hub to execute a specific scene
353      *
354      * @param sceneId id of the scene to be executed
355      * @throws HubProcessingException if there is any processing error
356      * @throws HubMaintenanceException if the hub is down for maintenance
357      */
358     public void activateScene(int sceneId) throws HubProcessingException, HubMaintenanceException {
359         invoke(HttpMethod.GET, sceneActivate, Query.of("sceneId", Integer.toString(sceneId)), null);
360     }
361
362     /**
363      * Fetches a JSON package that describes all scene collections in the hub, and wraps it in
364      * a SceneCollections class instance
365      *
366      * @return SceneCollections class instance
367      * @throws HubInvalidResponseException if response is invalid
368      * @throws HubProcessingException if there is any processing error
369      * @throws HubMaintenanceException if the hub is down for maintenance
370      */
371     public SceneCollections getSceneCollections()
372             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
373         String json = invoke(HttpMethod.GET, sceneCollections, null, null);
374         try {
375             SceneCollections sceneCollections = gson.fromJson(json, SceneCollections.class);
376             if (sceneCollections == null) {
377                 throw new HubInvalidResponseException("Missing sceneCollections response");
378             }
379             List<SceneCollection> sceneCollectionData = sceneCollections.sceneCollectionData;
380             if (sceneCollectionData == null) {
381                 throw new HubInvalidResponseException("Missing 'sceneCollections.sceneCollectionData' element");
382             }
383             return sceneCollections;
384         } catch (JsonParseException e) {
385             throw new HubInvalidResponseException("Error parsing sceneCollections response", e);
386         }
387     }
388
389     /**
390      * Instructs the hub to execute a specific scene collection
391      *
392      * @param sceneCollectionId id of the scene collection to be executed
393      * @throws HubProcessingException if there is any processing error
394      * @throws HubMaintenanceException if the hub is down for maintenance
395      */
396     public void activateSceneCollection(int sceneCollectionId) throws HubProcessingException, HubMaintenanceException {
397         invoke(HttpMethod.GET, sceneCollectionActivate,
398                 Query.of("sceneCollectionId", Integer.toString(sceneCollectionId)), null);
399     }
400
401     /**
402      * Fetches a JSON package that describes all scheduled events in the hub, and wraps it in
403      * a ScheduledEvents class instance
404      *
405      * @return ScheduledEvents class instance
406      * @throws HubInvalidResponseException if response is invalid
407      * @throws HubProcessingException if there is any processing error
408      * @throws HubMaintenanceException if the hub is down for maintenance
409      */
410     public ScheduledEvents getScheduledEvents()
411             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
412         String json = invoke(HttpMethod.GET, scheduledEvents, null, null);
413         try {
414             ScheduledEvents scheduledEvents = gson.fromJson(json, ScheduledEvents.class);
415             if (scheduledEvents == null) {
416                 throw new HubInvalidResponseException("Missing scheduledEvents response");
417             }
418             List<ScheduledEvent> scheduledEventData = scheduledEvents.scheduledEventData;
419             if (scheduledEventData == null) {
420                 throw new HubInvalidResponseException("Missing 'scheduledEvents.scheduledEventData' element");
421             }
422             return scheduledEvents;
423         } catch (JsonParseException e) {
424             throw new HubInvalidResponseException("Error parsing scheduledEvents response", e);
425         }
426     }
427
428     /**
429      * Enables or disables a scheduled event in the hub.
430      *
431      * @param scheduledEventId id of the scheduled event to be enabled or disabled
432      * @param enable true to enable scheduled event, false to disable
433      * @throws HubInvalidResponseException if response is invalid
434      * @throws HubProcessingException if there is any processing error
435      * @throws HubMaintenanceException if the hub is down for maintenance
436      */
437     public void enableScheduledEvent(int scheduledEventId, boolean enable)
438             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
439         String uri = scheduledEvents + "/" + scheduledEventId;
440         String jsonResponse = invoke(HttpMethod.GET, uri, null, null);
441         try {
442             JsonObject jsonObject = JsonParser.parseString(jsonResponse).getAsJsonObject();
443             JsonElement scheduledEventElement = jsonObject.get("scheduledEvent");
444             if (scheduledEventElement == null) {
445                 throw new HubInvalidResponseException("Missing 'scheduledEvent' element");
446             }
447             JsonObject scheduledEventObject = scheduledEventElement.getAsJsonObject();
448             scheduledEventObject.addProperty("enabled", enable);
449             invoke(HttpMethod.PUT, uri, null, jsonObject.toString());
450         } catch (JsonParseException | IllegalStateException e) {
451             throw new HubInvalidResponseException("Error parsing scheduledEvent response", e);
452         }
453     }
454
455     /**
456      * Fetches a JSON package that describes all repeaters in the hub, and wraps it in
457      * a Repeaters class instance
458      *
459      * @return Repeaters class instance
460      * @throws HubInvalidResponseException if response is invalid
461      * @throws HubProcessingException if there is any processing error
462      * @throws HubMaintenanceException if the hub is down for maintenance
463      */
464     public Repeaters getRepeaters()
465             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
466         String json = invoke(HttpMethod.GET, repeaters, null, null);
467         try {
468             Repeaters repeaters = gson.fromJson(json, Repeaters.class);
469             if (repeaters == null) {
470                 throw new HubInvalidResponseException("Missing repeaters response");
471             }
472             List<RepeaterData> repeaterData = repeaters.repeaterData;
473             if (repeaterData == null) {
474                 throw new HubInvalidResponseException("Missing 'repeaters.repeaterData' element");
475             }
476             return repeaters;
477         } catch (JsonParseException e) {
478             throw new HubInvalidResponseException("Error parsing repeaters response", e);
479         }
480     }
481
482     /**
483      * Fetches a JSON package that describes a specific repeater in the hub, and wraps it
484      * in a RepeaterData class instance
485      *
486      * @param repeaterId id of the repeater to be fetched
487      * @return RepeaterData class instance
488      * @throws HubInvalidResponseException if response is invalid
489      * @throws HubProcessingException if there is any processing error
490      * @throws HubMaintenanceException if the hub is down for maintenance
491      */
492     public RepeaterData getRepeater(int repeaterId)
493             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
494         String jsonResponse = invoke(HttpMethod.GET, repeaters + Integer.toString(repeaterId), null, null);
495         return repeaterDataFromJson(jsonResponse);
496     }
497
498     private RepeaterData repeaterDataFromJson(String json) throws HubInvalidResponseException {
499         try {
500             Repeater repeater = gson.fromJson(json, Repeater.class);
501             if (repeater == null) {
502                 throw new HubInvalidResponseException("Missing repeater response");
503             }
504             RepeaterData repeaterData = repeater.repeater;
505             if (repeaterData == null) {
506                 throw new HubInvalidResponseException("Missing 'repeater.repeater' element");
507             }
508             return repeaterData;
509         } catch (JsonParseException e) {
510             throw new HubInvalidResponseException("Error parsing repeater response", e);
511         }
512     }
513
514     /**
515      * Instructs the hub to identify a specific repeater by blinking
516      *
517      * @param repeaterId id of the repeater to be identified
518      * @return RepeaterData class instance
519      * @throws HubInvalidResponseException if response is invalid
520      * @throws HubProcessingException if there is any processing error
521      * @throws HubMaintenanceException if the hub is down for maintenance
522      */
523     public RepeaterData identifyRepeater(int repeaterId)
524             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
525         String jsonResponse = invoke(HttpMethod.GET, repeaters + repeaterId,
526                 Query.of("identify", Boolean.toString(true)), null);
527         return repeaterDataFromJson(jsonResponse);
528     }
529
530     /**
531      * Enables or disables blinking for a repeater
532      *
533      * @param repeaterId id of the repeater for which to be enable or disable blinking
534      * @param enable true to enable blinking, false to disable
535      * @return RepeaterData class instance
536      * @throws HubInvalidResponseException if response is invalid
537      * @throws HubProcessingException if there is any processing error
538      * @throws HubMaintenanceException if the hub is down for maintenance
539      */
540     public RepeaterData enableRepeaterBlinking(int repeaterId, boolean enable)
541             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
542         String jsonRequest = gson.toJson(new RepeaterBlinking(repeaterId, enable));
543         String jsonResponse = invoke(HttpMethod.PUT, repeaters + repeaterId, null, jsonRequest);
544         return repeaterDataFromJson(jsonResponse);
545     }
546
547     /**
548      * Sets color and brightness for a repeater
549      *
550      * @param repeaterId id of the repeater for which to set color and brightness
551      * @return RepeaterData class instance
552      * @throws HubInvalidResponseException if response is invalid
553      * @throws HubProcessingException if there is any processing error
554      * @throws HubMaintenanceException if the hub is down for maintenance
555      */
556     public RepeaterData setRepeaterColor(int repeaterId, Color color)
557             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
558         String jsonRequest = gson.toJson(new RepeaterColor(repeaterId, color));
559         String jsonResponse = invoke(HttpMethod.PUT, repeaters + repeaterId, null, jsonRequest);
560         return repeaterDataFromJson(jsonResponse);
561     }
562
563     /**
564      * Invoke a call on the hub server to retrieve information or send a command
565      *
566      * @param method GET or PUT
567      * @param url the host url to be called
568      * @param query the http query parameter
569      * @param jsonCommand the request command content (as a json string)
570      * @return the response content (as a json string)
571      * @throws HubMaintenanceException
572      * @throws HubProcessingException
573      */
574     private synchronized String invoke(HttpMethod method, String url, @Nullable Query query,
575             @Nullable String jsonCommand) throws HubMaintenanceException, HubProcessingException {
576         if (logger.isTraceEnabled()) {
577             if (query != null) {
578                 logger.trace("API command {} {}{}", method, url, query);
579             } else {
580                 logger.trace("API command {} {}", method, url);
581             }
582             if (jsonCommand != null) {
583                 logger.trace("JSON command = {}", jsonCommand);
584             }
585         }
586         Request request = httpClient.newRequest(url).method(method).header("Connection", "close").accept("*/*")
587                 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
588         if (query != null) {
589             request.param(query.getKey(), query.getValue());
590         }
591         if (jsonCommand != null) {
592             request.header(HttpHeader.CONTENT_TYPE, "application/json").content(new StringContentProvider(jsonCommand));
593         }
594         ContentResponse response;
595         try {
596             response = request.send();
597         } catch (InterruptedException e) {
598             Thread.currentThread().interrupt();
599             throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
600         } catch (TimeoutException | ExecutionException e) {
601             if (Instant.now().isBefore(maintenanceScheduledEnd)) {
602                 // throw "softer" exception during maintenance window
603                 logger.debug("Hub still undergoing maintenance");
604                 throw new HubMaintenanceException("Hub still undergoing maintenance");
605             }
606             throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
607         }
608         int statusCode = response.getStatus();
609         if (statusCode == HttpStatus.LOCKED_423) {
610             // set end of maintenance window, and throw a "softer" exception
611             maintenanceScheduledEnd = Instant.now().plus(maintenancePeriod);
612             logger.debug("Hub undergoing maintenance");
613             throw new HubMaintenanceException("Hub undergoing maintenance");
614         }
615         if (statusCode != HttpStatus.OK_200) {
616             logger.warn("Hub returned HTTP {} {}", statusCode, response.getReason());
617             throw new HubProcessingException(String.format("HTTP %d error", statusCode));
618         }
619         String jsonResponse = response.getContentAsString();
620         if (logger.isTraceEnabled()) {
621             logger.trace("JSON response = {}", jsonResponse);
622         }
623         if (jsonResponse == null || jsonResponse.isEmpty()) {
624             logger.warn("Hub returned no content");
625             throw new HubProcessingException("Missing response entity");
626         }
627         return jsonResponse;
628     }
629
630     /**
631      * Fetches a JSON package that describes a specific shade in the hub, and wraps it
632      * in a Shade class instance
633      *
634      * @param shadeId id of the shade to be fetched
635      * @return ShadeData class instance
636      * @throws HubInvalidResponseException if response is invalid
637      * @throws HubProcessingException if there is any processing error
638      * @throws HubMaintenanceException if the hub is down for maintenance
639      * @throws HubShadeTimeoutException if the shade did not respond to a request
640      */
641     public ShadeData getShade(int shadeId) throws HubInvalidResponseException, HubProcessingException,
642             HubMaintenanceException, HubShadeTimeoutException {
643         String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId), null, null);
644         return shadeDataFromJson(jsonResponse);
645     }
646
647     /**
648      * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
649      * a specific shade's position; fetches a JSON package that describes that shade,
650      * and wraps it in a Shade class instance
651      *
652      * @param shadeId id of the shade to be refreshed
653      * @return ShadeData class instance
654      * @throws HubInvalidResponseException if response is invalid
655      * @throws HubProcessingException if there is any processing error
656      * @throws HubMaintenanceException if the hub is down for maintenance
657      * @throws HubShadeTimeoutException if the shade did not respond to a request
658      */
659     public ShadeData refreshShadePosition(int shadeId)
660             throws JsonParseException, HubProcessingException, HubMaintenanceException, HubShadeTimeoutException {
661         String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
662                 Query.of("refresh", Boolean.toString(true)), null);
663         return shadeDataFromJson(jsonResponse);
664     }
665
666     /**
667      * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
668      * a specific shade's survey data, which will also refresh signal strength;
669      * fetches a JSON package that describes that survey, and wraps it in a Survey
670      * class instance
671      *
672      * @param shadeId id of the shade to be surveyed
673      * @return List of SurveyData class instances
674      * @throws HubInvalidResponseException if response is invalid
675      * @throws HubProcessingException if there is any processing error
676      * @throws HubMaintenanceException if the hub is down for maintenance
677      */
678     public List<SurveyData> getShadeSurvey(int shadeId)
679             throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
680         String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
681                 Query.of("survey", Boolean.toString(true)), null);
682         try {
683             Survey survey = gson.fromJson(jsonResponse, Survey.class);
684             if (survey == null) {
685                 throw new HubInvalidResponseException("Missing survey response");
686             }
687             List<SurveyData> surveyData = survey.surveyData;
688             if (surveyData == null) {
689                 throw new HubInvalidResponseException("Missing 'survey.surveyData' element");
690             }
691             return surveyData;
692         } catch (JsonParseException e) {
693             throw new HubInvalidResponseException("Error parsing survey response", e);
694         }
695     }
696
697     /**
698      * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
699      * a specific shade's battery level; fetches a JSON package that describes that shade,
700      * and wraps it in a Shade class instance
701      *
702      * @param shadeId id of the shade to be refreshed
703      * @return ShadeData class instance
704      * @throws HubInvalidResponseException if response is invalid
705      * @throws HubProcessingException if there is any processing error
706      * @throws HubMaintenanceException if the hub is down for maintenance
707      * @throws HubShadeTimeoutException if the shade did not respond to a request
708      */
709     public ShadeData refreshShadeBatteryLevel(int shadeId) throws HubInvalidResponseException, HubProcessingException,
710             HubMaintenanceException, HubShadeTimeoutException {
711         String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
712                 Query.of("updateBatteryLevel", Boolean.toString(true)), null);
713         return shadeDataFromJson(jsonResponse);
714     }
715 }