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.ShadeCalibrate;
31 import org.openhab.binding.hdpowerview.internal.api.requests.ShadeMove;
32 import org.openhab.binding.hdpowerview.internal.api.requests.ShadeStop;
33 import org.openhab.binding.hdpowerview.internal.api.responses.FirmwareVersion;
34 import org.openhab.binding.hdpowerview.internal.api.responses.FirmwareVersions;
35 import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections;
36 import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections.SceneCollection;
37 import org.openhab.binding.hdpowerview.internal.api.responses.Scenes;
38 import org.openhab.binding.hdpowerview.internal.api.responses.Scenes.Scene;
39 import org.openhab.binding.hdpowerview.internal.api.responses.ScheduledEvents;
40 import org.openhab.binding.hdpowerview.internal.api.responses.ScheduledEvents.ScheduledEvent;
41 import org.openhab.binding.hdpowerview.internal.api.responses.Shade;
42 import org.openhab.binding.hdpowerview.internal.api.responses.Shades;
43 import org.openhab.binding.hdpowerview.internal.api.responses.Shades.ShadeData;
44 import org.openhab.binding.hdpowerview.internal.api.responses.Survey;
45 import org.openhab.binding.hdpowerview.internal.exceptions.HubInvalidResponseException;
46 import org.openhab.binding.hdpowerview.internal.exceptions.HubMaintenanceException;
47 import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
51 import com.google.gson.Gson;
52 import com.google.gson.JsonElement;
53 import com.google.gson.JsonObject;
54 import com.google.gson.JsonParseException;
55 import com.google.gson.JsonParser;
58 * JAX-RS targets for communicating with an HD PowerView hub
60 * @author Andy Lintner - Initial contribution
61 * @author Andrew Fiddian-Green - Added support for secondary rail positions
62 * @author Jacob Laursen - Added support for scene groups and automations
65 public class HDPowerViewWebTargets {
67 private final Logger logger = LoggerFactory.getLogger(HDPowerViewWebTargets.class);
70 * the hub returns a 423 error (resource locked) daily just after midnight;
71 * which means it is temporarily undergoing maintenance; so we use "soft"
72 * exception handling during the five minute maintenance period after a 423
75 private final int maintenancePeriod = 300;
76 private Instant maintenanceScheduledEnd = Instant.now().minusSeconds(2 * maintenancePeriod);
78 private final String base;
79 private final String firmwareVersion;
80 private final String shades;
81 private final String sceneActivate;
82 private final String scenes;
83 private final String sceneCollectionActivate;
84 private final String sceneCollections;
85 private final String scheduledEvents;
87 private final Gson gson = new Gson();
88 private final HttpClient httpClient;
91 * private helper class for passing http url query parameters
93 private static class Query {
94 private final String key;
95 private final String value;
97 private Query(String key, String value) {
102 public static Query of(String key, String value) {
103 return new Query(key, value);
106 public String getKey() {
110 public String getValue() {
115 public String toString() {
116 return String.format("?%s=%s", key, value);
121 * Initialize the web targets
123 * @param httpClient the HTTP client (the binding)
124 * @param ipAddress the IP address of the server (the hub)
126 public HDPowerViewWebTargets(HttpClient httpClient, String ipAddress) {
127 base = "http://" + ipAddress + "/api/";
128 shades = base + "shades/";
129 firmwareVersion = base + "fwversion/";
130 sceneActivate = base + "scenes";
131 scenes = base + "scenes/";
133 // Hub v1 only supports "scenecollections". Hub v2 will redirect to "sceneCollections".
134 sceneCollectionActivate = base + "scenecollections";
135 sceneCollections = base + "scenecollections/";
137 scheduledEvents = base + "scheduledevents";
138 this.httpClient = httpClient;
142 * Fetches a JSON package with firmware information for the hub.
144 * @return FirmwareVersions class instance
145 * @throws HubInvalidResponseException if response is invalid
146 * @throws HubProcessingException if there is any processing error
147 * @throws HubMaintenanceException if the hub is down for maintenance
149 public FirmwareVersions getFirmwareVersions()
150 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
151 String json = invoke(HttpMethod.GET, firmwareVersion, null, null);
153 FirmwareVersion firmwareVersion = gson.fromJson(json, FirmwareVersion.class);
154 if (firmwareVersion == null) {
155 throw new HubInvalidResponseException("Missing firmware response");
157 FirmwareVersions firmwareVersions = firmwareVersion.firmware;
158 if (firmwareVersions == null) {
159 throw new HubInvalidResponseException("Missing 'firmware' element");
161 return firmwareVersions;
162 } catch (JsonParseException e) {
163 throw new HubInvalidResponseException("Error parsing firmware response", e);
168 * Fetches a JSON package that describes all shades in the hub, and wraps it in
169 * a Shades class instance
171 * @return Shades class instance
172 * @throws HubInvalidResponseException if response is invalid
173 * @throws HubProcessingException if there is any processing error
174 * @throws HubMaintenanceException if the hub is down for maintenance
176 public Shades getShades() throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
177 String json = invoke(HttpMethod.GET, shades, null, null);
179 Shades shades = gson.fromJson(json, Shades.class);
180 if (shades == null) {
181 throw new HubInvalidResponseException("Missing shades response");
183 List<ShadeData> shadeData = shades.shadeData;
184 if (shadeData == null) {
185 throw new HubInvalidResponseException("Missing 'shades.shadeData' element");
188 } catch (JsonParseException e) {
189 throw new HubInvalidResponseException("Error parsing shades response", e);
194 * Instructs the hub to move a specific shade
196 * @param shadeId id of the shade to be moved
197 * @param position instance of ShadePosition containing the new position
198 * @return ShadeData class instance (with new position)
199 * @throws HubInvalidResponseException if response is invalid
200 * @throws HubProcessingException if there is any processing error
201 * @throws HubMaintenanceException if the hub is down for maintenance
203 public ShadeData moveShade(int shadeId, ShadePosition position)
204 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
205 String jsonRequest = gson.toJson(new ShadeMove(position));
206 String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
207 return shadeDataFromJson(jsonResponse);
210 private ShadeData shadeDataFromJson(String json) throws HubInvalidResponseException {
212 Shade shade = gson.fromJson(json, Shade.class);
214 throw new HubInvalidResponseException("Missing shade response");
216 ShadeData shadeData = shade.shade;
217 if (shadeData == null) {
218 throw new HubInvalidResponseException("Missing 'shade.shade' element");
221 } catch (JsonParseException e) {
222 throw new HubInvalidResponseException("Error parsing shade response", e);
227 * Instructs the hub to stop movement of a specific shade
229 * @param shadeId id of the shade to be stopped
230 * @return ShadeData class instance (new position cannot be relied upon)
231 * @throws HubInvalidResponseException if response is invalid
232 * @throws HubProcessingException if there is any processing error
233 * @throws HubMaintenanceException if the hub is down for maintenance
235 public ShadeData stopShade(int shadeId)
236 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
237 String jsonRequest = gson.toJson(new ShadeStop());
238 String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
239 return shadeDataFromJson(jsonResponse);
243 * Instructs the hub to calibrate a specific shade
245 * @param shadeId id of the shade to be calibrated
246 * @return ShadeData class instance
247 * @throws HubInvalidResponseException if response is invalid
248 * @throws HubProcessingException if there is any processing error
249 * @throws HubMaintenanceException if the hub is down for maintenance
251 public ShadeData calibrateShade(int shadeId)
252 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
253 String jsonRequest = gson.toJson(new ShadeCalibrate());
254 String jsonResponse = invoke(HttpMethod.PUT, shades + Integer.toString(shadeId), null, jsonRequest);
255 return shadeDataFromJson(jsonResponse);
259 * Fetches a JSON package that describes all scenes in the hub, and wraps it in
260 * a Scenes class instance
262 * @return Scenes class instance
263 * @throws HubInvalidResponseException if response is invalid
264 * @throws HubProcessingException if there is any processing error
265 * @throws HubMaintenanceException if the hub is down for maintenance
267 public Scenes getScenes() throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
268 String json = invoke(HttpMethod.GET, scenes, null, null);
270 Scenes scenes = gson.fromJson(json, Scenes.class);
271 if (scenes == null) {
272 throw new HubInvalidResponseException("Missing scenes response");
274 List<Scene> sceneData = scenes.sceneData;
275 if (sceneData == null) {
276 throw new HubInvalidResponseException("Missing 'scenes.sceneData' element");
279 } catch (JsonParseException e) {
280 throw new HubInvalidResponseException("Error parsing scenes response", e);
285 * Instructs the hub to execute a specific scene
287 * @param sceneId id of the scene to be executed
288 * @throws HubProcessingException if there is any processing error
289 * @throws HubMaintenanceException if the hub is down for maintenance
291 public void activateScene(int sceneId) throws HubProcessingException, HubMaintenanceException {
292 invoke(HttpMethod.GET, sceneActivate, Query.of("sceneId", Integer.toString(sceneId)), null);
296 * Fetches a JSON package that describes all scene collections in the hub, and wraps it in
297 * a SceneCollections class instance
299 * @return SceneCollections class instance
300 * @throws HubInvalidResponseException if response is invalid
301 * @throws HubProcessingException if there is any processing error
302 * @throws HubMaintenanceException if the hub is down for maintenance
304 public SceneCollections getSceneCollections()
305 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
306 String json = invoke(HttpMethod.GET, sceneCollections, null, null);
308 SceneCollections sceneCollections = gson.fromJson(json, SceneCollections.class);
309 if (sceneCollections == null) {
310 throw new HubInvalidResponseException("Missing sceneCollections response");
312 List<SceneCollection> sceneCollectionData = sceneCollections.sceneCollectionData;
313 if (sceneCollectionData == null) {
314 throw new HubInvalidResponseException("Missing 'sceneCollections.sceneCollectionData' element");
316 return sceneCollections;
317 } catch (JsonParseException e) {
318 throw new HubInvalidResponseException("Error parsing sceneCollections response", e);
323 * Instructs the hub to execute a specific scene collection
325 * @param sceneCollectionId id of the scene collection to be executed
326 * @throws HubProcessingException if there is any processing error
327 * @throws HubMaintenanceException if the hub is down for maintenance
329 public void activateSceneCollection(int sceneCollectionId) throws HubProcessingException, HubMaintenanceException {
330 invoke(HttpMethod.GET, sceneCollectionActivate,
331 Query.of("sceneCollectionId", Integer.toString(sceneCollectionId)), null);
335 * Fetches a JSON package that describes all scheduled events in the hub, and wraps it in
336 * a ScheduledEvents class instance
338 * @return ScheduledEvents class instance
339 * @throws HubInvalidResponseException if response is invalid
340 * @throws HubProcessingException if there is any processing error
341 * @throws HubMaintenanceException if the hub is down for maintenance
343 public ScheduledEvents getScheduledEvents()
344 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
345 String json = invoke(HttpMethod.GET, scheduledEvents, null, null);
347 ScheduledEvents scheduledEvents = gson.fromJson(json, ScheduledEvents.class);
348 if (scheduledEvents == null) {
349 throw new HubInvalidResponseException("Missing scheduledEvents response");
351 List<ScheduledEvent> scheduledEventData = scheduledEvents.scheduledEventData;
352 if (scheduledEventData == null) {
353 throw new HubInvalidResponseException("Missing 'scheduledEvents.scheduledEventData' element");
355 return scheduledEvents;
356 } catch (JsonParseException e) {
357 throw new HubInvalidResponseException("Error parsing scheduledEvents response", e);
362 * Enables or disables a scheduled event in the hub.
364 * @param scheduledEventId id of the scheduled event to be enabled or disabled
365 * @param enable true to enable scheduled event, false to disable
366 * @throws HubInvalidResponseException if response is invalid
367 * @throws HubProcessingException if there is any processing error
368 * @throws HubMaintenanceException if the hub is down for maintenance
370 public void enableScheduledEvent(int scheduledEventId, boolean enable)
371 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
372 String uri = scheduledEvents + "/" + scheduledEventId;
373 String jsonResponse = invoke(HttpMethod.GET, uri, null, null);
375 JsonObject jsonObject = JsonParser.parseString(jsonResponse).getAsJsonObject();
376 JsonElement scheduledEventElement = jsonObject.get("scheduledEvent");
377 if (scheduledEventElement == null) {
378 throw new HubInvalidResponseException("Missing 'scheduledEvent' element");
380 JsonObject scheduledEventObject = scheduledEventElement.getAsJsonObject();
381 scheduledEventObject.addProperty("enabled", enable);
382 invoke(HttpMethod.PUT, uri, null, jsonObject.toString());
383 } catch (JsonParseException | IllegalStateException e) {
384 throw new HubInvalidResponseException("Error parsing scheduledEvent response", e);
389 * Invoke a call on the hub server to retrieve information or send a command
391 * @param method GET or PUT
392 * @param url the host url to be called
393 * @param query the http query parameter
394 * @param jsonCommand the request command content (as a json string)
395 * @return the response content (as a json string)
396 * @throws HubMaintenanceException
397 * @throws HubProcessingException
399 private synchronized String invoke(HttpMethod method, String url, @Nullable Query query,
400 @Nullable String jsonCommand) throws HubMaintenanceException, HubProcessingException {
401 if (logger.isTraceEnabled()) {
403 logger.trace("API command {} {}{}", method, url, query);
405 logger.trace("API command {} {}", method, url);
407 if (jsonCommand != null) {
408 logger.trace("JSON command = {}", jsonCommand);
411 Request request = httpClient.newRequest(url).method(method).header("Connection", "close").accept("*/*");
413 request.param(query.getKey(), query.getValue());
415 if (jsonCommand != null) {
416 request.header(HttpHeader.CONTENT_TYPE, "application/json").content(new StringContentProvider(jsonCommand));
418 ContentResponse response;
420 response = request.send();
421 } catch (InterruptedException | TimeoutException | ExecutionException e) {
422 if (Instant.now().isBefore(maintenanceScheduledEnd)) {
423 // throw "softer" exception during maintenance window
424 logger.debug("Hub still undergoing maintenance");
425 throw new HubMaintenanceException("Hub still undergoing maintenance");
427 throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
429 int statusCode = response.getStatus();
430 if (statusCode == HttpStatus.LOCKED_423) {
431 // set end of maintenance window, and throw a "softer" exception
432 maintenanceScheduledEnd = Instant.now().plusSeconds(maintenancePeriod);
433 logger.debug("Hub undergoing maintenance");
434 throw new HubMaintenanceException("Hub undergoing maintenance");
436 if (statusCode != HttpStatus.OK_200) {
437 logger.warn("Hub returned HTTP {} {}", statusCode, response.getReason());
438 throw new HubProcessingException(String.format("HTTP %d error", statusCode));
440 String jsonResponse = response.getContentAsString();
441 if ("".equals(jsonResponse)) {
442 logger.warn("Hub returned no content");
443 throw new HubProcessingException("Missing response entity");
445 if (logger.isTraceEnabled()) {
446 logger.trace("JSON response = {}", jsonResponse);
452 * Fetches a JSON package that describes a specific shade in the hub, and wraps it
453 * in a Shade class instance
455 * @param shadeId id of the shade to be fetched
456 * @return ShadeData class instance
457 * @throws HubInvalidResponseException if response is invalid
458 * @throws HubProcessingException if there is any processing error
459 * @throws HubMaintenanceException if the hub is down for maintenance
461 public ShadeData getShade(int shadeId)
462 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
463 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId), null, null);
464 return shadeDataFromJson(jsonResponse);
468 * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
469 * a specific shade's position; fetches a JSON package that describes that shade,
470 * and wraps it in a Shade class instance
472 * @param shadeId id of the shade to be refreshed
473 * @return ShadeData class instance
474 * @throws HubInvalidResponseException if response is invalid
475 * @throws HubProcessingException if there is any processing error
476 * @throws HubMaintenanceException if the hub is down for maintenance
478 public ShadeData refreshShadePosition(int shadeId)
479 throws JsonParseException, HubProcessingException, HubMaintenanceException {
480 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
481 Query.of("refresh", Boolean.toString(true)), null);
482 return shadeDataFromJson(jsonResponse);
486 * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
487 * a specific shade's survey data, which will also refresh signal strength;
488 * fetches a JSON package that describes that survey, and wraps it in a Survey
491 * @param shadeId id of the shade to be surveyed
492 * @return Survey class instance
493 * @throws HubInvalidResponseException if response is invalid
494 * @throws HubProcessingException if there is any processing error
495 * @throws HubMaintenanceException if the hub is down for maintenance
497 public Survey getShadeSurvey(int shadeId)
498 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
499 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
500 Query.of("survey", Boolean.toString(true)), null);
502 Survey survey = gson.fromJson(jsonResponse, Survey.class);
503 if (survey == null) {
504 throw new HubInvalidResponseException("Missing survey response");
507 } catch (JsonParseException e) {
508 throw new HubInvalidResponseException("Error parsing survey response", e);
513 * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on
514 * a specific shade's battery level; fetches a JSON package that describes that shade,
515 * and wraps it in a Shade class instance
517 * @param shadeId id of the shade to be refreshed
518 * @return ShadeData 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
523 public ShadeData refreshShadeBatteryLevel(int shadeId)
524 throws HubInvalidResponseException, HubProcessingException, HubMaintenanceException {
525 String jsonResponse = invoke(HttpMethod.GET, shades + Integer.toString(shadeId),
526 Query.of("updateBatteryLevel", Boolean.toString(true)), null);
527 return shadeDataFromJson(jsonResponse);