2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.miele.internal.handler;
15 import static org.openhab.binding.miele.internal.MieleBindingConstants.*;
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;
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;
36 import java.util.Random;
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;
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;
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;
69 * The {@link MieleBridgeHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
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)
77 public class MieleBridgeHandler extends BaseBridgeHandler {
79 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_XGW3000);
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])$");
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;
91 protected Random rand = new Random();
92 protected Gson gson = new Gson();
93 private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
95 protected List<ApplianceStatusListener> applianceStatusListeners = new CopyOnWriteArrayList<>();
96 protected ScheduledFuture<?> pollingJob;
97 protected ExecutorService executor;
98 protected Future<?> eventListenerJob;
100 protected List<HomeDevice> previousHomeDevices = new CopyOnWriteArrayList<>();
103 protected Map<String, String> headers;
105 // Data structures to de-JSONify whatever Miele appliances are sending us
106 public class HomeDevice {
109 public String Status;
110 public String ParentUID;
111 public String ProtocolAdapterName;
112 public String Vendor;
115 public JsonArray DeviceClasses;
116 public String Version;
117 public String TimestampAdded;
118 public JsonObject Error;
119 public JsonObject Properties;
124 public FullyQualifiedApplianceIdentifier getApplianceIdentifier() {
125 return new FullyQualifiedApplianceIdentifier(this.UID);
128 public String getSerialNumber() {
129 return Properties.get("serial.number").getAsString();
133 public class DeviceClassObject {
134 public String DeviceClassType;
135 public JsonArray Operations;
136 public String DeviceClass;
137 public JsonArray Properties;
139 DeviceClassObject() {
143 public class DeviceOperation {
145 public String Arguments;
146 public JsonObject Metadata;
152 public class DeviceProperty {
156 public JsonObject Metadata;
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;
171 public MieleBridgeHandler(Bridge bridge) {
176 public void initialize() {
177 logger.debug("Initializing the Miele bridge handler.");
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()) {
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());
190 // for future usage - no headers to be set for now
191 headers = new HashMap<>();
194 updateStatus(ThingStatus.UNKNOWN);
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));
201 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
202 "Cannot connect to the Miele gateway. host IP address or multicast interface are not set.");
206 private Runnable pollingRunnable = new Runnable() {
209 if (IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()) {
211 if (isReachable((String) getConfig().get(HOST))) {
212 currentBridgeConnectionState = true;
214 currentBridgeConnectionState = false;
215 lastBridgeConnectionState = false;
219 if (!lastBridgeConnectionState && currentBridgeConnectionState) {
220 logger.debug("Connection to Miele Gateway {} established.", getConfig().get(HOST));
221 lastBridgeConnectionState = true;
222 onConnectionResumed();
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)) {
237 logger.debug("A new appliance with ID '{}' has been added", hd.UID);
238 for (ApplianceStatusListener listener : applianceStatusListeners) {
239 listener.onApplianceAdded(hd);
244 for (HomeDevice hd : previousHomeDevices) {
245 boolean isCurrent = false;
246 for (HomeDevice chd : currentHomeDevices) {
247 if (chd.UID.equals(hd.UID)) {
253 logger.debug("The appliance with ID '{}' has been removed", hd);
254 for (ApplianceStatusListener listener : applianceStatusListeners) {
255 listener.onApplianceRemoved(hd);
260 previousHomeDevices = currentHomeDevices;
262 for (Thing appliance : getThing().getThings()) {
263 if (appliance.getStatus() == ThingStatus.ONLINE) {
264 String applianceId = (String) appliance.getConfiguration().getProperties()
266 String protocol = appliance.getProperties().get(PROTOCOL_PROPERTY_NAME);
267 var applianceIdentifier = new FullyQualifiedApplianceIdentifier(applianceId,
270 Object[] args = new Object[2];
271 args[0] = applianceIdentifier.getUid();
273 JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
275 if (result != null) {
276 for (JsonElement obj : result.getAsJsonArray()) {
278 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
280 for (ApplianceStatusListener listener : applianceStatusListeners) {
281 listener.onApplianceStateChanged(applianceIdentifier, dco);
283 } catch (Exception e) {
284 logger.debug("An exception occurred while quering an appliance : '{}'",
293 } catch (Exception e) {
294 logger.debug("An exception occurred while polling an appliance :'{}'", e.getMessage());
297 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
301 private boolean isReachable(String ipAddress) {
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
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);
313 } catch (Exception e) {
317 logger.debug("{} is reachable", ipAddress);
322 public List<HomeDevice> getHomeDevices() {
323 List<HomeDevice> devices = new ArrayList<>();
325 if (getThing().getStatus() == ThingStatus.ONLINE) {
327 String[] args = new String[1];
328 args[0] = "(type=SuperVision)";
329 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
331 for (JsonElement obj : result.getAsJsonArray()) {
332 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
335 } catch (Exception e) {
336 logger.debug("An exception occurred while getting the home devices :'{}'", e.getMessage());
342 private Runnable eventListenerRunnable = () -> {
343 if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
345 // Get the address that we are going to connect to.
346 InetAddress address1 = null;
347 InetAddress address2 = null;
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 : '{}'",
356 byte[] buf = new byte[256];
357 MulticastSocket clientSocket = null;
361 clientSocket = new MulticastSocket(JSON_RPC_PORT);
362 clientSocket.setSoTimeout(100);
364 clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
365 clientSocket.joinGroup(address1);
366 clientSocket.joinGroup(address2);
371 DatagramPacket packet = new DatagramPacket(buf, buf.length);
372 clientSocket.receive(packet);
374 String event = new String(packet.getData());
375 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
378 DeviceProperty dp = new DeviceProperty();
381 String[] parts = StringUtils.split(event, "&");
382 for (String p : parts) {
383 String[] subparts = StringUtils.split(p, "=");
384 switch (subparts[0]) {
386 dp.Name = subparts[1];
390 dp.Value = subparts[1];
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),
412 for (ApplianceStatusListener listener : applianceStatusListeners) {
413 listener.onAppliancePropertyChanged(id, dp);
416 } catch (SocketTimeoutException e) {
419 } catch (InterruptedException ex) {
420 logger.debug("Eventlistener has been interrupted.");
425 } catch (Exception ex) {
426 logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
429 // restart the cycle with a clean slate
431 if (clientSocket != null) {
432 clientSocket.leaveGroup(address1);
433 clientSocket.leaveGroup(address2);
435 } catch (IOException e) {
436 logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
438 if (clientSocket != null) {
439 clientSocket.close();
444 logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
448 public JsonElement invokeOperation(FullyQualifiedApplianceIdentifier applianceIdentifier, String modelID,
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;
456 return invokeRPC("HDAccess/invokeDCOOperation", args);
458 logger.debug("The Bridge is offline - operations can not be invoked.");
463 protected JsonElement invokeRPC(String methodName, Object[] args) {
464 int id = rand.nextInt(Integer.MAX_VALUE);
466 JsonObject req = new JsonObject();
467 req.addProperty("jsonrpc", "2.0");
468 req.addProperty("id", id);
469 req.addProperty("method", methodName);
471 JsonElement result = null;
473 JsonArray params = new JsonArray();
475 for (Object o : args) {
476 params.add(gson.toJsonTree(o));
479 req.add("params", params);
481 String requestData = req.toString();
482 String responseData = null;
484 responseData = post(url, headers, requestData);
485 } catch (Exception e) {
486 logger.debug("An exception occurred while posting data : '{}'", e.getMessage());
489 if (responseData != null) {
490 logger.debug("The request '{}' yields '{}'", requestData, responseData);
491 JsonObject resp = (JsonObject) JsonParser.parseReader(new StringReader(responseData));
493 result = resp.get("result");
494 JsonElement error = resp.get("error");
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);
507 logger.debug("An unknown remote exception occurred: '{}'", error.toString());
515 protected String post(URL url, Map<String, String> headers, String data) throws IOException {
516 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
518 if (headers != null) {
519 for (Map.Entry<String, String> entry : headers.entrySet()) {
520 connection.addRequestProperty(entry.getKey(), entry.getValue());
524 connection.addRequestProperty("Accept-Encoding", "gzip");
526 connection.setRequestMethod("POST");
527 connection.setDoOutput(true);
528 connection.connect();
530 OutputStream out = null;
533 out = connection.getOutputStream();
535 out.write(data.getBytes());
538 int statusCode = connection.getResponseCode();
539 if (statusCode != HttpURLConnection.HTTP_OK) {
540 logger.debug("An unexpected status code was returned: '{}'", statusCode);
548 String responseEncoding = connection.getHeaderField("Content-Encoding");
549 responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
551 ByteArrayOutputStream bos = new ByteArrayOutputStream();
553 InputStream in = connection.getInputStream();
555 in = connection.getInputStream();
556 if ("gzip".equalsIgnoreCase(responseEncoding)) {
557 in = new GZIPInputStream(in);
559 in = new BufferedInputStream(in);
561 byte[] buff = new byte[1024];
563 while ((n = in.read(buff)) > 0) {
564 bos.write(buff, 0, n);
574 return bos.toString();
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());
584 logger.debug("Scheduling the Miele event listener job");
586 if (eventListenerJob == null || eventListenerJob.isCancelled()) {
587 executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("binding-miele"));
588 eventListenerJob = executor.submit(eventListenerRunnable);
593 * This method is called whenever the connection to the given {@link MieleBridge} is lost.
596 public void onConnectionLost() {
597 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
601 * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
603 * @param bridge the hue bridge the connection is resumed to
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();
615 public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
616 if (applianceStatusListener == null) {
617 throw new IllegalArgumentException("It's not allowed to pass a null ApplianceStatusListener.");
619 boolean result = applianceStatusListeners.add(applianceStatusListener);
620 if (result && isInitialized()) {
623 for (HomeDevice hd : getHomeDevices()) {
624 applianceStatusListener.onApplianceAdded(hd);
630 public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
631 boolean result = applianceStatusListeners.remove(applianceStatusListener);
632 if (result && isInitialized()) {
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
648 public void dispose() {
650 if (pollingJob != null) {
651 pollingJob.cancel(true);
654 if (eventListenerJob != null) {
655 eventListenerJob.cancel(true);
656 eventListenerJob = null;
658 if (executor != null) {
659 executor.shutdownNow();