2 * Copyright (c) 2010-2022 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.hdpowerview.internal;
15 import java.time.Instant;
16 import java.util.List;
17 import java.util.concurrent.ExecutionException;
18 import java.util.concurrent.TimeoutException;
20 import org.eclipse.jdt.annotation.NonNullByDefault;
21 import org.eclipse.jdt.annotation.Nullable;
22 import org.eclipse.jetty.client.HttpClient;
23 import org.eclipse.jetty.client.api.ContentResponse;
24 import org.eclipse.jetty.client.api.Request;
25 import org.eclipse.jetty.client.util.StringContentProvider;
26 import org.eclipse.jetty.http.HttpHeader;
27 import org.eclipse.jetty.http.HttpMethod;
28 import org.eclipse.jetty.http.HttpStatus;
29 import org.openhab.binding.hdpowerview.internal.api.ShadePosition;
30 import org.openhab.binding.hdpowerview.internal.api.requests.RepeaterBlinking;
31 import org.openhab.binding.hdpowerview.internal.api.requests.ShadeCalibrate;
32 import org.openhab.binding.hdpowerview.internal.api.requests.ShadeMove;
33 import org.openhab.binding.hdpowerview.internal.api.requests.ShadeStop;
34 import org.openhab.binding.hdpowerview.internal.api.responses.FirmwareVersion;
35 import org.openhab.binding.hdpowerview.internal.api.responses.FirmwareVersions;
36 import org.openhab.binding.hdpowerview.internal.api.responses.Repeater;
37 import org.openhab.binding.hdpowerview.internal.api.responses.RepeaterData;
38 import org.openhab.binding.hdpowerview.internal.api.responses.Repeaters;
39 import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections;
40 import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections.SceneCollection;
41 import org.openhab.binding.hdpowerview.internal.api.responses.Scenes;
42 import org.openhab.binding.hdpowerview.internal.api.responses.Scenes.Scene;
43 import org.openhab.binding.hdpowerview.internal.api.responses.ScheduledEvents;
44 import org.openhab.binding.hdpowerview.internal.api.responses.ScheduledEvents.ScheduledEvent;
45 import org.openhab.binding.hdpowerview.internal.api.responses.Shade;
46 import org.openhab.binding.hdpowerview.internal.api.responses.Shades;
47 import org.openhab.binding.hdpowerview.internal.api.responses.Shades.ShadeData;
48 import org.openhab.binding.hdpowerview.internal.api.responses.Survey;
49 import org.openhab.binding.hdpowerview.internal.exceptions.HubInvalidResponseException;
50 import org.openhab.binding.hdpowerview.internal.exceptions.HubMaintenanceException;
51 import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.google.gson.Gson;
56 import com.google.gson.JsonElement;
57 import com.google.gson.JsonObject;
58 import com.google.gson.JsonParseException;
59 import com.google.gson.JsonParser;
62 * JAX-RS targets for communicating with an HD PowerView hub
64 * @author Andy Lintner - Initial contribution
65 * @author Andrew Fiddian-Green - Added support for secondary rail positions
66 * @author Jacob Laursen - Added support for scene groups and automations
69 public class HDPowerViewWebTargets {
71 private final Logger logger = LoggerFactory.getLogger(HDPowerViewWebTargets.class);
74 * the hub returns a 423 error (resource locked) daily just after midnight;
75 * which means it is temporarily undergoing maintenance; so we use "soft"
76 * exception handling during the five minute maintenance period after a 423
79 private final int maintenancePeriod = 300;
80 private Instant maintenanceScheduledEnd = Instant.now().minusSeconds(2 * maintenancePeriod);
82 private final String base;
83 private final String firmwareVersion;
84 private final String shades;
85 private final String sceneActivate;
86 private final String scenes;
87 private final String sceneCollectionActivate;
88 private final String sceneCollections;
89 private final String scheduledEvents;
90 private final String repeaters;
92 private final Gson gson = new Gson();
93 private final HttpClient httpClient;
96 * private helper class for passing http url query parameters
98 private static class Query {
99 private final String key;
100 private final String value;
102 private Query(String key, String value) {
107 public static Query of(String key, String value) {
108 return new Query(key, value);
111 public String getKey() {
115 public String getValue() {
120 public String toString() {
121 return String.format("?%s=%s", key, value);
126 * Initialize the web targets
128 * @param httpClient the HTTP client (the binding)
129 * @param ipAddress the IP address of the server (the hub)
131 public HDPowerViewWebTargets(HttpClient httpClient, String ipAddress) {
132 base = "http://" + ipAddress + "/api/";
133 shades = base + "shades/";
134 firmwareVersion = base + "fwversion/";
135 sceneActivate = base + "scenes";
136 scenes = base + "scenes/";
138 // Hub v1 only supports "scenecollections". Hub v2 will redirect to "sceneCollections".
139 sceneCollectionActivate = base + "scenecollections";
140 sceneCollections = base + "scenecollections/";
142 scheduledEvents = base + "scheduledevents";
144 repeaters = base + "repeaters/";
146 this.httpClient = httpClient;
150 * Fetches a JSON package with firmware information for the hub.
152 * @return FirmwareVersions class instance
153 * @throws HubInvalidResponseException if response is invalid
154 * @throws HubProcessingException if there is any processing error
155 * @throws HubMaintenanceException if the hub is down for maintenance
157 public FirmwareVersions getFirmwareVersions()
158 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
159 String json = invoke(HttpMethod.GET, firmwareVersion, null, null);
161 FirmwareVersion firmwareVersion = gson.fromJson(json, FirmwareVersion.class);
162 if (firmwareVersion == null) {
163 throw new HubInvalidResponseException("Missing firmware response");
165 FirmwareVersions firmwareVersions = firmwareVersion.firmware;
166 if (firmwareVersions == null) {
167 throw new HubInvalidResponseException("Missing 'firmware' element");
169 return firmwareVersions;
170 } catch (JsonParseException e) {
171 throw new HubInvalidResponseException("Error parsing firmware response", e);
176 * Fetches a JSON package that describes all shades in the hub, and wraps it in
177 * a Shades class instance
179 * @return Shades class instance
180 * @throws HubInvalidResponseException if response is invalid
181 * @throws HubProcessingException if there is any processing error
182 * @throws HubMaintenanceException if the hub is down for maintenance
184 public Shades getShades() throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
185 String json = invoke(HttpMethod.GET, shades, null, null);
187 Shades shades = gson.fromJson(json, Shades.class);
188 if (shades == null) {
189 throw new HubInvalidResponseException("Missing shades response");
191 List<ShadeData> shadeData = shades.shadeData;
192 if (shadeData == null) {
193 throw new HubInvalidResponseException("Missing 'shades.shadeData' element");
196 } catch (JsonParseException e) {
197 throw new HubInvalidResponseException("Error parsing shades response", e);
202 * Instructs the hub to move a specific shade
204 * @param shadeId id of the shade to be moved
205 * @param position instance of ShadePosition containing the new position
206 * @return ShadeData class instance (with new position)
207 * @throws HubInvalidResponseException if response is invalid
208 * @throws HubProcessingException if there is any processing error
209 * @throws HubMaintenanceException if the hub is down for maintenance
211 public ShadeData moveShade(int shadeId, ShadePosition position)
212 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
213 String jsonRequest = gson.toJson(new ShadeMove(position));
214 String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
215 return shadeDataFromJson(jsonResponse);
218 private ShadeData shadeDataFromJson(String json) throws HubInvalidResponseException {
220 Shade shade = gson.fromJson(json, Shade.class);
222 throw new HubInvalidResponseException("Missing shade response");
224 ShadeData shadeData = shade.shade;
225 if (shadeData == null) {
226 throw new HubInvalidResponseException("Missing 'shade.shade' element");
229 } catch (JsonParseException e) {
230 throw new HubInvalidResponseException("Error parsing shade response", e);
235 * Instructs the hub to stop movement of a specific shade
237 * @param shadeId id of the shade to be stopped
238 * @return ShadeData class instance (new position cannot be relied upon)
239 * @throws HubInvalidResponseException if response is invalid
240 * @throws HubProcessingException if there is any processing error
241 * @throws HubMaintenanceException if the hub is down for maintenance
243 public ShadeData stopShade(int shadeId)
244 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
245 String jsonRequest = gson.toJson(new ShadeStop());
246 String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
247 return shadeDataFromJson(jsonResponse);
251 * Instructs the hub to calibrate a specific shade
253 * @param shadeId id of the shade to be calibrated
254 * @return ShadeData class instance
255 * @throws HubInvalidResponseException if response is invalid
256 * @throws HubProcessingException if there is any processing error
257 * @throws HubMaintenanceException if the hub is down for maintenance
259 public ShadeData calibrateShade(int shadeId)
260 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
261 String jsonRequest = gson.toJson(new ShadeCalibrate());
262 String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
263 return shadeDataFromJson(jsonResponse);
267 * Fetches a JSON package that describes all scenes in the hub, and wraps it in
268 * a Scenes class instance
270 * @return Scenes class instance
271 * @throws HubInvalidResponseException if response is invalid
272 * @throws HubProcessingException if there is any processing error
273 * @throws HubMaintenanceException if the hub is down for maintenance
275 public Scenes getScenes() throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
276 String json = invoke(HttpMethod.GET, scenes, null, null);
278 Scenes scenes = gson.fromJson(json, Scenes.class);
279 if (scenes == null) {
280 throw new HubInvalidResponseException("Missing scenes response");
282 List<Scene> sceneData = scenes.sceneData;
283 if (sceneData == null) {
284 throw new HubInvalidResponseException("Missing 'scenes.sceneData' element");
287 } catch (JsonParseException e) {
288 throw new HubInvalidResponseException("Error parsing scenes response", e);
293 * Instructs the hub to execute a specific scene
295 * @param sceneId id of the scene to be executed
296 * @throws HubProcessingException if there is any processing error
297 * @throws HubMaintenanceException if the hub is down for maintenance
299 public void activateScene(int sceneId) throws HubProcessingException, HubMaintenanceException {
300 invoke(HttpMethod.GET, sceneActivate, Query.of("sceneId", Integer.toString(sceneId)), null);
304 * Fetches a JSON package that describes all scene collections in the hub, and wraps it in
305 * a SceneCollections class instance
307 * @return SceneCollections class instance
308 * @throws HubInvalidResponseException if response is invalid
309 * @throws HubProcessingException if there is any processing error
310 * @throws HubMaintenanceException if the hub is down for maintenance
312 public SceneCollections getSceneCollections()
313 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
314 String json = invoke(HttpMethod.GET, sceneCollections, null, null);
316 SceneCollections sceneCollections = gson.fromJson(json, SceneCollections.class);
317 if (sceneCollections == null) {
318 throw new HubInvalidResponseException("Missing sceneCollections response");
320 List<SceneCollection> sceneCollectionData = sceneCollections.sceneCollectionData;
321 if (sceneCollectionData == null) {
322 throw new HubInvalidResponseException("Missing 'sceneCollections.sceneCollectionData' element");
324 return sceneCollections;
325 } catch (JsonParseException e) {
326 throw new HubInvalidResponseException("Error parsing sceneCollections response", e);
331 * Instructs the hub to execute a specific scene collection
333 * @param sceneCollectionId id of the scene collection to be executed
334 * @throws HubProcessingException if there is any processing error
335 * @throws HubMaintenanceException if the hub is down for maintenance
337 public void activateSceneCollection(int sceneCollectionId) throws HubProcessingException, HubMaintenanceException {
338 invoke(HttpMethod.GET, sceneCollectionActivate,
339 Query.of("sceneCollectionId", Integer.toString(sceneCollectionId)), null);
343 * Fetches a JSON package that describes all scheduled events in the hub, and wraps it in
344 * a ScheduledEvents class instance
346 * @return ScheduledEvents class instance
347 * @throws HubInvalidResponseException if response is invalid
348 * @throws HubProcessingException if there is any processing error
349 * @throws HubMaintenanceException if the hub is down for maintenance
351 public ScheduledEvents getScheduledEvents()
352 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
353 String json = invoke(HttpMethod.GET, scheduledEvents, null, null);
355 ScheduledEvents scheduledEvents = gson.fromJson(json, ScheduledEvents.class);
356 if (scheduledEvents == null) {
357 throw new HubInvalidResponseException("Missing scheduledEvents response");
359 List<ScheduledEvent> scheduledEventData = scheduledEvents.scheduledEventData;
360 if (scheduledEventData == null) {
361 throw new HubInvalidResponseException("Missing 'scheduledEvents.scheduledEventData' element");
363 return scheduledEvents;
364 } catch (JsonParseException e) {
365 throw new HubInvalidResponseException("Error parsing scheduledEvents response", e);
370 * Enables or disables a scheduled event in the hub.
372 * @param scheduledEventId id of the scheduled event to be enabled or disabled
373 * @param enable true to enable scheduled event, false to disable
374 * @throws HubInvalidResponseException if response is invalid
375 * @throws HubProcessingException if there is any processing error
376 * @throws HubMaintenanceException if the hub is down for maintenance
378 public void enableScheduledEvent(int scheduledEventId, boolean enable)
379 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
380 String uri = scheduledEvents + "/" + scheduledEventId;
381 String jsonResponse = invoke(HttpMethod.GET, uri, null, null);
383 JsonObject jsonObject = JsonParser.parseString(jsonResponse).getAsJsonObject();
384 JsonElement scheduledEventElement = jsonObject.get("scheduledEvent");
385 if (scheduledEventElement == null) {
386 throw new HubInvalidResponseException("Missing 'scheduledEvent' element");
388 JsonObject scheduledEventObject = scheduledEventElement.getAsJsonObject();
389 scheduledEventObject.addProperty("enabled", enable);
390 invoke(HttpMethod.PUT, uri, null, jsonObject.toString());
391 } catch (JsonParseException | IllegalStateException e) {
392 throw new HubInvalidResponseException("Error parsing scheduledEvent response", e);
397 * Fetches a JSON package that describes all repeaters in the hub, and wraps it in
398 * a Repeaters class instance
400 * @return Repeaters class instance
401 * @throws HubInvalidResponseException if response is invalid
402 * @throws HubProcessingException if there is any processing error
403 * @throws HubMaintenanceException if the hub is down for maintenance
405 public Repeaters getRepeaters()
406 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
407 String json = invoke(HttpMethod.GET, repeaters, null, null);
409 Repeaters repeaters = gson.fromJson(json, Repeaters.class);
410 if (repeaters == null) {
411 throw new HubInvalidResponseException("Missing repeaters response");
413 List<RepeaterData> repeaterData = repeaters.repeaterData;
414 if (repeaterData == null) {
415 throw new HubInvalidResponseException("Missing 'repeaters.repeaterData' element");
418 } catch (JsonParseException e) {
419 throw new HubInvalidResponseException("Error parsing repeaters response", e);
424 * Fetches a JSON package that describes a specific repeater in the hub, and wraps it
425 * in a RepeaterData class instance
427 * @param repeaterId id of the repeater to be fetched
428 * @return RepeaterData class instance
429 * @throws HubInvalidResponseException if response is invalid
430 * @throws HubProcessingException if there is any processing error
431 * @throws HubMaintenanceException if the hub is down for maintenance
433 public RepeaterData getRepeater(int repeaterId)
434 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
435 String jsonResponse = invoke(HttpMethod.GET, repeaters + Integer.toString(repeaterId), null, null);
436 return repeaterDataFromJson(jsonResponse);
439 private RepeaterData repeaterDataFromJson(String json) throws HubInvalidResponseException {
441 Repeater repeater = gson.fromJson(json, Repeater.class);
442 if (repeater == null) {
443 throw new HubInvalidResponseException("Missing repeater response");
445 RepeaterData repeaterData = repeater.repeater;
446 if (repeaterData == null) {
447 throw new HubInvalidResponseException("Missing 'repeater.repeater' element");
450 } catch (JsonParseException e) {
451 throw new HubInvalidResponseException("Error parsing repeater response", e);
456 * Instructs the hub to identify a specific repeater by blinking
458 * @param repeaterId id of the repeater to be identified
459 * @return RepeaterData 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
464 public RepeaterData identifyRepeater(int repeaterId)
465 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
466 String jsonResponse = invoke(HttpMethod.GET, repeaters + repeaterId,
467 Query.of("identify", Boolean.toString(true)), null);
468 return repeaterDataFromJson(jsonResponse);
472 * Enables or disables blinking for a repeater
474 * @param repeaterId id of the repeater for which to be enable or disable blinking
475 * @param enable true to enable blinking, false to disable
476 * @return RepeaterData class instance
477 * @throws HubInvalidResponseException if response is invalid
478 * @throws HubProcessingException if there is any processing error
479 * @throws HubMaintenanceException if the hub is down for maintenance
481 public RepeaterData enableRepeaterBlinking(int repeaterId, boolean enable)
482 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
483 String jsonRequest = gson.toJson(new RepeaterBlinking(repeaterId, enable));
484 String jsonResponse = invoke(HttpMethod.PUT, repeaters + repeaterId, null, jsonRequest);
485 return repeaterDataFromJson(jsonResponse);
489 * Invoke a call on the hub server to retrieve information or send a command
491 * @param method GET or PUT
492 * @param url the host url to be called
493 * @param query the http query parameter
494 * @param jsonCommand the request command content (as a json string)
495 * @return the response content (as a json string)
496 * @throws HubMaintenanceException
497 * @throws HubProcessingException
499 private synchronized String invoke(HttpMethod method, String url, @Nullable Query query,
500 @Nullable String jsonCommand) throws HubMaintenanceException, HubProcessingException {
501 if (logger.isTraceEnabled()) {
503 logger.trace("API command {} {}{}", method, url, query);
505 logger.trace("API command {} {}", method, url);
507 if (jsonCommand != null) {
508 logger.trace("JSON command = {}", jsonCommand);
511 Request request = httpClient.newRequest(url).method(method).header("Connection", "close").accept("*/*");
513 request.param(query.getKey(), query.getValue());
515 if (jsonCommand != null) {
516 request.header(HttpHeader.CONTENT_TYPE, "application/json").content(new StringContentProvider(jsonCommand));
518 ContentResponse response;
520 response = request.send();
521 } catch (InterruptedException | TimeoutException | ExecutionException e) {
522 if (Instant.now().isBefore(maintenanceScheduledEnd)) {
523 // throw "softer" exception during maintenance window
524 logger.debug("Hub still undergoing maintenance");
525 throw new HubMaintenanceException("Hub still undergoing maintenance");
527 throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
529 int statusCode = response.getStatus();
530 if (statusCode == HttpStatus.LOCKED_423) {
531 // set end of maintenance window, and throw a "softer" exception
532 maintenanceScheduledEnd = Instant.now().plusSeconds(maintenancePeriod);
533 logger.debug("Hub undergoing maintenance");
534 throw new HubMaintenanceException("Hub undergoing maintenance");
536 if (statusCode != HttpStatus.OK_200) {
537 logger.warn("Hub returned HTTP {} {}", statusCode, response.getReason());
538 throw new HubProcessingException(String.format("HTTP %d error", statusCode));
540 String jsonResponse = response.getContentAsString();
541 if ("".equals(jsonResponse)) {
542 logger.warn("Hub returned no content");
543 throw new HubProcessingException("Missing response entity");
545 if (logger.isTraceEnabled()) {
546 logger.trace("JSON response = {}", jsonResponse);
552 * Fetches a JSON package that describes a specific shade in the hub, and wraps it
553 * in a Shade class instance
555 * @param shadeId id of the shade to be fetched
556 * @return ShadeData class instance
557 * @throws HubInvalidResponseException if response is invalid
558 * @throws HubProcessingException if there is any processing error
559 * @throws HubMaintenanceException if the hub is down for maintenance
561 public ShadeData getShade(int shadeId)
562 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
563 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId), null, null);
564 return shadeDataFromJson(jsonResponse);
568 * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
569 * a specific shade's position; fetches a JSON package that describes that shade,
570 * and wraps it in a Shade class instance
572 * @param shadeId id of the shade to be refreshed
573 * @return ShadeData class instance
574 * @throws HubInvalidResponseException if response is invalid
575 * @throws HubProcessingException if there is any processing error
576 * @throws HubMaintenanceException if the hub is down for maintenance
578 public ShadeData refreshShadePosition(int shadeId)
579 throws JsonParseException, HubProcessingException, HubMaintenanceException {
580 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
581 Query.of("refresh", Boolean.toString(true)), null);
582 return shadeDataFromJson(jsonResponse);
586 * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
587 * a specific shade's survey data, which will also refresh signal strength;
588 * fetches a JSON package that describes that survey, and wraps it in a Survey
591 * @param shadeId id of the shade to be surveyed
592 * @return Survey class instance
593 * @throws HubInvalidResponseException if response is invalid
594 * @throws HubProcessingException if there is any processing error
595 * @throws HubMaintenanceException if the hub is down for maintenance
597 public Survey getShadeSurvey(int shadeId)
598 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
599 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
600 Query.of("survey", Boolean.toString(true)), null);
602 Survey survey = gson.fromJson(jsonResponse, Survey.class);
603 if (survey == null) {
604 throw new HubInvalidResponseException("Missing survey response");
607 } catch (JsonParseException e) {
608 throw new HubInvalidResponseException("Error parsing survey response", e);
613 * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
614 * a specific shade's battery level; fetches a JSON package that describes that shade,
615 * and wraps it in a Shade class instance
617 * @param shadeId id of the shade to be refreshed
618 * @return ShadeData class instance
619 * @throws HubInvalidResponseException if response is invalid
620 * @throws HubProcessingException if there is any processing error
621 * @throws HubMaintenanceException if the hub is down for maintenance
623 public ShadeData refreshShadeBatteryLevel(int shadeId)
624 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
625 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
626 Query.of("updateBatteryLevel", Boolean.toString(true)), null);
627 return shadeDataFromJson(jsonResponse);