2 * Copyright (c) 2010-2022 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 getFirmwareVersion() {
148 return Properties.get("firmware.version").getAsString();
152 public String getRemoteUid() {
153 JsonElement remoteUid = Properties.get("remote.uid");
154 if (remoteUid == null) {
155 // remote.uid and serial.number seems to be the same. If remote.uid
156 // is missing for some reason, it makes sense to provide fallback
158 return getSerialNumber();
160 return remoteUid.getAsString();
163 public String getConnectionType() {
164 JsonElement connectionType = Properties.get("connection.type");
165 if (connectionType == null) {
168 return connectionType.getAsString();
171 public String getConnectionBaudRate() {
172 JsonElement baudRate = Properties.get("connection.baud.rate");
173 if (baudRate == null) {
176 return baudRate.getAsString();
180 public String getApplianceModel() {
181 JsonElement model = Properties.get("miele.model");
185 return model.getAsString();
188 public String getDeviceClass() {
189 for (JsonElement dc : DeviceClasses) {
190 String dcStr = dc.getAsString();
191 if (dcStr.contains(MIELE_CLASS) && !dcStr.equals(MIELE_APPLIANCE_CLASS)) {
192 return dcStr.substring(MIELE_CLASS.length());
199 public class DeviceClassObject {
200 public String DeviceClassType;
201 public JsonArray Operations;
202 public String DeviceClass;
203 public JsonArray Properties;
205 DeviceClassObject() {
209 public class DeviceOperation {
211 public String Arguments;
212 public JsonObject Metadata;
218 public class DeviceProperty {
222 public JsonObject Metadata;
228 public MieleBridgeHandler(Bridge bridge) {
233 public void initialize() {
234 logger.debug("Initializing the Miele bridge handler.");
236 if (!validateConfig(getConfig())) {
241 url = new URL("http://" + (String) getConfig().get(HOST) + "/remote/json-rpc");
242 } catch (MalformedURLException e) {
243 logger.debug("An exception occurred while defining an URL :'{}'", e.getMessage());
244 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
248 // for future usage - no headers to be set for now
249 headers = new HashMap<>();
252 lastBridgeConnectionState = false;
253 updateStatus(ThingStatus.UNKNOWN);
256 private boolean validateConfig(Configuration config) {
257 if (config.get(HOST) == null || ((String) config.get(HOST)).isBlank()) {
258 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
259 "@text/offline.configuration-error.ip-address-not-set");
262 if (config.get(INTERFACE) == null || ((String) config.get(INTERFACE)).isBlank()) {
263 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
264 "@text/offline.configuration-error.ip-multicast-interface-not-set");
267 if (!IP_PATTERN.matcher((String) config.get(HOST)).matches()) {
268 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
269 "@text/offline.configuration-error.invalid-ip-gateway [\"" + config.get(HOST) + "\"]");
272 if (!IP_PATTERN.matcher((String) config.get(INTERFACE)).matches()) {
273 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
274 "@text/offline.configuration-error.invalid-ip-multicast-interface [\"" + config.get(INTERFACE)
278 String language = (String) config.get(LANGUAGE);
279 if (language != null && !language.isBlank()) {
281 new Locale.Builder().setLanguageTag(language).build();
282 } catch (IllformedLocaleException e) {
283 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
284 "@text/offline.configuration-error.invalid-language [\"" + language + "\"]");
291 private Runnable pollingRunnable = new Runnable() {
294 if (!IP_PATTERN.matcher((String) getConfig().get(HOST)).matches()) {
295 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
300 if (isReachable((String) getConfig().get(HOST))) {
301 currentBridgeConnectionState = true;
303 currentBridgeConnectionState = false;
304 lastBridgeConnectionState = false;
308 if (!lastBridgeConnectionState && currentBridgeConnectionState) {
309 logger.debug("Connection to Miele Gateway {} established.", getConfig().get(HOST));
310 lastBridgeConnectionState = true;
311 onConnectionResumed();
314 if (!currentBridgeConnectionState || getThing().getStatus() != ThingStatus.ONLINE) {
318 List<HomeDevice> homeDevices = getHomeDevices();
319 for (HomeDevice hd : homeDevices) {
320 String key = hd.getApplianceIdentifier().getApplianceId();
321 if (!cachedHomeDevicesByApplianceId.containsKey(key)) {
322 logger.debug("A new appliance with ID '{}' has been added", hd.UID);
323 for (ApplianceStatusListener listener : applianceStatusListeners) {
324 listener.onApplianceAdded(hd);
327 cachedHomeDevicesByApplianceId.put(key, hd);
328 cachedHomeDevicesByRemoteUid.put(hd.getRemoteUid(), hd);
332 Set<@NonNull Entry<String, HomeDevice>> cachedEntries = cachedHomeDevicesByApplianceId.entrySet();
334 Iterator<@NonNull Entry<String, HomeDevice>> iterator = cachedEntries.iterator();
336 while (iterator.hasNext()) {
337 Entry<String, HomeDevice> cachedEntry = iterator.next();
338 HomeDevice cachedHomeDevice = cachedEntry.getValue();
339 if (!homeDevices.stream().anyMatch(d -> d.UID.equals(cachedHomeDevice.UID))) {
340 logger.debug("The appliance with ID '{}' has been removed", cachedHomeDevice.UID);
341 for (ApplianceStatusListener listener : applianceStatusListeners) {
342 listener.onApplianceRemoved(cachedHomeDevice);
344 cachedHomeDevicesByRemoteUid.remove(cachedHomeDevice.getRemoteUid());
349 for (Thing appliance : getThing().getThings()) {
350 if (appliance.getStatus() == ThingStatus.ONLINE) {
351 String applianceId = (String) appliance.getConfiguration().getProperties().get(APPLIANCE_ID);
352 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(
355 if (applianceIdentifier == null) {
356 logger.error("The appliance with ID '{}' was not found in appliance list from bridge.",
361 Object[] args = new Object[2];
362 args[0] = applianceIdentifier.getUid();
364 JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
366 if (result != null) {
367 for (JsonElement obj : result.getAsJsonArray()) {
369 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
371 // Skip com.prosyst.mbs.services.zigbee.hdm.deviceclasses.ReportingControl
372 if (dco == null || !dco.DeviceClass.startsWith(MIELE_CLASS)) {
376 for (ApplianceStatusListener listener : applianceStatusListeners) {
377 listener.onApplianceStateChanged(applianceIdentifier, dco);
379 } catch (Exception e) {
380 logger.debug("An exception occurred while querying an appliance : '{}'",
387 } catch (Exception e) {
388 logger.debug("An exception occurred while polling an appliance :'{}'", e.getMessage());
392 private boolean isReachable(String ipAddress) {
394 // note that InetAddress.isReachable is unreliable, see
395 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
396 // That's why we do an HTTP access instead
398 // If there is no connection, this line will fail
399 JsonElement result = invokeRPC("system.listMethods", null);
400 if (result == null) {
401 logger.debug("{} is not reachable", ipAddress);
404 } catch (Exception e) {
408 logger.debug("{} is reachable", ipAddress);
413 public List<HomeDevice> getHomeDevices() {
414 List<HomeDevice> devices = new ArrayList<>();
416 if (getThing().getStatus() == ThingStatus.ONLINE) {
418 String[] args = new String[1];
419 args[0] = "(type=SuperVision)";
420 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
422 for (JsonElement obj : result.getAsJsonArray()) {
423 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
426 } catch (Exception e) {
427 logger.debug("An exception occurred while getting the home devices :'{}'", e.getMessage());
433 private FullyQualifiedApplianceIdentifier getApplianceIdentifierFromApplianceId(String applianceId) {
434 HomeDevice homeDevice = this.cachedHomeDevicesByApplianceId.get(applianceId);
435 if (homeDevice == null) {
439 return homeDevice.getApplianceIdentifier();
442 private Runnable eventListenerRunnable = () -> {
443 if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
445 // Get the address that we are going to connect to.
446 InetAddress address1 = null;
447 InetAddress address2 = null;
449 address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
450 address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
451 } catch (UnknownHostException e) {
452 logger.debug("An exception occurred while setting up the multicast receiver : '{}'",
456 byte[] buf = new byte[256];
457 MulticastSocket clientSocket = null;
461 clientSocket = new MulticastSocket(JSON_RPC_PORT);
462 clientSocket.setSoTimeout(100);
464 clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
465 clientSocket.joinGroup(address1);
466 clientSocket.joinGroup(address2);
471 DatagramPacket packet = new DatagramPacket(buf, buf.length);
472 clientSocket.receive(packet);
474 String event = new String(packet.getData());
475 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
478 DeviceProperty dp = new DeviceProperty();
481 String[] parts = event.split("&");
482 for (String p : parts) {
483 String[] subparts = p.split("=");
484 switch (subparts[0]) {
486 dp.Name = subparts[1];
490 dp.Value = subparts[1].strip().trim();
504 // In XGW 3000 firmware 2.03 this was changed from UID (hdm:ZigBee:0123456789abcdef#210)
505 // to serial number (001234567890)
506 FullyQualifiedApplianceIdentifier applianceIdentifier;
507 if (id.startsWith("hdm:")) {
508 applianceIdentifier = new FullyQualifiedApplianceIdentifier(id);
510 HomeDevice device = cachedHomeDevicesByRemoteUid.get(id);
511 if (device == null) {
512 logger.debug("Multicast event not handled as id {} is unknown.", id);
515 applianceIdentifier = device.getApplianceIdentifier();
517 for (ApplianceStatusListener listener : applianceStatusListeners) {
518 listener.onAppliancePropertyChanged(applianceIdentifier, dp);
520 } catch (SocketTimeoutException e) {
523 } catch (InterruptedException ex) {
524 logger.debug("Eventlistener has been interrupted.");
529 } catch (Exception ex) {
530 logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
533 // restart the cycle with a clean slate
535 if (clientSocket != null) {
536 clientSocket.leaveGroup(address1);
537 clientSocket.leaveGroup(address2);
539 } catch (IOException e) {
540 logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
542 if (clientSocket != null) {
543 clientSocket.close();
548 logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
552 public JsonElement invokeOperation(String applianceId, String modelID, String methodName) {
553 if (getThing().getStatus() != ThingStatus.ONLINE) {
554 logger.debug("The Bridge is offline - operations can not be invoked.");
558 FullyQualifiedApplianceIdentifier applianceIdentifier = getApplianceIdentifierFromApplianceId(applianceId);
559 if (applianceIdentifier == null) {
561 "The appliance with ID '{}' was not found in appliance list from bridge - operations can not be invoked.",
566 Object[] args = new Object[4];
567 args[0] = applianceIdentifier.getUid();
568 args[1] = MIELE_CLASS + modelID;
569 args[2] = methodName;
572 return invokeRPC("HDAccess/invokeDCOOperation", args);
575 protected JsonElement invokeRPC(String methodName, Object[] args) {
576 int id = rand.nextInt(Integer.MAX_VALUE);
578 JsonObject req = new JsonObject();
579 req.addProperty("jsonrpc", "2.0");
580 req.addProperty("id", id);
581 req.addProperty("method", methodName);
583 JsonElement result = null;
585 JsonArray params = new JsonArray();
587 for (Object o : args) {
588 params.add(gson.toJsonTree(o));
591 req.add("params", params);
593 String requestData = req.toString();
594 String responseData = null;
596 responseData = post(url, headers, requestData);
597 } catch (Exception e) {
598 logger.debug("An exception occurred while posting data : '{}'", e.getMessage());
601 if (responseData != null) {
602 logger.trace("The request '{}' yields '{}'", requestData, responseData);
603 JsonObject resp = (JsonObject) JsonParser.parseReader(new StringReader(responseData));
605 result = resp.get("result");
606 JsonElement error = resp.get("error");
608 if (error != null && !error.isJsonNull()) {
609 if (error.isJsonPrimitive()) {
610 logger.debug("A remote exception occurred: '{}'", error.getAsString());
611 } else if (error.isJsonObject()) {
612 JsonObject o = error.getAsJsonObject();
613 Integer code = (o.has("code") ? o.get("code").getAsInt() : null);
614 String message = (o.has("message") ? o.get("message").getAsString() : null);
615 String data = (o.has("data") ? (o.get("data") instanceof JsonObject ? o.get("data").toString()
616 : o.get("data").getAsString()) : null);
617 logger.debug("A remote exception occurred: '{}':'{}':'{}'", code, message, data);
619 logger.debug("An unknown remote exception occurred: '{}'", error.toString());
627 protected String post(URL url, Map<String, String> headers, String data) throws IOException {
628 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
630 if (headers != null) {
631 for (Map.Entry<String, String> entry : headers.entrySet()) {
632 connection.addRequestProperty(entry.getKey(), entry.getValue());
636 connection.addRequestProperty("Accept-Encoding", "gzip");
638 connection.setRequestMethod("POST");
639 connection.setDoOutput(true);
640 connection.connect();
642 OutputStream out = null;
645 out = connection.getOutputStream();
647 out.write(data.getBytes());
650 int statusCode = connection.getResponseCode();
651 if (statusCode != HttpURLConnection.HTTP_OK) {
652 logger.debug("An unexpected status code was returned: '{}'", statusCode);
660 String responseEncoding = connection.getHeaderField("Content-Encoding");
661 responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
663 ByteArrayOutputStream bos = new ByteArrayOutputStream();
665 InputStream in = connection.getInputStream();
667 in = connection.getInputStream();
668 if ("gzip".equalsIgnoreCase(responseEncoding)) {
669 in = new GZIPInputStream(in);
671 in = new BufferedInputStream(in);
673 byte[] buff = new byte[1024];
675 while ((n = in.read(buff)) > 0) {
676 bos.write(buff, 0, n);
686 return bos.toString();
689 private synchronized void onUpdate() {
690 logger.debug("Scheduling the Miele polling job");
691 if (pollingJob == null || pollingJob.isCancelled()) {
692 logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
693 pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
694 logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
696 logger.debug("Scheduling the Miele event listener job");
698 if (eventListenerJob == null || eventListenerJob.isCancelled()) {
699 executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("binding-miele"));
700 eventListenerJob = executor.submit(eventListenerRunnable);
705 * This method is called whenever the connection to the given {@link MieleBridge} is lost.
708 public void onConnectionLost() {
709 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
713 * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
715 * @param bridge the Miele bridge the connection is resumed to
717 public void onConnectionResumed() {
718 updateStatus(ThingStatus.ONLINE);
719 for (Thing thing : getThing().getThings()) {
720 MieleApplianceHandler<?> handler = (MieleApplianceHandler<?>) thing.getHandler();
721 if (handler != null) {
722 handler.onBridgeConnectionResumed();
727 public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
728 if (applianceStatusListener == null) {
729 throw new IllegalArgumentException("It's not allowed to pass a null ApplianceStatusListener.");
731 boolean result = applianceStatusListeners.add(applianceStatusListener);
732 if (result && isInitialized()) {
735 for (HomeDevice hd : getHomeDevices()) {
736 applianceStatusListener.onApplianceAdded(hd);
742 public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
743 boolean result = applianceStatusListeners.remove(applianceStatusListener);
744 if (result && isInitialized()) {
751 public void handleCommand(ChannelUID channelUID, Command command) {
752 // Nothing to do here - the XGW bridge does not handle commands, for now
753 if (command instanceof RefreshType) {
754 // Placeholder for future refinement
760 public void dispose() {
762 if (pollingJob != null) {
763 pollingJob.cancel(true);
766 if (eventListenerJob != null) {
767 eventListenerJob.cancel(true);
768 eventListenerJob = null;
770 if (executor != null) {
771 executor.shutdownNow();