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.Iterator;
35 import java.util.List;
37 import java.util.Map.Entry;
38 import java.util.Random;
40 import java.util.concurrent.ConcurrentHashMap;
41 import java.util.concurrent.CopyOnWriteArrayList;
42 import java.util.concurrent.ExecutorService;
43 import java.util.concurrent.Executors;
44 import java.util.concurrent.Future;
45 import java.util.concurrent.ScheduledFuture;
46 import java.util.concurrent.TimeUnit;
47 import java.util.regex.Pattern;
48 import java.util.zip.GZIPInputStream;
50 import org.eclipse.jdt.annotation.NonNull;
51 import org.openhab.binding.miele.internal.FullyQualifiedApplianceIdentifier;
52 import org.openhab.core.common.NamedThreadFactory;
53 import org.openhab.core.thing.Bridge;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.ThingTypeUID;
59 import org.openhab.core.thing.binding.BaseBridgeHandler;
60 import org.openhab.core.types.Command;
61 import org.openhab.core.types.RefreshType;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
65 import com.google.gson.Gson;
66 import com.google.gson.JsonArray;
67 import com.google.gson.JsonElement;
68 import com.google.gson.JsonObject;
69 import com.google.gson.JsonParser;
72 * The {@link MieleBridgeHandler} is responsible for handling commands, which are
73 * sent to one of the channels.
75 * @author Karel Goderis - Initial contribution
76 * @author Kai Kreuzer - Fixed lifecycle issues
77 * @author Martin Lepsy - Added protocol information to support WiFi devices & some refactoring for HomeDevice
78 * @author Jacob Laursen - Fixed multicast and protocol support (ZigBee/LAN)
80 public class MieleBridgeHandler extends BaseBridgeHandler {
83 public static final Set<@NonNull ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_XGW3000);
85 private static final String MIELE_CLASS = "com.miele.xgw3000.gateway.hdm.deviceclasses.Miele";
87 private static final Pattern IP_PATTERN = Pattern
88 .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
90 protected static final int POLLING_PERIOD = 15; // in seconds
91 protected static final int JSON_RPC_PORT = 2810;
92 protected static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
93 protected static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
94 private boolean lastBridgeConnectionState = false;
95 private boolean currentBridgeConnectionState = false;
97 protected Random rand = new Random();
98 protected Gson gson = new Gson();
99 private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
101 protected List<ApplianceStatusListener> applianceStatusListeners = new CopyOnWriteArrayList<>();
102 protected ScheduledFuture<?> pollingJob;
103 protected ExecutorService executor;
104 protected Future<?> eventListenerJob;
107 protected Map<String, HomeDevice> cachedHomeDevicesByApplianceId = new ConcurrentHashMap<String, HomeDevice>();
108 protected Map<String, HomeDevice> cachedHomeDevicesByRemoteUid = new ConcurrentHashMap<String, HomeDevice>();
111 protected Map<String, String> headers;
113 // Data structures to de-JSONify whatever Miele appliances are sending us
114 public class HomeDevice {
116 private static final String MIELE_APPLIANCE_CLASS = "com.miele.xgw3000.gateway.hdm.deviceclasses.MieleAppliance";
119 public String Status;
120 public String ParentUID;
121 public String ProtocolAdapterName;
122 public String Vendor;
125 public JsonArray DeviceClasses;
126 public String Version;
127 public String TimestampAdded;
128 public JsonObject Error;
129 public JsonObject Properties;
134 public FullyQualifiedApplianceIdentifier getApplianceIdentifier() {
135 return new FullyQualifiedApplianceIdentifier(this.UID);
139 public String getSerialNumber() {
140 return Properties.get("serial.number").getAsString();
144 public String getRemoteUid() {
145 JsonElement remoteUid = Properties.get("remote.uid");
146 if (remoteUid == null) {
147 // remote.uid and serial.number seems to be the same. If remote.uid
148 // is missing for some reason, it makes sense to provide fallback
150 return getSerialNumber();
152 return remoteUid.getAsString();
155 public String getConnectionType() {
156 JsonElement connectionType = Properties.get("connection.type");
157 if (connectionType == null) {
160 return connectionType.getAsString();
164 public String getApplianceModel() {
165 JsonElement model = Properties.get("miele.model");
169 return model.getAsString();
172 public String getDeviceClass() {
173 for (JsonElement dc : DeviceClasses) {
174 String dcStr = dc.getAsString();
175 if (dcStr.contains(MIELE_CLASS) && !dcStr.equals(MIELE_APPLIANCE_CLASS)) {
176 return dcStr.substring(MIELE_CLASS.length());
183 public class DeviceClassObject {
184 public String DeviceClassType;
185 public JsonArray Operations;
186 public String DeviceClass;
187 public JsonArray Properties;
189 DeviceClassObject() {
193 public class DeviceOperation {
195 public String Arguments;
196 public JsonObject Metadata;
202 public class DeviceProperty {
206 public JsonObject Metadata;
212 public class DeviceMetaData {
213 public String Filter;
214 public String description;
215 public String LocalizedID;
216 public String LocalizedValue;
217 public JsonObject MieleEnum;
218 public String access;
221 public MieleBridgeHandler(Bridge bridge) {
226 public void initialize() {
227 logger.debug("Initializing the Miele bridge handler.");
229 if (getConfig().get(HOST) != null && getConfig().get(INTERFACE) != null) {
230 if (IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()
231 && IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
233 url = new URL("http://" + (String) getConfig().get(HOST) + "/remote/json-rpc");
234 } catch (MalformedURLException e) {
235 logger.debug("An exception occurred while defining an URL :'{}'", e.getMessage());
236 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
240 // for future usage - no headers to be set for now
241 headers = new HashMap<>();
244 updateStatus(ThingStatus.UNKNOWN);
246 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
247 "Invalid IP address for the Miele@Home gateway or multicast interface:" + getConfig().get(HOST)
248 + "/" + getConfig().get(INTERFACE));
251 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
252 "Cannot connect to the Miele gateway. host IP address or multicast interface are not set.");
256 private Runnable pollingRunnable = new Runnable() {
259 if (!IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()) {
260 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
265 if (isReachable((String) getConfig().get(HOST))) {
266 currentBridgeConnectionState = true;
268 currentBridgeConnectionState = false;
269 lastBridgeConnectionState = false;
273 if (!lastBridgeConnectionState && currentBridgeConnectionState) {
274 logger.debug("Connection to Miele Gateway {} established.", getConfig().get(HOST));
275 lastBridgeConnectionState = true;
276 onConnectionResumed();
279 if (!currentBridgeConnectionState || getThing().getStatus() != ThingStatus.ONLINE) {
283 List<HomeDevice> homeDevices = getHomeDevices();
284 for (HomeDevice hd : homeDevices) {
285 String key = hd.getApplianceIdentifier().getApplianceId();
286 if (!cachedHomeDevicesByApplianceId.containsKey(key)) {
287 logger.debug("A new appliance with ID '{}' has been added", hd.UID);
288 for (ApplianceStatusListener listener : applianceStatusListeners) {
289 listener.onApplianceAdded(hd);
292 cachedHomeDevicesByApplianceId.put(key, hd);
293 cachedHomeDevicesByRemoteUid.put(hd.getRemoteUid(), hd);
297 Set<@NonNull Entry<String, HomeDevice>> cachedEntries = cachedHomeDevicesByApplianceId.entrySet();
299 Iterator<@NonNull Entry<String, HomeDevice>> iterator = cachedEntries.iterator();
301 while (iterator.hasNext()) {
302 Entry<String, HomeDevice> cachedEntry = iterator.next();
303 HomeDevice cachedHomeDevice = cachedEntry.getValue();
304 if (!homeDevices.stream().anyMatch(d -> d.UID.equals(cachedHomeDevice.UID))) {
305 logger.debug("The appliance with ID '{}' has been removed", cachedHomeDevice.UID);
306 for (ApplianceStatusListener listener : applianceStatusListeners) {
307 listener.onApplianceRemoved(cachedHomeDevice);
309 cachedHomeDevicesByRemoteUid.remove(cachedHomeDevice.getRemoteUid());
314 for (Thing appliance : getThing().getThings()) {
315 if (appliance.getStatus() == ThingStatus.ONLINE) {
316 String applianceId = (String) appliance.getConfiguration().getProperties().get(APPLIANCE_ID);
317 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(
320 if (applianceIdentifier == null) {
321 logger.error("The appliance with ID '{}' was not found in appliance list from bridge.",
326 Object[] args = new Object[2];
327 args[0] = applianceIdentifier.getUid();
329 JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
331 if (result != null) {
332 for (JsonElement obj : result.getAsJsonArray()) {
334 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
336 // Skip com.prosyst.mbs.services.zigbee.hdm.deviceclasses.ReportingControl
337 if (dco == null || !dco.DeviceClass.startsWith(MIELE_CLASS)) {
341 for (ApplianceStatusListener listener : applianceStatusListeners) {
342 listener.onApplianceStateChanged(applianceIdentifier, dco);
344 } catch (Exception e) {
345 logger.debug("An exception occurred while querying an appliance : '{}'",
352 } catch (Exception e) {
353 logger.debug("An exception occurred while polling an appliance :'{}'", e.getMessage());
357 private boolean isReachable(String ipAddress) {
359 // note that InetAddress.isReachable is unreliable, see
360 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
361 // That's why we do an HTTP access instead
363 // If there is no connection, this line will fail
364 JsonElement result = invokeRPC("system.listMethods", null);
365 if (result == null) {
366 logger.debug("{} is not reachable", ipAddress);
369 } catch (Exception e) {
373 logger.debug("{} is reachable", ipAddress);
378 public List<HomeDevice> getHomeDevices() {
379 List<HomeDevice> devices = new ArrayList<>();
381 if (getThing().getStatus() == ThingStatus.ONLINE) {
383 String[] args = new String[1];
384 args[0] = "(type=SuperVision)";
385 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
387 for (JsonElement obj : result.getAsJsonArray()) {
388 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
391 } catch (Exception e) {
392 logger.debug("An exception occurred while getting the home devices :'{}'", e.getMessage());
398 private FullyQualifiedApplianceIdentifier getApplianceIdentifierFromApplianceId(String applianceId) {
399 HomeDevice homeDevice = this.cachedHomeDevicesByApplianceId.get(applianceId);
400 if (homeDevice == null) {
404 return homeDevice.getApplianceIdentifier();
407 private Runnable eventListenerRunnable = () -> {
408 if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
410 // Get the address that we are going to connect to.
411 InetAddress address1 = null;
412 InetAddress address2 = null;
414 address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
415 address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
416 } catch (UnknownHostException e) {
417 logger.debug("An exception occurred while setting up the multicast receiver : '{}'",
421 byte[] buf = new byte[256];
422 MulticastSocket clientSocket = null;
426 clientSocket = new MulticastSocket(JSON_RPC_PORT);
427 clientSocket.setSoTimeout(100);
429 clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
430 clientSocket.joinGroup(address1);
431 clientSocket.joinGroup(address2);
436 DatagramPacket packet = new DatagramPacket(buf, buf.length);
437 clientSocket.receive(packet);
439 String event = new String(packet.getData());
440 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
443 DeviceProperty dp = new DeviceProperty();
446 String[] parts = event.split("&");
447 for (String p : parts) {
448 String[] subparts = p.split("=");
449 switch (subparts[0]) {
451 dp.Name = subparts[1];
455 dp.Value = subparts[1].strip().trim();
469 // In XGW 3000 firmware 2.03 this was changed from UID (hdm:ZigBee:0123456789abcdef#210)
470 // to serial number (001234567890)
471 FullyQualifiedApplianceIdentifier applianceIdentifier;
472 if (id.startsWith("hdm:")) {
473 applianceIdentifier = new FullyQualifiedApplianceIdentifier(id);
475 HomeDevice device = cachedHomeDevicesByRemoteUid.get(id);
476 if (device == null) {
477 logger.debug("Multicast event not handled as id {} is unknown.", id);
480 applianceIdentifier = device.getApplianceIdentifier();
482 for (ApplianceStatusListener listener : applianceStatusListeners) {
483 listener.onAppliancePropertyChanged(applianceIdentifier, dp);
485 } catch (SocketTimeoutException e) {
488 } catch (InterruptedException ex) {
489 logger.debug("Eventlistener has been interrupted.");
494 } catch (Exception ex) {
495 logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
498 // restart the cycle with a clean slate
500 if (clientSocket != null) {
501 clientSocket.leaveGroup(address1);
502 clientSocket.leaveGroup(address2);
504 } catch (IOException e) {
505 logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
507 if (clientSocket != null) {
508 clientSocket.close();
513 logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
517 public JsonElement invokeOperation(String applianceId, String modelID, String methodName) {
518 if (getThing().getStatus() != ThingStatus.ONLINE) {
519 logger.debug("The Bridge is offline - operations can not be invoked.");
523 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(applianceId);
524 if (applianceIdentifier == null) {
526 "The appliance with ID '{}' was not found in appliance list from bridge - operations can not be invoked.",
531 Object[] args = new Object[4];
532 args[0] = applianceIdentifier.getUid();
533 args[1] = MIELE_CLASS + modelID;
534 args[2] = methodName;
537 return invokeRPC("HDAccess/invokeDCOOperation", args);
540 protected JsonElement invokeRPC(String methodName, Object[] args) {
541 int id = rand.nextInt(Integer.MAX_VALUE);
543 JsonObject req = new JsonObject();
544 req.addProperty("jsonrpc", "2.0");
545 req.addProperty("id", id);
546 req.addProperty("method", methodName);
548 JsonElement result = null;
550 JsonArray params = new JsonArray();
552 for (Object o : args) {
553 params.add(gson.toJsonTree(o));
556 req.add("params", params);
558 String requestData = req.toString();
559 String responseData = null;
561 responseData = post(url, headers, requestData);
562 } catch (Exception e) {
563 logger.debug("An exception occurred while posting data : '{}'", e.getMessage());
566 if (responseData != null) {
567 logger.debug("The request '{}' yields '{}'", requestData, responseData);
568 JsonObject resp = (JsonObject) JsonParser.parseReader(new StringReader(responseData));
570 result = resp.get("result");
571 JsonElement error = resp.get("error");
573 if (error != null && !error.isJsonNull()) {
574 if (error.isJsonPrimitive()) {
575 logger.debug("A remote exception occurred: '{}'", error.getAsString());
576 } else if (error.isJsonObject()) {
577 JsonObject o = error.getAsJsonObject();
578 Integer code = (o.has("code") ? o.get("code").getAsInt() : null);
579 String message = (o.has("message") ? o.get("message").getAsString() : null);
580 String data = (o.has("data") ? (o.get("data") instanceof JsonObject ? o.get("data").toString()
581 : o.get("data").getAsString()) : null);
582 logger.debug("A remote exception occurred: '{}':'{}':'{}'", code, message, data);
584 logger.debug("An unknown remote exception occurred: '{}'", error.toString());
592 protected String post(URL url, Map<String, String> headers, String data) throws IOException {
593 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
595 if (headers != null) {
596 for (Map.Entry<String, String> entry : headers.entrySet()) {
597 connection.addRequestProperty(entry.getKey(), entry.getValue());
601 connection.addRequestProperty("Accept-Encoding", "gzip");
603 connection.setRequestMethod("POST");
604 connection.setDoOutput(true);
605 connection.connect();
607 OutputStream out = null;
610 out = connection.getOutputStream();
612 out.write(data.getBytes());
615 int statusCode = connection.getResponseCode();
616 if (statusCode != HttpURLConnection.HTTP_OK) {
617 logger.debug("An unexpected status code was returned: '{}'", statusCode);
625 String responseEncoding = connection.getHeaderField("Content-Encoding");
626 responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
628 ByteArrayOutputStream bos = new ByteArrayOutputStream();
630 InputStream in = connection.getInputStream();
632 in = connection.getInputStream();
633 if ("gzip".equalsIgnoreCase(responseEncoding)) {
634 in = new GZIPInputStream(in);
636 in = new BufferedInputStream(in);
638 byte[] buff = new byte[1024];
640 while ((n = in.read(buff)) > 0) {
641 bos.write(buff, 0, n);
651 return bos.toString();
654 private synchronized void onUpdate() {
655 logger.debug("Scheduling the Miele polling job");
656 if (pollingJob == null || pollingJob.isCancelled()) {
657 logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
658 pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
659 logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
661 logger.debug("Scheduling the Miele event listener job");
663 if (eventListenerJob == null || eventListenerJob.isCancelled()) {
664 executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("binding-miele"));
665 eventListenerJob = executor.submit(eventListenerRunnable);
670 * This method is called whenever the connection to the given {@link MieleBridge} is lost.
673 public void onConnectionLost() {
674 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
678 * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
680 * @param bridge the Miele bridge the connection is resumed to
682 public void onConnectionResumed() {
683 updateStatus(ThingStatus.ONLINE);
684 for (Thing thing : getThing().getThings()) {
685 MieleApplianceHandler<?> handler = (MieleApplianceHandler<?>) thing.getHandler();
686 if (handler != null) {
687 handler.onBridgeConnectionResumed();
692 public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
693 if (applianceStatusListener == null) {
694 throw new IllegalArgumentException("It's not allowed to pass a null ApplianceStatusListener.");
696 boolean result = applianceStatusListeners.add(applianceStatusListener);
697 if (result && isInitialized()) {
700 for (HomeDevice hd : getHomeDevices()) {
701 applianceStatusListener.onApplianceAdded(hd);
707 public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
708 boolean result = applianceStatusListeners.remove(applianceStatusListener);
709 if (result && isInitialized()) {
716 public void handleCommand(ChannelUID channelUID, Command command) {
717 // Nothing to do here - the XGW bridge does not handle commands, for now
718 if (command instanceof RefreshType) {
719 // Placeholder for future refinement
725 public void dispose() {
727 if (pollingJob != null) {
728 pollingJob.cancel(true);
731 if (eventListenerJob != null) {
732 eventListenerJob.cancel(true);
733 eventListenerJob = null;
735 if (executor != null) {
736 executor.shutdownNow();