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