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.IllformedLocaleException;
35 import java.util.Iterator;
36 import java.util.List;
37 import java.util.Locale;
39 import java.util.Map.Entry;
40 import java.util.Random;
42 import java.util.concurrent.ConcurrentHashMap;
43 import java.util.concurrent.CopyOnWriteArrayList;
44 import java.util.concurrent.ExecutorService;
45 import java.util.concurrent.Executors;
46 import java.util.concurrent.Future;
47 import java.util.concurrent.ScheduledFuture;
48 import java.util.concurrent.TimeUnit;
49 import java.util.regex.Pattern;
50 import java.util.zip.GZIPInputStream;
52 import org.eclipse.jdt.annotation.NonNull;
53 import org.openhab.binding.miele.internal.FullyQualifiedApplianceIdentifier;
54 import org.openhab.core.common.NamedThreadFactory;
55 import org.openhab.core.config.core.Configuration;
56 import org.openhab.core.thing.Bridge;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.ThingTypeUID;
62 import org.openhab.core.thing.binding.BaseBridgeHandler;
63 import org.openhab.core.types.Command;
64 import org.openhab.core.types.RefreshType;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
68 import com.google.gson.Gson;
69 import com.google.gson.JsonArray;
70 import com.google.gson.JsonElement;
71 import com.google.gson.JsonObject;
72 import com.google.gson.JsonParser;
75 * The {@link MieleBridgeHandler} is responsible for handling commands, which are
76 * sent to one of the channels.
78 * @author Karel Goderis - Initial contribution
79 * @author Kai Kreuzer - Fixed lifecycle issues
80 * @author Martin Lepsy - Added protocol information to support WiFi devices & some refactoring for HomeDevice
81 * @author Jacob Laursen - Fixed multicast and protocol support (ZigBee/LAN)
83 public class MieleBridgeHandler extends BaseBridgeHandler {
86 public static final Set<@NonNull ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_XGW3000);
88 private static final String MIELE_CLASS = "com.miele.xgw3000.gateway.hdm.deviceclasses.Miele";
90 private static final Pattern IP_PATTERN = Pattern
91 .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
93 protected static final int POLLING_PERIOD = 15; // in seconds
94 protected static final int JSON_RPC_PORT = 2810;
95 protected static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
96 protected static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
97 private boolean lastBridgeConnectionState = false;
98 private boolean currentBridgeConnectionState = false;
100 protected Random rand = new Random();
101 protected Gson gson = new Gson();
102 private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
104 protected List<ApplianceStatusListener> applianceStatusListeners = new CopyOnWriteArrayList<>();
105 protected ScheduledFuture<?> pollingJob;
106 protected ExecutorService executor;
107 protected Future<?> eventListenerJob;
110 protected Map<String, HomeDevice> cachedHomeDevicesByApplianceId = new ConcurrentHashMap<String, HomeDevice>();
111 protected Map<String, HomeDevice> cachedHomeDevicesByRemoteUid = new ConcurrentHashMap<String, HomeDevice>();
114 protected Map<String, String> headers;
116 // Data structures to de-JSONify whatever Miele appliances are sending us
117 public class HomeDevice {
119 private static final String MIELE_APPLIANCE_CLASS = "com.miele.xgw3000.gateway.hdm.deviceclasses.MieleAppliance";
122 public String Status;
123 public String ParentUID;
124 public String ProtocolAdapterName;
125 public String Vendor;
128 public JsonArray DeviceClasses;
129 public String Version;
130 public String TimestampAdded;
131 public JsonObject Error;
132 public JsonObject Properties;
137 public FullyQualifiedApplianceIdentifier getApplianceIdentifier() {
138 return new FullyQualifiedApplianceIdentifier(this.UID);
142 public String getSerialNumber() {
143 return Properties.get("serial.number").getAsString();
147 public String getRemoteUid() {
148 JsonElement remoteUid = Properties.get("remote.uid");
149 if (remoteUid == null) {
150 // remote.uid and serial.number seems to be the same. If remote.uid
151 // is missing for some reason, it makes sense to provide fallback
153 return getSerialNumber();
155 return remoteUid.getAsString();
158 public String getConnectionType() {
159 JsonElement connectionType = Properties.get("connection.type");
160 if (connectionType == null) {
163 return connectionType.getAsString();
167 public String getApplianceModel() {
168 JsonElement model = Properties.get("miele.model");
172 return model.getAsString();
175 public String getDeviceClass() {
176 for (JsonElement dc : DeviceClasses) {
177 String dcStr = dc.getAsString();
178 if (dcStr.contains(MIELE_CLASS) && !dcStr.equals(MIELE_APPLIANCE_CLASS)) {
179 return dcStr.substring(MIELE_CLASS.length());
186 public class DeviceClassObject {
187 public String DeviceClassType;
188 public JsonArray Operations;
189 public String DeviceClass;
190 public JsonArray Properties;
192 DeviceClassObject() {
196 public class DeviceOperation {
198 public String Arguments;
199 public JsonObject Metadata;
205 public class DeviceProperty {
209 public JsonObject Metadata;
215 public MieleBridgeHandler(Bridge bridge) {
220 public void initialize() {
221 logger.debug("Initializing the Miele bridge handler.");
223 if (!validateConfig(getConfig())) {
228 url = new URL("http://" + (String) getConfig().get(HOST) + "/remote/json-rpc");
229 } catch (MalformedURLException e) {
230 logger.debug("An exception occurred while defining an URL :'{}'", e.getMessage());
231 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
235 // for future usage - no headers to be set for now
236 headers = new HashMap<>();
239 lastBridgeConnectionState = false;
240 updateStatus(ThingStatus.UNKNOWN);
243 private boolean validateConfig(Configuration config) {
244 if (config.get(HOST) == null || ((String) config.get(HOST)).isBlank()) {
245 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
246 "@text/offline.configuration-error.ip-address-not-set");
249 if (config.get(INTERFACE) == null || ((String) config.get(INTERFACE)).isBlank()) {
250 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
251 "@text/offline.configuration-error.ip-multicast-interface-not-set");
254 if (!IP_PATTERN.matcher((String) config.get(HOST)).matches()) {
255 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
256 "@text/offline.configuration-error.invalid-ip-gateway [\"" + config.get(HOST) + "\"]");
259 if (!IP_PATTERN.matcher((String) config.get(INTERFACE)).matches()) {
260 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
261 "@text/offline.configuration-error.invalid-ip-multicast-interface [\"" + config.get(INTERFACE)
265 String language = (String) config.get(LANGUAGE);
266 if (language != null && !language.isBlank()) {
268 new Locale.Builder().setLanguageTag(language).build();
269 } catch (IllformedLocaleException e) {
270 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
271 "@text/offline.configuration-error.invalid-language [\"" + language + "\"]");
278 private Runnable pollingRunnable = new Runnable() {
281 if (!IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()) {
282 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
287 if (isReachable((String) getConfig().get(HOST))) {
288 currentBridgeConnectionState = true;
290 currentBridgeConnectionState = false;
291 lastBridgeConnectionState = false;
295 if (!lastBridgeConnectionState && currentBridgeConnectionState) {
296 logger.debug("Connection to Miele Gateway {} established.", getConfig().get(HOST));
297 lastBridgeConnectionState = true;
298 onConnectionResumed();
301 if (!currentBridgeConnectionState || getThing().getStatus() != ThingStatus.ONLINE) {
305 List<HomeDevice> homeDevices = getHomeDevices();
306 for (HomeDevice hd : homeDevices) {
307 String key = hd.getApplianceIdentifier().getApplianceId();
308 if (!cachedHomeDevicesByApplianceId.containsKey(key)) {
309 logger.debug("A new appliance with ID '{}' has been added", hd.UID);
310 for (ApplianceStatusListener listener : applianceStatusListeners) {
311 listener.onApplianceAdded(hd);
314 cachedHomeDevicesByApplianceId.put(key, hd);
315 cachedHomeDevicesByRemoteUid.put(hd.getRemoteUid(), hd);
319 Set<@NonNull Entry<String, HomeDevice>> cachedEntries = cachedHomeDevicesByApplianceId.entrySet();
321 Iterator<@NonNull Entry<String, HomeDevice>> iterator = cachedEntries.iterator();
323 while (iterator.hasNext()) {
324 Entry<String, HomeDevice> cachedEntry = iterator.next();
325 HomeDevice cachedHomeDevice = cachedEntry.getValue();
326 if (!homeDevices.stream().anyMatch(d -> d.UID.equals(cachedHomeDevice.UID))) {
327 logger.debug("The appliance with ID '{}' has been removed", cachedHomeDevice.UID);
328 for (ApplianceStatusListener listener : applianceStatusListeners) {
329 listener.onApplianceRemoved(cachedHomeDevice);
331 cachedHomeDevicesByRemoteUid.remove(cachedHomeDevice.getRemoteUid());
336 for (Thing appliance : getThing().getThings()) {
337 if (appliance.getStatus() == ThingStatus.ONLINE) {
338 String applianceId = (String) appliance.getConfiguration().getProperties().get(APPLIANCE_ID);
339 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(
342 if (applianceIdentifier == null) {
343 logger.error("The appliance with ID '{}' was not found in appliance list from bridge.",
348 Object[] args = new Object[2];
349 args[0] = applianceIdentifier.getUid();
351 JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
353 if (result != null) {
354 for (JsonElement obj : result.getAsJsonArray()) {
356 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
358 // Skip com.prosyst.mbs.services.zigbee.hdm.deviceclasses.ReportingControl
359 if (dco == null || !dco.DeviceClass.startsWith(MIELE_CLASS)) {
363 for (ApplianceStatusListener listener : applianceStatusListeners) {
364 listener.onApplianceStateChanged(applianceIdentifier, dco);
366 } catch (Exception e) {
367 logger.debug("An exception occurred while querying an appliance : '{}'",
374 } catch (Exception e) {
375 logger.debug("An exception occurred while polling an appliance :'{}'", e.getMessage());
379 private boolean isReachable(String ipAddress) {
381 // note that InetAddress.isReachable is unreliable, see
382 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
383 // That's why we do an HTTP access instead
385 // If there is no connection, this line will fail
386 JsonElement result = invokeRPC("system.listMethods", null);
387 if (result == null) {
388 logger.debug("{} is not reachable", ipAddress);
391 } catch (Exception e) {
395 logger.debug("{} is reachable", ipAddress);
400 public List<HomeDevice> getHomeDevices() {
401 List<HomeDevice> devices = new ArrayList<>();
403 if (getThing().getStatus() == ThingStatus.ONLINE) {
405 String[] args = new String[1];
406 args[0] = "(type=SuperVision)";
407 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
409 for (JsonElement obj : result.getAsJsonArray()) {
410 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
413 } catch (Exception e) {
414 logger.debug("An exception occurred while getting the home devices :'{}'", e.getMessage());
420 private FullyQualifiedApplianceIdentifier getApplianceIdentifierFromApplianceId(String applianceId) {
421 HomeDevice homeDevice = this.cachedHomeDevicesByApplianceId.get(applianceId);
422 if (homeDevice == null) {
426 return homeDevice.getApplianceIdentifier();
429 private Runnable eventListenerRunnable = () -> {
430 if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
432 // Get the address that we are going to connect to.
433 InetAddress address1 = null;
434 InetAddress address2 = null;
436 address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
437 address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
438 } catch (UnknownHostException e) {
439 logger.debug("An exception occurred while setting up the multicast receiver : '{}'",
443 byte[] buf = new byte[256];
444 MulticastSocket clientSocket = null;
448 clientSocket = new MulticastSocket(JSON_RPC_PORT);
449 clientSocket.setSoTimeout(100);
451 clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
452 clientSocket.joinGroup(address1);
453 clientSocket.joinGroup(address2);
458 DatagramPacket packet = new DatagramPacket(buf, buf.length);
459 clientSocket.receive(packet);
461 String event = new String(packet.getData());
462 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
465 DeviceProperty dp = new DeviceProperty();
468 String[] parts = event.split("&");
469 for (String p : parts) {
470 String[] subparts = p.split("=");
471 switch (subparts[0]) {
473 dp.Name = subparts[1];
477 dp.Value = subparts[1].strip().trim();
491 // In XGW 3000 firmware 2.03 this was changed from UID (hdm:ZigBee:0123456789abcdef#210)
492 // to serial number (001234567890)
493 FullyQualifiedApplianceIdentifier applianceIdentifier;
494 if (id.startsWith("hdm:")) {
495 applianceIdentifier = new FullyQualifiedApplianceIdentifier(id);
497 HomeDevice device = cachedHomeDevicesByRemoteUid.get(id);
498 if (device == null) {
499 logger.debug("Multicast event not handled as id {} is unknown.", id);
502 applianceIdentifier = device.getApplianceIdentifier();
504 for (ApplianceStatusListener listener : applianceStatusListeners) {
505 listener.onAppliancePropertyChanged(applianceIdentifier, dp);
507 } catch (SocketTimeoutException e) {
510 } catch (InterruptedException ex) {
511 logger.debug("Eventlistener has been interrupted.");
516 } catch (Exception ex) {
517 logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
520 // restart the cycle with a clean slate
522 if (clientSocket != null) {
523 clientSocket.leaveGroup(address1);
524 clientSocket.leaveGroup(address2);
526 } catch (IOException e) {
527 logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
529 if (clientSocket != null) {
530 clientSocket.close();
535 logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
539 public JsonElement invokeOperation(String applianceId, String modelID, String methodName) {
540 if (getThing().getStatus() != ThingStatus.ONLINE) {
541 logger.debug("The Bridge is offline - operations can not be invoked.");
545 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(applianceId);
546 if (applianceIdentifier == null) {
548 "The appliance with ID '{}' was not found in appliance list from bridge - operations can not be invoked.",
553 Object[] args = new Object[4];
554 args[0] = applianceIdentifier.getUid();
555 args[1] = MIELE_CLASS + modelID;
556 args[2] = methodName;
559 return invokeRPC("HDAccess/invokeDCOOperation", args);
562 protected JsonElement invokeRPC(String methodName, Object[] args) {
563 int id = rand.nextInt(Integer.MAX_VALUE);
565 JsonObject req = new JsonObject();
566 req.addProperty("jsonrpc", "2.0");
567 req.addProperty("id", id);
568 req.addProperty("method", methodName);
570 JsonElement result = null;
572 JsonArray params = new JsonArray();
574 for (Object o : args) {
575 params.add(gson.toJsonTree(o));
578 req.add("params", params);
580 String requestData = req.toString();
581 String responseData = null;
583 responseData = post(url, headers, requestData);
584 } catch (Exception e) {
585 logger.debug("An exception occurred while posting data : '{}'", e.getMessage());
588 if (responseData != null) {
589 logger.debug("The request '{}' yields '{}'", requestData, responseData);
590 JsonObject resp = (JsonObject) JsonParser.parseReader(new StringReader(responseData));
592 result = resp.get("result");
593 JsonElement error = resp.get("error");
595 if (error != null && !error.isJsonNull()) {
596 if (error.isJsonPrimitive()) {
597 logger.debug("A remote exception occurred: '{}'", error.getAsString());
598 } else if (error.isJsonObject()) {
599 JsonObject o = error.getAsJsonObject();
600 Integer code = (o.has("code") ? o.get("code").getAsInt() : null);
601 String message = (o.has("message") ? o.get("message").getAsString() : null);
602 String data = (o.has("data") ? (o.get("data") instanceof JsonObject ? o.get("data").toString()
603 : o.get("data").getAsString()) : null);
604 logger.debug("A remote exception occurred: '{}':'{}':'{}'", code, message, data);
606 logger.debug("An unknown remote exception occurred: '{}'", error.toString());
614 protected String post(URL url, Map<String, String> headers, String data) throws IOException {
615 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
617 if (headers != null) {
618 for (Map.Entry<String, String> entry : headers.entrySet()) {
619 connection.addRequestProperty(entry.getKey(), entry.getValue());
623 connection.addRequestProperty("Accept-Encoding", "gzip");
625 connection.setRequestMethod("POST");
626 connection.setDoOutput(true);
627 connection.connect();
629 OutputStream out = null;
632 out = connection.getOutputStream();
634 out.write(data.getBytes());
637 int statusCode = connection.getResponseCode();
638 if (statusCode != HttpURLConnection.HTTP_OK) {
639 logger.debug("An unexpected status code was returned: '{}'", statusCode);
647 String responseEncoding = connection.getHeaderField("Content-Encoding");
648 responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
650 ByteArrayOutputStream bos = new ByteArrayOutputStream();
652 InputStream in = connection.getInputStream();
654 in = connection.getInputStream();
655 if ("gzip".equalsIgnoreCase(responseEncoding)) {
656 in = new GZIPInputStream(in);
658 in = new BufferedInputStream(in);
660 byte[] buff = new byte[1024];
662 while ((n = in.read(buff)) > 0) {
663 bos.write(buff, 0, n);
673 return bos.toString();
676 private synchronized void onUpdate() {
677 logger.debug("Scheduling the Miele polling job");
678 if (pollingJob == null || pollingJob.isCancelled()) {
679 logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
680 pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
681 logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
683 logger.debug("Scheduling the Miele event listener job");
685 if (eventListenerJob == null || eventListenerJob.isCancelled()) {
686 executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("binding-miele"));
687 eventListenerJob = executor.submit(eventListenerRunnable);
692 * This method is called whenever the connection to the given {@link MieleBridge} is lost.
695 public void onConnectionLost() {
696 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
700 * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
702 * @param bridge the Miele bridge the connection is resumed to
704 public void onConnectionResumed() {
705 updateStatus(ThingStatus.ONLINE);
706 for (Thing thing : getThing().getThings()) {
707 MieleApplianceHandler<?> handler = (MieleApplianceHandler<?>) thing.getHandler();
708 if (handler != null) {
709 handler.onBridgeConnectionResumed();
714 public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
715 if (applianceStatusListener == null) {
716 throw new IllegalArgumentException("It's not allowed to pass a null ApplianceStatusListener.");
718 boolean result = applianceStatusListeners.add(applianceStatusListener);
719 if (result && isInitialized()) {
722 for (HomeDevice hd : getHomeDevices()) {
723 applianceStatusListener.onApplianceAdded(hd);
729 public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
730 boolean result = applianceStatusListeners.remove(applianceStatusListener);
731 if (result && isInitialized()) {
738 public void handleCommand(ChannelUID channelUID, Command command) {
739 // Nothing to do here - the XGW bridge does not handle commands, for now
740 if (command instanceof RefreshType) {
741 // Placeholder for future refinement
747 public void dispose() {
749 if (pollingJob != null) {
750 pollingJob.cancel(true);
753 if (eventListenerJob != null) {
754 eventListenerJob.cancel(true);
755 eventListenerJob = null;
757 if (executor != null) {
758 executor.shutdownNow();