]> git.basschouten.com Git - openhab-addons.git/blob
1ee0b0bcf95f7e603fc4c9cb65909b1d8efba676
[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.io.Closeable;
16 import java.io.IOException;
17 import java.util.List;
18 import java.util.Map;
19 import java.util.Set;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
24
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;
31
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;
54
55 import com.google.gson.Gson;
56 import com.google.gson.JsonParseException;
57
58 /**
59  * JAX-RS targets for communicating with an HD PowerView Generation 3 Gateway.
60  *
61  * @author Andrew Fiddian-Green - Initial contribution
62  */
63 @NonNullByDefault
64 public class GatewayWebTargets implements Closeable, HostnameVerifier {
65
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
70     private final Logger logger = LoggerFactory.getLogger(GatewayWebTargets.class);
71     private final Gson jsonParser = new Gson();
72
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;
88
89     private boolean registered;
90     private boolean closing;
91
92     private @Nullable SseEventSource shadeEventSource;
93     private @Nullable ScheduledFuture<?> sseQuietCheck;
94
95     /**
96      * Initialize the web targets
97      *
98      * @param httpClient the HTTP client (the binding)
99      * @param ipAddress the IP address of the server (the hub)
100      */
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;
108
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";
120
121         /*
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 !!
125          */
126         register = home + "integration/openhab.org";
127     }
128
129     /**
130      * Issue a command to activate a scene.
131      *
132      * @param sceneId the scene to be activated.
133      * @throws HubProcessingException if any error occurs.
134      */
135     public void activateScene(int sceneId) throws HubProcessingException {
136         invoke(HttpMethod.PUT, String.format(sceneActivate, sceneId), null, null);
137     }
138
139     @Override
140     public void close() throws IOException {
141         closing = true;
142         sseClose();
143     }
144
145     /**
146      * Register the binding with the hub (if not already registered).
147      *
148      * @throws HubProcessingException if any error occurs.
149      */
150     public boolean gatewayRegister() throws HubProcessingException {
151         if (!registered) {
152             invoke(HttpMethod.PUT, register, null, null);
153             registered = true;
154         }
155         return registered;
156     }
157
158     /**
159      * Get hub properties.
160      *
161      * @return a map containing the hub properties.
162      * @throws HubProcessingException if any error occurs.
163      */
164     public Map<String, String> getInformation() throws HubProcessingException {
165         String json = invoke(HttpMethod.GET, info, null, null);
166         try {
167             Info result = jsonParser.fromJson(json, Info.class);
168             if (result == null) {
169                 throw new HubProcessingException("getInformation(): missing response");
170             }
171             return Map.of( //
172                     Thing.PROPERTY_FIRMWARE_VERSION, result.getFwVersion(), //
173                     Thing.PROPERTY_SERIAL_NUMBER, result.getSerialNumber());
174         } catch (JsonParseException e) {
175             throw new HubProcessingException("getFirmwareVersions(): JsonParseException");
176         }
177     }
178
179     /**
180      * Get the list of scenes.
181      *
182      * @return the list of scenes.
183      * @throws HubProcessingException if any error occurs.
184      */
185     public List<Scene> getScenes() throws HubProcessingException {
186         String json = invoke(HttpMethod.GET, scenes, null, null);
187         try {
188             return List.of(jsonParser.fromJson(json, Scene[].class));
189         } catch (JsonParseException e) {
190             throw new HubProcessingException("getScenes() JsonParseException");
191         }
192     }
193
194     /**
195      * Get the data for a single shade.
196      *
197      * @param shadeId the id of the shade to get.
198      * @return the shade.
199      * @throws HubProcessingException if any error occurs.
200      */
201     public Shade getShade(int shadeId) throws HubProcessingException {
202         String json = invoke(HttpMethod.GET, String.format(shadeSingle, shadeId), null, null);
203         try {
204             Shade result = jsonParser.fromJson(json, Shade.class);
205             if (result == null) {
206                 throw new HubProcessingException("getShade() missing response");
207             }
208             return result;
209         } catch (JsonParseException e) {
210             throw new HubProcessingException("getShade() JsonParseException");
211         }
212     }
213
214     /**
215      * Get the list of shades.
216      *
217      * @return the list of shades.
218      * @throws HubProcessingException if any error occurs.
219      */
220     public List<Shade> getShades() throws HubProcessingException {
221         String json = invoke(HttpMethod.GET, shades, null, null);
222         try {
223             return List.of(jsonParser.fromJson(json, Shade[].class));
224         } catch (JsonParseException e) {
225             throw new HubProcessingException("getShades() JsonParseException");
226         }
227     }
228
229     /**
230      * Invoke a call on the hub server to retrieve information or send a command.
231      *
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.
238      */
239     protected synchronized String invoke(HttpMethod method, String url, @Nullable Query query,
240             @Nullable String jsonCommand) throws HubProcessingException {
241         if (logger.isTraceEnabled()) {
242             if (query != null) {
243                 logger.trace("invoke() method:{}, url:{}, query:{}", method, url, query);
244             } else {
245                 logger.trace("invoke() method:{}, url:{}", method, url);
246             }
247             if (jsonCommand != null) {
248                 logger.trace("invoke() request JSON:{}", jsonCommand);
249             }
250         }
251         Request request = httpClient.newRequest(url).method(method).header("Connection", "close").accept("*/*");
252         if (query != null) {
253             request.param(query.getKey(), query.getValue());
254         }
255         if (jsonCommand != null) {
256             request.header(HttpHeader.CONTENT_TYPE, "application/json").content(new StringContentProvider(jsonCommand));
257         }
258         ContentResponse response;
259         try {
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()));
266         }
267         int statusCode = response.getStatus();
268         if (!HTTP_OK_CODES.contains(statusCode)) {
269             throw new HubProcessingException(String.format("HTTP %d error", statusCode));
270         }
271         String jsonResponse = response.getContentAsString();
272         if (logger.isTraceEnabled()) {
273             logger.trace("invoke() response JSON:{}", jsonResponse);
274         }
275         if (method == HttpMethod.GET && jsonResponse.isEmpty()) {
276             throw new HubProcessingException("Empty response entity");
277         }
278         return jsonResponse;
279     }
280
281     /**
282      * Issue a jog command to a shade.
283      *
284      * @param shadeId the shade to be jogged.
285      * @throws HubProcessingException if any error occurs.
286      */
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);
290     }
291
292     /**
293      * Issue a command to move a shade.
294      *
295      * @param shadeId the shade to be moved.
296      * @param shade DTO with the new position.
297      * @throws HubProcessingException if any error occurs.
298      */
299     public void moveShade(int shadeId, Shade shade) throws HubProcessingException {
300         invoke(HttpMethod.PUT, shadePositions, Query.of(IDS, Integer.toString(shadeId)), jsonParser.toJson(shade));
301     }
302
303     /**
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.
306      */
307     public void onSseQuiet() {
308         if (!closing) {
309             sseReOpen();
310         }
311     }
312
313     /**
314      * Handle SSE errors. For the time being just log them, because the framework should automatically recover itself.
315      *
316      * @param e the error that was thrown.
317      */
318     private void onSseError(Throwable e) {
319         if (!closing) {
320             logger.debug("onSseShadeError() {}", e.getMessage(), e);
321         }
322     }
323
324     /**
325      * Handle inbound shade SSE events.
326      *
327      * @param sseEvent the inbound event.
328      */
329     private void onSSeEvent(InboundSseEvent sseEvent) {
330         if (closing) {
331             return;
332         }
333         ScheduledFuture<?> task = sseQuietCheck;
334         if (task != null && !task.isCancelled()) {
335             task.cancel(true);
336         }
337         sseQuietCheck = hubHandler.getScheduler().schedule(this::onSseQuiet, SLEEP_SECONDS, TimeUnit.SECONDS);
338         if (sseEvent.isEmpty()) {
339             return;
340         }
341         String json = sseEvent.readData();
342         if (json == null) {
343             return;
344         }
345         logger.trace("onSseShadeEvent() json:{}", json);
346         ShadeEvent shadeEvent = jsonParser.fromJson(json, ShadeEvent.class);
347         if (shadeEvent != null) {
348             ShadePosition positions = shadeEvent.getCurrentPositions();
349             hubHandler
350                     .onShadeEvent(new Shade().setId(shadeEvent.getId()).setShadePosition(positions).setPartialState());
351         }
352     }
353
354     /**
355      * Close the SSE links.
356      */
357     private synchronized void sseClose() {
358         logger.debug("sseClose() called");
359         ScheduledFuture<?> task = sseQuietCheck;
360         if (task != null) {
361             task.cancel(true);
362             sseQuietCheck = null;
363         }
364         SseEventSource source;
365         source = this.shadeEventSource;
366         if (source != null) {
367             source.close();
368             this.shadeEventSource = null;
369         }
370     }
371
372     /**
373      * Open the SSE links.
374      */
375     public synchronized void sseOpen() {
376         sseClose();
377
378         logger.debug("sseOpen() called");
379         Client client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
380                 .hostnameVerifier(null).hostnameVerifier(this).readTimeout(0, TimeUnit.SECONDS).build();
381
382         try {
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);
391         }
392     }
393
394     /**
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.
397      */
398     private synchronized void sseReOpen() {
399         logger.debug("sseReOpen() called");
400         SseEventSource shadeEventSource = this.shadeEventSource;
401         if (shadeEventSource != null) {
402             try {
403                 if (shadeEventSource.isOpen()) {
404                     shadeEventSource.close();
405                 }
406                 if (!shadeEventSource.isOpen()) {
407                     shadeEventSource.open();
408                 }
409                 return;
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);
413             }
414         }
415         sseOpen();
416     }
417
418     /**
419      * Issue a stop command to a shade.
420      *
421      * @param shadeId the shade to be stopped.
422      * @throws HubProcessingException if any error occurs.
423      */
424     public void stopShade(int shadeId) throws HubProcessingException {
425         invoke(HttpMethod.PUT, shadeStop, Query.of(IDS, Integer.valueOf(shadeId).toString()), null);
426     }
427
428     /**
429      * HostnameVerifier method implementation that validates the host name when opening SSE connections.
430      *
431      * @param hostName the host name to be verified.
432      * @param sslSession (not used).
433      * @return true if the host name matches our own.
434      */
435     @Override
436     public boolean verify(@Nullable String hostName, @Nullable SSLSession sslSession) {
437         return this.ipAddress.equals(hostName);
438     }
439 }