2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.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);
69 private static final int REQUEST_TIMEOUT_MS = 10_000;
71 private final Logger logger = LoggerFactory.getLogger(GatewayWebTargets.class);
72 private final Gson jsonParser = new Gson();
74 private final String shades;
75 private final String scenes;
76 private final String sceneActivate;
77 private final String shadeMotion;
78 private final String shadePositions;
79 private final String shadeSingle;
80 private final String shadeStop;
81 private final String info;
82 private final String register;
83 private final String shadeEvents;
84 private final HttpClient httpClient;
85 private final ClientBuilder clientBuilder;
86 private final SseEventSourceFactory eventSourceFactory;
87 private final GatewayBridgeHandler hubHandler;
88 private final String ipAddress;
90 private boolean registered;
91 private boolean closing;
93 private @Nullable SseEventSource shadeEventSource;
94 private @Nullable ScheduledFuture<?> sseQuietCheck;
97 * Initialize the web targets
99 * @param httpClient the HTTP client (the binding)
100 * @param ipAddress the IP address of the server (the hub)
102 public GatewayWebTargets(GatewayBridgeHandler hubHandler, HttpClient httpClient, ClientBuilder clientBuilder,
103 SseEventSourceFactory eventSourceFactory, String ipAddress) {
104 this.ipAddress = ipAddress;
105 this.httpClient = httpClient;
106 this.clientBuilder = clientBuilder;
107 this.eventSourceFactory = eventSourceFactory;
108 this.hubHandler = hubHandler;
110 String base = "http://" + ipAddress + "/";
111 String home = base + "home/";
112 shades = home + "shades";
113 scenes = home + "scenes";
114 sceneActivate = home + "scenes/%d/activate";
115 shadeMotion = home + "shades/%d/motion";
116 shadePositions = home + "shades/positions";
117 shadeSingle = home + "shades/%d";
118 shadeStop = home + "shades/stop";
119 shadeEvents = home + "shades/events?sse=true";
120 info = base + "gateway/info";
123 * Hunter Douglas keeps a statistical count of systems (e.g. openHAB, Home Assistant, Amazon etc.) that are
124 * using their Generation 3 REST API to connect to their gateways. So we are asked to register with the gateway
125 * on startup. => So do not change the 'openhab.org' tag below !!
127 register = home + "integration/openhab.org";
131 * Issue a command to activate a scene.
133 * @param sceneId the scene to be activated.
134 * @throws HubProcessingException if any error occurs.
136 public void activateScene(int sceneId) throws HubProcessingException {
137 invoke(HttpMethod.PUT, String.format(sceneActivate, sceneId), null, null);
141 public void close() throws IOException {
147 * Register the binding with the hub (if not already registered).
149 * @throws HubProcessingException if any error occurs.
151 public boolean gatewayRegister() throws HubProcessingException {
153 invoke(HttpMethod.PUT, register, null, null);
160 * Get hub properties.
162 * @return a map containing the hub properties.
163 * @throws HubProcessingException if any error occurs.
165 public Map<String, String> getInformation() throws HubProcessingException {
166 String json = invoke(HttpMethod.GET, info, null, null);
168 Info result = jsonParser.fromJson(json, Info.class);
169 if (result == null) {
170 throw new HubProcessingException("getInformation(): missing response");
173 Thing.PROPERTY_FIRMWARE_VERSION, result.getFwVersion(), //
174 Thing.PROPERTY_SERIAL_NUMBER, result.getSerialNumber());
175 } catch (JsonParseException e) {
176 throw new HubProcessingException("getFirmwareVersions(): JsonParseException");
181 * Get the list of scenes.
183 * @return the list of scenes.
184 * @throws HubProcessingException if any error occurs.
186 public List<Scene> getScenes() throws HubProcessingException {
187 String json = invoke(HttpMethod.GET, scenes, null, null);
189 return List.of(jsonParser.fromJson(json, Scene[].class));
190 } catch (JsonParseException e) {
191 throw new HubProcessingException("getScenes() JsonParseException");
196 * Get the data for a single shade.
198 * @param shadeId the id of the shade to get.
200 * @throws HubProcessingException if any error occurs.
202 public Shade getShade(int shadeId) throws HubProcessingException {
203 String json = invoke(HttpMethod.GET, String.format(shadeSingle, shadeId), null, null);
205 Shade result = jsonParser.fromJson(json, Shade.class);
206 if (result == null) {
207 throw new HubProcessingException("getShade() missing response");
210 } catch (JsonParseException e) {
211 throw new HubProcessingException("getShade() JsonParseException");
216 * Get the list of shades.
218 * @return the list of shades.
219 * @throws HubProcessingException if any error occurs.
221 public List<Shade> getShades() throws HubProcessingException {
222 String json = invoke(HttpMethod.GET, shades, null, null);
224 return List.of(jsonParser.fromJson(json, Shade[].class));
225 } catch (JsonParseException e) {
226 throw new HubProcessingException("getShades() JsonParseException");
231 * Invoke a call on the hub server to retrieve information or send a command.
233 * @param method GET or PUT.
234 * @param url the host URL to be called.
235 * @param query the HTTP query parameter.
236 * @param jsonCommand the request command content (as a JSON string).
237 * @return the response content (as a JSON string).
238 * @throws HubProcessingException if something goes wrong.
240 protected synchronized String invoke(HttpMethod method, String url, @Nullable Query query,
241 @Nullable String jsonCommand) throws HubProcessingException {
242 if (logger.isTraceEnabled()) {
244 logger.trace("invoke() method:{}, url:{}, query:{}", method, url, query);
246 logger.trace("invoke() method:{}, url:{}", method, url);
248 if (jsonCommand != null) {
249 logger.trace("invoke() request JSON:{}", jsonCommand);
252 Request request = httpClient.newRequest(url).method(method).header("Connection", "close").accept("*/*")
253 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
255 request.param(query.getKey(), query.getValue());
257 if (jsonCommand != null) {
258 request.header(HttpHeader.CONTENT_TYPE, "application/json").content(new StringContentProvider(jsonCommand));
260 ContentResponse response;
262 response = request.send();
263 } catch (InterruptedException e) {
264 Thread.currentThread().interrupt();
265 throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
266 } catch (TimeoutException | ExecutionException e) {
267 throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
269 int statusCode = response.getStatus();
270 if (!HTTP_OK_CODES.contains(statusCode)) {
271 throw new HubProcessingException(String.format("HTTP %d error", statusCode));
273 String jsonResponse = response.getContentAsString();
274 if (logger.isTraceEnabled()) {
275 logger.trace("invoke() response JSON:{}", jsonResponse);
277 if (method == HttpMethod.GET && jsonResponse.isEmpty()) {
278 throw new HubProcessingException("Empty response entity");
284 * Issue a jog command to a shade.
286 * @param shadeId the shade to be jogged.
287 * @throws HubProcessingException if any error occurs.
289 public void jogShade(int shadeId) throws HubProcessingException {
290 String json = jsonParser.toJson(new ShadeMotion(ShadeMotion.Type.JOG));
291 invoke(HttpMethod.PUT, String.format(shadeMotion, shadeId), null, json);
295 * Issue a command to move a shade.
297 * @param shadeId the shade to be moved.
298 * @param shade DTO with the new position.
299 * @throws HubProcessingException if any error occurs.
301 public void moveShade(int shadeId, Shade shade) throws HubProcessingException {
302 invoke(HttpMethod.PUT, shadePositions, Query.of(IDS, Integer.toString(shadeId)), jsonParser.toJson(shade));
306 * Called when the SSE event channel has not received any events for a long time. This could mean that the event
307 * source socket has dropped. So restart the SSE connection.
309 public void onSseQuiet() {
316 * Handle SSE errors. For the time being just log them, because the framework should automatically recover itself.
318 * @param e the error that was thrown.
320 private void onSseError(Throwable e) {
322 logger.debug("onSseShadeError() {}", e.getMessage(), e);
327 * Handle inbound shade SSE events.
329 * @param sseEvent the inbound event.
331 private void onSSeEvent(InboundSseEvent sseEvent) {
335 ScheduledFuture<?> task = sseQuietCheck;
336 if (task != null && !task.isCancelled()) {
339 sseQuietCheck = hubHandler.getScheduler().schedule(this::onSseQuiet, SLEEP_SECONDS, TimeUnit.SECONDS);
340 if (sseEvent.isEmpty()) {
343 String json = sseEvent.readData();
347 logger.trace("onSseShadeEvent() json:{}", json);
348 ShadeEvent shadeEvent = jsonParser.fromJson(json, ShadeEvent.class);
349 if (shadeEvent != null) {
350 ShadePosition positions = shadeEvent.getCurrentPositions();
352 .onShadeEvent(new Shade().setId(shadeEvent.getId()).setShadePosition(positions).setPartialState());
357 * Close the SSE links.
359 private synchronized void sseClose() {
360 logger.debug("sseClose() called");
361 ScheduledFuture<?> task = sseQuietCheck;
364 sseQuietCheck = null;
366 SseEventSource source;
367 source = this.shadeEventSource;
368 if (source != null) {
370 this.shadeEventSource = null;
375 * Open the SSE links.
377 public synchronized void sseOpen() {
380 logger.debug("sseOpen() called");
381 Client client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
382 .hostnameVerifier(null).hostnameVerifier(this).readTimeout(0, TimeUnit.SECONDS).build();
385 // open SSE channel for shades
386 SseEventSource shadeEventSource = eventSourceFactory.newSource(client.target(shadeEvents));
387 shadeEventSource.register(this::onSSeEvent, this::onSseError);
388 shadeEventSource.open();
389 this.shadeEventSource = shadeEventSource;
390 } catch (Exception e) {
391 // SSE documentation does not say what exceptions may be thrown, so catch everything
392 logger.warn("sseOpen() {}", e.getMessage(), e);
397 * Reopen the SSE links. If the event source already exists, try first to simply close and re-open it, but if that
398 * fails, then completely destroy and re-create it.
400 private synchronized void sseReOpen() {
401 logger.debug("sseReOpen() called");
402 SseEventSource shadeEventSource = this.shadeEventSource;
403 if (shadeEventSource != null) {
405 if (shadeEventSource.isOpen()) {
406 shadeEventSource.close();
408 if (!shadeEventSource.isOpen()) {
409 shadeEventSource.open();
412 } catch (Exception e) {
413 // SSE documentation does not say what exceptions may be thrown, so catch everything
414 logger.warn("sseReOpen() {}", e.getMessage(), e);
421 * Issue a stop command to a shade.
423 * @param shadeId the shade to be stopped.
424 * @throws HubProcessingException if any error occurs.
426 public void stopShade(int shadeId) throws HubProcessingException {
427 invoke(HttpMethod.PUT, shadeStop, Query.of(IDS, Integer.toString(shadeId)), null);
431 * HostnameVerifier method implementation that validates the host name when opening SSE connections.
433 * @param hostName the host name to be verified.
434 * @param sslSession (not used).
435 * @return true if the host name matches our own.
438 public boolean verify(@Nullable String hostName, @Nullable SSLSession sslSession) {
439 return this.ipAddress.equals(hostName);