2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.hdpowerview.internal;
15 import java.io.Closeable;
16 import java.io.IOException;
17 import java.util.List;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
25 import javax.net.ssl.HostnameVerifier;
26 import javax.net.ssl.SSLSession;
27 import javax.ws.rs.client.Client;
28 import javax.ws.rs.client.ClientBuilder;
29 import javax.ws.rs.sse.InboundSseEvent;
30 import javax.ws.rs.sse.SseEventSource;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.eclipse.jetty.client.HttpClient;
35 import org.eclipse.jetty.client.api.ContentResponse;
36 import org.eclipse.jetty.client.api.Request;
37 import org.eclipse.jetty.client.util.StringContentProvider;
38 import org.eclipse.jetty.http.HttpHeader;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.eclipse.jetty.http.HttpStatus;
41 import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets.Query;
42 import org.openhab.binding.hdpowerview.internal.dto.gen3.Info;
43 import org.openhab.binding.hdpowerview.internal.dto.gen3.Scene;
44 import org.openhab.binding.hdpowerview.internal.dto.gen3.Shade;
45 import org.openhab.binding.hdpowerview.internal.dto.gen3.ShadeEvent;
46 import org.openhab.binding.hdpowerview.internal.dto.gen3.ShadePosition;
47 import org.openhab.binding.hdpowerview.internal.dto.requests.ShadeMotion;
48 import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
49 import org.openhab.binding.hdpowerview.internal.handler.GatewayBridgeHandler;
50 import org.openhab.core.thing.Thing;
51 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.google.gson.Gson;
56 import com.google.gson.JsonParseException;
59 * JAX-RS targets for communicating with an HD PowerView Generation 3 Gateway.
61 * @author Andrew Fiddian-Green - Initial contribution
64 public class GatewayWebTargets implements Closeable, HostnameVerifier {
66 private static final String IDS = "ids";
67 private static final int SLEEP_SECONDS = 360;
68 private static final Set<Integer> HTTP_OK_CODES = Set.of(HttpStatus.OK_200, HttpStatus.NO_CONTENT_204);
70 private final Logger logger = LoggerFactory.getLogger(GatewayWebTargets.class);
71 private final Gson jsonParser = new Gson();
73 private final String shades;
74 private final String scenes;
75 private final String sceneActivate;
76 private final String shadeMotion;
77 private final String shadePositions;
78 private final String shadeSingle;
79 private final String shadeStop;
80 private final String info;
81 private final String register;
82 private final String shadeEvents;
83 private final HttpClient httpClient;
84 private final ClientBuilder clientBuilder;
85 private final SseEventSourceFactory eventSourceFactory;
86 private final GatewayBridgeHandler hubHandler;
87 private final String ipAddress;
89 private boolean registered;
90 private boolean closing;
92 private @Nullable SseEventSource shadeEventSource;
93 private @Nullable ScheduledFuture<?> sseQuietCheck;
96 * Initialize the web targets
98 * @param httpClient the HTTP client (the binding)
99 * @param ipAddress the IP address of the server (the hub)
101 public GatewayWebTargets(GatewayBridgeHandler hubHandler, HttpClient httpClient, ClientBuilder clientBuilder,
102 SseEventSourceFactory eventSourceFactory, String ipAddress) {
103 this.ipAddress = ipAddress;
104 this.httpClient = httpClient;
105 this.clientBuilder = clientBuilder;
106 this.eventSourceFactory = eventSourceFactory;
107 this.hubHandler = hubHandler;
109 String base = "http://" + ipAddress + "/";
110 String home = base + "home/";
111 shades = home + "shades";
112 scenes = home + "scenes";
113 sceneActivate = home + "scenes/%d/activate";
114 shadeMotion = home + "shades/%d/motion";
115 shadePositions = home + "shades/positions";
116 shadeSingle = home + "shades/%d";
117 shadeStop = home + "shades/stop";
118 shadeEvents = home + "shades/events?sse=true";
119 info = base + "gateway/info";
122 * Hunter Douglas keeps a statistical count of systems (e.g. openHAB, Home Assistant, Amazon etc.) that are
123 * using their Generation 3 REST API to connect to their gateways. So we are asked to register with the gateway
124 * on startup. => So do not change the 'openhab.org' tag below !!
126 register = home + "integration/openhab.org";
130 * Issue a command to activate a scene.
132 * @param sceneId the scene to be activated.
133 * @throws HubProcessingException if any error occurs.
135 public void activateScene(int sceneId) throws HubProcessingException {
136 invoke(HttpMethod.PUT, String.format(sceneActivate, sceneId), null, null);
140 public void close() throws IOException {
146 * Register the binding with the hub (if not already registered).
148 * @throws HubProcessingException if any error occurs.
150 public boolean gatewayRegister() throws HubProcessingException {
152 invoke(HttpMethod.PUT, register, null, null);
159 * Get hub properties.
161 * @return a map containing the hub properties.
162 * @throws HubProcessingException if any error occurs.
164 public Map<String, String> getInformation() throws HubProcessingException {
165 String json = invoke(HttpMethod.GET, info, null, null);
167 Info result = jsonParser.fromJson(json, Info.class);
168 if (result == null) {
169 throw new HubProcessingException("getInformation(): missing response");
172 Thing.PROPERTY_FIRMWARE_VERSION, result.getFwVersion(), //
173 Thing.PROPERTY_SERIAL_NUMBER, result.getSerialNumber());
174 } catch (JsonParseException e) {
175 throw new HubProcessingException("getFirmwareVersions(): JsonParseException");
180 * Get the list of scenes.
182 * @return the list of scenes.
183 * @throws HubProcessingException if any error occurs.
185 public List<Scene> getScenes() throws HubProcessingException {
186 String json = invoke(HttpMethod.GET, scenes, null, null);
188 return List.of(jsonParser.fromJson(json, Scene[].class));
189 } catch (JsonParseException e) {
190 throw new HubProcessingException("getScenes() JsonParseException");
195 * Get the data for a single shade.
197 * @param shadeId the id of the shade to get.
199 * @throws HubProcessingException if any error occurs.
201 public Shade getShade(int shadeId) throws HubProcessingException {
202 String json = invoke(HttpMethod.GET, String.format(shadeSingle, shadeId), null, null);
204 Shade result = jsonParser.fromJson(json, Shade.class);
205 if (result == null) {
206 throw new HubProcessingException("getShade() missing response");
209 } catch (JsonParseException e) {
210 throw new HubProcessingException("getShade() JsonParseException");
215 * Get the list of shades.
217 * @return the list of shades.
218 * @throws HubProcessingException if any error occurs.
220 public List<Shade> getShades() throws HubProcessingException {
221 String json = invoke(HttpMethod.GET, shades, null, null);
223 return List.of(jsonParser.fromJson(json, Shade[].class));
224 } catch (JsonParseException e) {
225 throw new HubProcessingException("getShades() JsonParseException");
230 * Invoke a call on the hub server to retrieve information or send a command.
232 * @param method GET or PUT.
233 * @param url the host URL to be called.
234 * @param query the HTTP query parameter.
235 * @param jsonCommand the request command content (as a JSON string).
236 * @return the response content (as a JSON string).
237 * @throws HubProcessingException if something goes wrong.
239 protected synchronized String invoke(HttpMethod method, String url, @Nullable Query query,
240 @Nullable String jsonCommand) throws HubProcessingException {
241 if (logger.isTraceEnabled()) {
243 logger.trace("invoke() method:{}, url:{}, query:{}", method, url, query);
245 logger.trace("invoke() method:{}, url:{}", method, url);
247 if (jsonCommand != null) {
248 logger.trace("invoke() request JSON:{}", jsonCommand);
251 Request request = httpClient.newRequest(url).method(method).header("Connection", "close").accept("*/*");
253 request.param(query.getKey(), query.getValue());
255 if (jsonCommand != null) {
256 request.header(HttpHeader.CONTENT_TYPE, "application/json").content(new StringContentProvider(jsonCommand));
258 ContentResponse response;
260 response = request.send();
261 } catch (InterruptedException e) {
262 Thread.currentThread().interrupt();
263 throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
264 } catch (TimeoutException | ExecutionException e) {
265 throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
267 int statusCode = response.getStatus();
268 if (!HTTP_OK_CODES.contains(statusCode)) {
269 throw new HubProcessingException(String.format("HTTP %d error", statusCode));
271 String jsonResponse = response.getContentAsString();
272 if (logger.isTraceEnabled()) {
273 logger.trace("invoke() response JSON:{}", jsonResponse);
275 if (method == HttpMethod.GET && jsonResponse.isEmpty()) {
276 throw new HubProcessingException("Empty response entity");
282 * Issue a jog command to a shade.
284 * @param shadeId the shade to be jogged.
285 * @throws HubProcessingException if any error occurs.
287 public void jogShade(int shadeId) throws HubProcessingException {
288 String json = jsonParser.toJson(new ShadeMotion(ShadeMotion.Type.JOG));
289 invoke(HttpMethod.PUT, String.format(shadeMotion, shadeId), null, json);
293 * Issue a command to move a shade.
295 * @param shadeId the shade to be moved.
296 * @param shade DTO with the new position.
297 * @throws HubProcessingException if any error occurs.
299 public void moveShade(int shadeId, Shade shade) throws HubProcessingException {
300 invoke(HttpMethod.PUT, shadePositions, Query.of(IDS, Integer.toString(shadeId)), jsonParser.toJson(shade));
304 * Called when the SSE event channel has not received any events for a long time. This could mean that the event
305 * source socket has dropped. So restart the SSE connection.
307 public void onSseQuiet() {
314 * Handle SSE errors. For the time being just log them, because the framework should automatically recover itself.
316 * @param e the error that was thrown.
318 private void onSseError(Throwable e) {
320 logger.debug("onSseShadeError() {}", e.getMessage(), e);
325 * Handle inbound shade SSE events.
327 * @param sseEvent the inbound event.
329 private void onSSeEvent(InboundSseEvent sseEvent) {
333 ScheduledFuture<?> task = sseQuietCheck;
334 if (task != null && !task.isCancelled()) {
337 sseQuietCheck = hubHandler.getScheduler().schedule(this::onSseQuiet, SLEEP_SECONDS, TimeUnit.SECONDS);
338 if (sseEvent.isEmpty()) {
341 String json = sseEvent.readData();
345 logger.trace("onSseShadeEvent() json:{}", json);
346 ShadeEvent shadeEvent = jsonParser.fromJson(json, ShadeEvent.class);
347 if (shadeEvent != null) {
348 ShadePosition positions = shadeEvent.getCurrentPositions();
350 .onShadeEvent(new Shade().setId(shadeEvent.getId()).setShadePosition(positions).setPartialState());
355 * Close the SSE links.
357 private synchronized void sseClose() {
358 logger.debug("sseClose() called");
359 ScheduledFuture<?> task = sseQuietCheck;
362 sseQuietCheck = null;
364 SseEventSource source;
365 source = this.shadeEventSource;
366 if (source != null) {
368 this.shadeEventSource = null;
373 * Open the SSE links.
375 public synchronized void sseOpen() {
378 logger.debug("sseOpen() called");
379 Client client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
380 .hostnameVerifier(null).hostnameVerifier(this).readTimeout(0, TimeUnit.SECONDS).build();
383 // open SSE channel for shades
384 SseEventSource shadeEventSource = eventSourceFactory.newSource(client.target(shadeEvents));
385 shadeEventSource.register(this::onSSeEvent, this::onSseError);
386 shadeEventSource.open();
387 this.shadeEventSource = shadeEventSource;
388 } catch (Exception e) {
389 // SSE documentation does not say what exceptions may be thrown, so catch everything
390 logger.warn("sseOpen() {}", e.getMessage(), e);
395 * Reopen the SSE links. If the event source already exists, try first to simply close and re-open it, but if that
396 * fails, then completely destroy and re-create it.
398 private synchronized void sseReOpen() {
399 logger.debug("sseReOpen() called");
400 SseEventSource shadeEventSource = this.shadeEventSource;
401 if (shadeEventSource != null) {
403 if (shadeEventSource.isOpen()) {
404 shadeEventSource.close();
406 if (!shadeEventSource.isOpen()) {
407 shadeEventSource.open();
410 } catch (Exception e) {
411 // SSE documentation does not say what exceptions may be thrown, so catch everything
412 logger.warn("sseReOpen() {}", e.getMessage(), e);
419 * Issue a stop command to a shade.
421 * @param shadeId the shade to be stopped.
422 * @throws HubProcessingException if any error occurs.
424 public void stopShade(int shadeId) throws HubProcessingException {
425 invoke(HttpMethod.PUT, shadeStop, Query.of(IDS, Integer.valueOf(shadeId).toString()), null);
429 * HostnameVerifier method implementation that validates the host name when opening SSE connections.
431 * @param hostName the host name to be verified.
432 * @param sslSession (not used).
433 * @return true if the host name matches our own.
436 public boolean verify(@Nullable String hostName, @Nullable SSLSession sslSession) {
437 return this.ipAddress.equals(hostName);