]> git.basschouten.com Git - openhab-addons.git/blob
b02df0947f593f9c1f6e28b3d8a2f2c7ee7b4d5b
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.miele.internal.handler;
14
15 import static org.openhab.binding.miele.internal.MieleBindingConstants.*;
16
17 import java.io.BufferedInputStream;
18 import java.io.ByteArrayOutputStream;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.io.OutputStream;
22 import java.io.StringReader;
23 import java.net.DatagramPacket;
24 import java.net.HttpURLConnection;
25 import java.net.InetAddress;
26 import java.net.MalformedURLException;
27 import java.net.MulticastSocket;
28 import java.net.SocketTimeoutException;
29 import java.net.URL;
30 import java.net.UnknownHostException;
31 import java.util.ArrayList;
32 import java.util.Collections;
33 import java.util.HashMap;
34 import java.util.List;
35 import java.util.Map;
36 import java.util.Random;
37 import java.util.Set;
38 import java.util.concurrent.CopyOnWriteArrayList;
39 import java.util.concurrent.ExecutorService;
40 import java.util.concurrent.Executors;
41 import java.util.concurrent.Future;
42 import java.util.concurrent.ScheduledFuture;
43 import java.util.concurrent.TimeUnit;
44 import java.util.regex.Pattern;
45 import java.util.zip.GZIPInputStream;
46
47 import org.apache.commons.lang3.StringUtils;
48 import org.openhab.core.common.NamedThreadFactory;
49 import org.openhab.core.thing.Bridge;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.ThingTypeUID;
55 import org.openhab.core.thing.binding.BaseBridgeHandler;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.RefreshType;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 import com.google.gson.Gson;
62 import com.google.gson.JsonArray;
63 import com.google.gson.JsonElement;
64 import com.google.gson.JsonObject;
65 import com.google.gson.JsonParser;
66
67 /**
68  * The {@link MieleBridgeHandler} is responsible for handling commands, which are
69  * sent to one of the channels.
70  *
71  * @author Karel Goderis - Initial contribution
72  * @author Kai Kreuzer - Fixed lifecycle issues
73  * @author Martin Lepsy - Added protocol information to support WiFi devices & some refactoring for HomeDevice
74  */
75 public class MieleBridgeHandler extends BaseBridgeHandler {
76
77     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_XGW3000);
78
79     private static final Pattern IP_PATTERN = Pattern
80             .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
81
82     protected static final int POLLING_PERIOD = 15; // in seconds
83     protected static final int JSON_RPC_PORT = 2810;
84     protected static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
85     protected static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
86     private boolean lastBridgeConnectionState = false;
87     private boolean currentBridgeConnectionState = false;
88
89     protected Random rand = new Random();
90     protected Gson gson = new Gson();
91     private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
92
93     protected List<ApplianceStatusListener> applianceStatusListeners = new CopyOnWriteArrayList<>();
94     protected ScheduledFuture<?> pollingJob;
95     protected ExecutorService executor;
96     protected Future<?> eventListenerJob;
97
98     protected List<HomeDevice> previousHomeDevices = new CopyOnWriteArrayList<>();
99
100     protected URL url;
101     protected Map<String, String> headers;
102
103     // Data structures to de-JSONify whatever Miele appliances are sending us
104     public class HomeDevice {
105
106         private static final String PROTOCOL_LAN = "LAN";
107
108         public String Name;
109         public String Status;
110         public String ParentUID;
111         public String ProtocolAdapterName;
112         public String Vendor;
113         public String UID;
114         public String Type;
115         public JsonArray DeviceClasses;
116         public String Version;
117         public String TimestampAdded;
118         public JsonObject Error;
119         public JsonObject Properties;
120
121         HomeDevice() {
122         }
123
124         public String getId() {
125             return getApplianceId().replaceAll("[^a-zA-Z0-9_]", "_");
126         }
127
128         public String getProtocol() {
129             return ProtocolAdapterName.equals(PROTOCOL_LAN) ? HDM_LAN : HDM_ZIGBEE;
130         }
131
132         public String getApplianceId() {
133             return ProtocolAdapterName.equals(PROTOCOL_LAN) ? StringUtils.right(UID, UID.length() - HDM_LAN.length())
134                     : StringUtils.right(UID, UID.length() - HDM_ZIGBEE.length());
135         }
136     }
137
138     public class DeviceClassObject {
139         public String DeviceClassType;
140         public JsonArray Operations;
141         public String DeviceClass;
142         public JsonArray Properties;
143
144         DeviceClassObject() {
145         }
146     }
147
148     public class DeviceOperation {
149         public String Name;
150         public String Arguments;
151         public JsonObject Metadata;
152
153         DeviceOperation() {
154         }
155     }
156
157     public class DeviceProperty {
158         public String Name;
159         public String Value;
160         public int Polling;
161         public JsonObject Metadata;
162
163         DeviceProperty() {
164         }
165     }
166
167     public class DeviceMetaData {
168         public String Filter;
169         public String description;
170         public String LocalizedID;
171         public String LocalizedValue;
172         public JsonObject MieleEnum;
173         public String access;
174     }
175
176     public MieleBridgeHandler(Bridge bridge) {
177         super(bridge);
178     }
179
180     @Override
181     public void initialize() {
182         logger.debug("Initializing the Miele bridge handler.");
183
184         if (getConfig().get(HOST) != null && getConfig().get(INTERFACE) != null) {
185             if (IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()
186                     && IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
187                 try {
188                     url = new URL("http://" + (String) getConfig().get(HOST) + "/remote/json-rpc");
189                 } catch (MalformedURLException e) {
190                     logger.debug("An exception occurred while defining an URL :'{}'", e.getMessage());
191                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
192                     return;
193                 }
194
195                 // for future usage - no headers to be set for now
196                 headers = new HashMap<>();
197
198                 onUpdate();
199                 updateStatus(ThingStatus.UNKNOWN);
200             } else {
201                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
202                         "Invalid IP address for the Miele@Home gateway or multicast interface:" + getConfig().get(HOST)
203                                 + "/" + getConfig().get(INTERFACE));
204             }
205         } else {
206             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
207                     "Cannot connect to the Miele gateway. host IP address or multicast interface are not set.");
208         }
209     }
210
211     private Runnable pollingRunnable = new Runnable() {
212         @Override
213         public void run() {
214             if (IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()) {
215                 try {
216                     if (isReachable((String) getConfig().get(HOST))) {
217                         currentBridgeConnectionState = true;
218                     } else {
219                         currentBridgeConnectionState = false;
220                         lastBridgeConnectionState = false;
221                         onConnectionLost();
222                     }
223
224                     if (!lastBridgeConnectionState && currentBridgeConnectionState) {
225                         logger.debug("Connection to Miele Gateway {} established.", getConfig().get(HOST));
226                         lastBridgeConnectionState = true;
227                         onConnectionResumed();
228                     }
229
230                     if (currentBridgeConnectionState) {
231                         if (getThing().getStatus() == ThingStatus.ONLINE) {
232                             List<HomeDevice> currentHomeDevices = getHomeDevices();
233                             for (HomeDevice hd : currentHomeDevices) {
234                                 boolean isExisting = false;
235                                 for (HomeDevice phd : previousHomeDevices) {
236                                     if (phd.UID.equals(hd.UID)) {
237                                         isExisting = true;
238                                         break;
239                                     }
240                                 }
241                                 if (!isExisting) {
242                                     logger.debug("A new appliance with ID '{}' has been added", hd.UID);
243                                     for (ApplianceStatusListener listener : applianceStatusListeners) {
244                                         listener.onApplianceAdded(hd);
245                                     }
246                                 }
247                             }
248
249                             for (HomeDevice hd : previousHomeDevices) {
250                                 boolean isCurrent = false;
251                                 for (HomeDevice chd : currentHomeDevices) {
252                                     if (chd.UID.equals(hd.UID)) {
253                                         isCurrent = true;
254                                         break;
255                                     }
256                                 }
257                                 if (!isCurrent) {
258                                     logger.debug("The appliance with ID '{}' has been removed", hd);
259                                     for (ApplianceStatusListener listener : applianceStatusListeners) {
260                                         listener.onApplianceRemoved(hd);
261                                     }
262                                 }
263                             }
264
265                             previousHomeDevices = currentHomeDevices;
266
267                             for (Thing appliance : getThing().getThings()) {
268                                 if (appliance.getStatus() == ThingStatus.ONLINE) {
269                                     String UID = appliance.getProperties().get(PROTOCOL_PROPERTY_NAME)
270                                             + (String) appliance.getConfiguration().getProperties().get(APPLIANCE_ID);
271
272                                     Object[] args = new Object[2];
273                                     args[0] = UID;
274                                     args[1] = true;
275                                     JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
276
277                                     if (result != null) {
278                                         for (JsonElement obj : result.getAsJsonArray()) {
279                                             try {
280                                                 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
281
282                                                 for (ApplianceStatusListener listener : applianceStatusListeners) {
283                                                     listener.onApplianceStateChanged(UID, dco);
284                                                 }
285                                             } catch (Exception e) {
286                                                 logger.debug("An exception occurred while quering an appliance : '{}'",
287                                                         e.getMessage());
288                                             }
289                                         }
290                                     }
291                                 }
292                             }
293                         }
294                     }
295                 } catch (Exception e) {
296                     logger.debug("An exception occurred while polling an appliance :'{}'", e.getMessage());
297                 }
298             } else {
299                 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
300             }
301         }
302
303         private boolean isReachable(String ipAddress) {
304             try {
305                 // note that InetAddress.isReachable is unreliable, see
306                 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
307                 // That's why we do an HTTP access instead
308
309                 // If there is no connection, this line will fail
310                 JsonElement result = invokeRPC("system.listMethods", null);
311                 if (result == null) {
312                     logger.debug("{} is not reachable", ipAddress);
313                     return false;
314                 }
315             } catch (Exception e) {
316                 return false;
317             }
318
319             logger.debug("{} is reachable", ipAddress);
320             return true;
321         }
322     };
323
324     public List<HomeDevice> getHomeDevices() {
325         List<HomeDevice> devices = new ArrayList<>();
326
327         if (getThing().getStatus() == ThingStatus.ONLINE) {
328             try {
329                 String[] args = new String[1];
330                 args[0] = "(type=SuperVision)";
331                 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
332
333                 for (JsonElement obj : result.getAsJsonArray()) {
334                     HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
335                     devices.add(hd);
336                 }
337             } catch (Exception e) {
338                 logger.debug("An exception occurred while getting the home devices :'{}'", e.getMessage());
339             }
340         }
341         return devices;
342     }
343
344     private Runnable eventListenerRunnable = () -> {
345         if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
346             while (true) {
347                 // Get the address that we are going to connect to.
348                 InetAddress address1 = null;
349                 InetAddress address2 = null;
350                 try {
351                     address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
352                     address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
353                 } catch (UnknownHostException e) {
354                     logger.debug("An exception occurred while setting up the multicast receiver : '{}'",
355                             e.getMessage());
356                 }
357
358                 byte[] buf = new byte[256];
359                 MulticastSocket clientSocket = null;
360
361                 while (true) {
362                     try {
363                         clientSocket = new MulticastSocket(JSON_RPC_PORT);
364                         clientSocket.setSoTimeout(100);
365
366                         clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
367                         clientSocket.joinGroup(address1);
368                         clientSocket.joinGroup(address2);
369
370                         while (true) {
371                             try {
372                                 buf = new byte[256];
373                                 DatagramPacket packet = new DatagramPacket(buf, buf.length);
374                                 clientSocket.receive(packet);
375
376                                 String event = new String(packet.getData());
377                                 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
378                                         packet.getPort());
379
380                                 DeviceProperty dp = new DeviceProperty();
381                                 String uid = null;
382
383                                 String[] parts = StringUtils.split(event, "&");
384                                 for (String p : parts) {
385                                     String[] subparts = StringUtils.split(p, "=");
386                                     switch (subparts[0]) {
387                                         case "property": {
388                                             dp.Name = subparts[1];
389                                             break;
390                                         }
391                                         case "value": {
392                                             dp.Value = subparts[1];
393                                             break;
394                                         }
395                                         case "id": {
396                                             uid = subparts[1];
397                                             break;
398                                         }
399                                     }
400                                 }
401
402                                 for (ApplianceStatusListener listener : applianceStatusListeners) {
403                                     listener.onAppliancePropertyChanged(uid, dp);
404                                 }
405                             } catch (SocketTimeoutException e) {
406                                 try {
407                                     Thread.sleep(500);
408                                 } catch (InterruptedException ex) {
409                                     logger.debug("Eventlistener has been interrupted.");
410                                     break;
411                                 }
412                             }
413                         }
414                     } catch (Exception ex) {
415                         logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
416                     }
417
418                     // restart the cycle with a clean slate
419                     try {
420                         if (clientSocket != null) {
421                             clientSocket.leaveGroup(address1);
422                             clientSocket.leaveGroup(address2);
423                         }
424                     } catch (IOException e) {
425                         logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
426                     }
427                     if (clientSocket != null) {
428                         clientSocket.close();
429                     }
430                 }
431             }
432         } else {
433             logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
434         }
435     };
436
437     public JsonElement invokeOperation(String UID, String modelID, String methodName) {
438         return invokeOperation(UID, modelID, methodName, HDM_ZIGBEE);
439     }
440
441     public JsonElement invokeOperation(String UID, String modelID, String methodName, String protocol) {
442         if (getThing().getStatus() == ThingStatus.ONLINE) {
443             Object[] args = new Object[4];
444             args[0] = protocol + UID;
445             args[1] = "com.miele.xgw3000.gateway.hdm.deviceclasses.Miele" + modelID;
446             args[2] = methodName;
447             args[3] = null;
448             return invokeRPC("HDAccess/invokeDCOOperation", args);
449         } else {
450             logger.debug("The Bridge is offline - operations can not be invoked.");
451             return null;
452         }
453     }
454
455     protected JsonElement invokeRPC(String methodName, Object[] args) {
456         int id = rand.nextInt(Integer.MAX_VALUE);
457
458         JsonObject req = new JsonObject();
459         req.addProperty("jsonrpc", "2.0");
460         req.addProperty("id", id);
461         req.addProperty("method", methodName);
462
463         JsonElement result = null;
464
465         JsonArray params = new JsonArray();
466         if (args != null) {
467             for (Object o : args) {
468                 params.add(gson.toJsonTree(o));
469             }
470         }
471         req.add("params", params);
472
473         String requestData = req.toString();
474         String responseData = null;
475         try {
476             responseData = post(url, headers, requestData);
477         } catch (Exception e) {
478             logger.debug("An exception occurred while posting data : '{}'", e.getMessage());
479         }
480
481         if (responseData != null) {
482             logger.debug("The request '{}' yields '{}'", requestData, responseData);
483             JsonObject resp = (JsonObject) JsonParser.parseReader(new StringReader(responseData));
484
485             result = resp.get("result");
486             JsonElement error = resp.get("error");
487
488             if (error != null && !error.isJsonNull()) {
489                 if (error.isJsonPrimitive()) {
490                     logger.debug("A remote exception occurred: '{}'", error.getAsString());
491                 } else if (error.isJsonObject()) {
492                     JsonObject o = error.getAsJsonObject();
493                     Integer code = (o.has("code") ? o.get("code").getAsInt() : null);
494                     String message = (o.has("message") ? o.get("message").getAsString() : null);
495                     String data = (o.has("data") ? (o.get("data") instanceof JsonObject ? o.get("data").toString()
496                             : o.get("data").getAsString()) : null);
497                     logger.debug("A remote exception occurred: '{}':'{}':'{}'", code, message, data);
498                 } else {
499                     logger.debug("An unknown remote exception occurred: '{}'", error.toString());
500                 }
501             }
502         }
503
504         return result;
505     }
506
507     protected String post(URL url, Map<String, String> headers, String data) throws IOException {
508         HttpURLConnection connection = (HttpURLConnection) url.openConnection();
509
510         if (headers != null) {
511             for (Map.Entry<String, String> entry : headers.entrySet()) {
512                 connection.addRequestProperty(entry.getKey(), entry.getValue());
513             }
514         }
515
516         connection.addRequestProperty("Accept-Encoding", "gzip");
517
518         connection.setRequestMethod("POST");
519         connection.setDoOutput(true);
520         connection.connect();
521
522         OutputStream out = null;
523
524         try {
525             out = connection.getOutputStream();
526
527             out.write(data.getBytes());
528             out.flush();
529
530             int statusCode = connection.getResponseCode();
531             if (statusCode != HttpURLConnection.HTTP_OK) {
532                 logger.debug("An unexpected status code was returned: '{}'", statusCode);
533             }
534         } finally {
535             if (out != null) {
536                 out.close();
537             }
538         }
539
540         String responseEncoding = connection.getHeaderField("Content-Encoding");
541         responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
542
543         ByteArrayOutputStream bos = new ByteArrayOutputStream();
544
545         InputStream in = connection.getInputStream();
546         try {
547             in = connection.getInputStream();
548             if ("gzip".equalsIgnoreCase(responseEncoding)) {
549                 in = new GZIPInputStream(in);
550             }
551             in = new BufferedInputStream(in);
552
553             byte[] buff = new byte[1024];
554             int n;
555             while ((n = in.read(buff)) > 0) {
556                 bos.write(buff, 0, n);
557             }
558             bos.flush();
559             bos.close();
560         } finally {
561             if (in != null) {
562                 in.close();
563             }
564         }
565
566         return bos.toString();
567     }
568
569     private synchronized void onUpdate() {
570         logger.debug("Scheduling the Miele polling job");
571         if (pollingJob == null || pollingJob.isCancelled()) {
572             logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
573             pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
574             logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
575         }
576         logger.debug("Scheduling the Miele event listener job");
577
578         if (eventListenerJob == null || eventListenerJob.isCancelled()) {
579             executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("binding-miele"));
580             eventListenerJob = executor.submit(eventListenerRunnable);
581         }
582     }
583
584     /**
585      * This method is called whenever the connection to the given {@link MieleBridge} is lost.
586      *
587      */
588     public void onConnectionLost() {
589         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
590     }
591
592     /**
593      * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
594      *
595      * @param bridge the hue bridge the connection is resumed to
596      */
597     public void onConnectionResumed() {
598         updateStatus(ThingStatus.ONLINE);
599         for (Thing thing : getThing().getThings()) {
600             MieleApplianceHandler<?> handler = (MieleApplianceHandler<?>) thing.getHandler();
601             if (handler != null) {
602                 handler.onBridgeConnectionResumed();
603             }
604         }
605     }
606
607     public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
608         if (applianceStatusListener == null) {
609             throw new IllegalArgumentException("It's not allowed to pass a null ApplianceStatusListener.");
610         }
611         boolean result = applianceStatusListeners.add(applianceStatusListener);
612         if (result && isInitialized()) {
613             onUpdate();
614
615             for (HomeDevice hd : getHomeDevices()) {
616                 applianceStatusListener.onApplianceAdded(hd);
617             }
618         }
619         return result;
620     }
621
622     public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
623         boolean result = applianceStatusListeners.remove(applianceStatusListener);
624         if (result && isInitialized()) {
625             onUpdate();
626         }
627         return result;
628     }
629
630     @Override
631     public void handleCommand(ChannelUID channelUID, Command command) {
632         // Nothing to do here - the XGW bridge does not handle commands, for now
633         if (command instanceof RefreshType) {
634             // Placeholder for future refinement
635             return;
636         }
637     }
638
639     @Override
640     public void dispose() {
641         super.dispose();
642         if (pollingJob != null) {
643             pollingJob.cancel(true);
644             pollingJob = null;
645         }
646         if (eventListenerJob != null) {
647             eventListenerJob.cancel(true);
648             eventListenerJob = null;
649         }
650         if (executor != null) {
651             executor.shutdownNow();
652             executor = null;
653         }
654     }
655 }