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