2 * Copyright (c) 2010-2020 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.ScheduledFuture;
40 import java.util.concurrent.TimeUnit;
41 import java.util.regex.Pattern;
42 import java.util.zip.GZIPInputStream;
44 import org.apache.commons.lang.StringUtils;
45 import org.openhab.core.thing.Bridge;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.ThingTypeUID;
51 import org.openhab.core.thing.binding.BaseBridgeHandler;
52 import org.openhab.core.types.Command;
53 import org.openhab.core.types.RefreshType;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
57 import com.google.gson.Gson;
58 import com.google.gson.JsonArray;
59 import com.google.gson.JsonElement;
60 import com.google.gson.JsonObject;
61 import com.google.gson.JsonParser;
64 * The {@link MieleBridgeHandler} is responsible for handling commands, which are
65 * sent to one of the channels.
67 * @author Karel Goderis - Initial contribution
68 * @author Kai Kreuzer - Fixed lifecycle issues
69 * @author Martin Lepsy - Added protocol information to support WiFi devices & some refactoring for HomeDevice
71 public class MieleBridgeHandler extends BaseBridgeHandler {
73 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_XGW3000);
75 private static final Pattern IP_PATTERN = Pattern
76 .compile("^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
78 protected static final int POLLING_PERIOD = 15; // in seconds
79 protected static final int JSON_RPC_PORT = 2810;
80 protected static final String JSON_RPC_MULTICAST_IP1 = "239.255.68.139";
81 protected static final String JSON_RPC_MULTICAST_IP2 = "224.255.68.139";
82 private boolean lastBridgeConnectionState = false;
83 private boolean currentBridgeConnectionState = false;
85 protected Random rand = new Random();
86 protected Gson gson = new Gson();
87 private final Logger logger = LoggerFactory.getLogger(MieleBridgeHandler.class);
89 protected List<ApplianceStatusListener> applianceStatusListeners = new CopyOnWriteArrayList<>();
90 protected ScheduledFuture<?> pollingJob;
91 protected ScheduledFuture<?> eventListenerJob;
93 protected List<HomeDevice> previousHomeDevices = new CopyOnWriteArrayList<>();
96 protected Map<String, String> headers;
98 // Data structures to de-JSONify whatever Miele appliances are sending us
99 public class HomeDevice {
101 private static final String PROTOCOL_LAN = "LAN";
104 public String Status;
105 public String ParentUID;
106 public String ProtocolAdapterName;
107 public String Vendor;
110 public JsonArray DeviceClasses;
111 public String Version;
112 public String TimestampAdded;
113 public JsonObject Error;
114 public JsonObject Properties;
119 public String getId() {
120 return getApplianceId().replaceAll("[^a-zA-Z0-9_]", "_");
123 public String getProtocol() {
124 return ProtocolAdapterName.equals(PROTOCOL_LAN) ? HDM_LAN : HDM_ZIGBEE;
127 public String getApplianceId() {
128 return ProtocolAdapterName.equals(PROTOCOL_LAN) ? StringUtils.right(UID, UID.length() - HDM_LAN.length())
129 : StringUtils.right(UID, UID.length() - HDM_ZIGBEE.length());
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 UID = appliance.getProperties().get(PROTOCOL_PROPERTY_NAME)
265 + (String) appliance.getConfiguration().getProperties().get(APPLIANCE_ID);
267 Object[] args = new Object[2];
270 JsonElement result = invokeRPC("HDAccess/getDeviceClassObjects", args);
272 if (result != null) {
273 for (JsonElement obj : result.getAsJsonArray()) {
275 DeviceClassObject dco = gson.fromJson(obj, DeviceClassObject.class);
277 for (ApplianceStatusListener listener : applianceStatusListeners) {
278 listener.onApplianceStateChanged(UID, dco);
280 } catch (Exception e) {
281 logger.debug("An exception occurred while quering an appliance : '{}'",
290 } catch (Exception e) {
291 logger.debug("An exception occurred while polling an appliance :'{}'", e.getMessage());
294 logger.debug("Invalid IP address for the Miele@Home gateway : '{}'", getConfig().get(HOST));
298 private boolean isReachable(String ipAddress) {
300 // note that InetAddress.isReachable is unreliable, see
301 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
302 // That's why we do an HTTP access instead
304 // If there is no connection, this line will fail
305 JsonElement result = invokeRPC("system.listMethods", null);
306 if (result == null) {
307 logger.debug("{} is not reachable", ipAddress);
310 } catch (Exception e) {
314 logger.debug("{} is reachable", ipAddress);
319 public List<HomeDevice> getHomeDevices() {
320 List<HomeDevice> devices = new ArrayList<>();
322 if (getThing().getStatus() == ThingStatus.ONLINE) {
324 String[] args = new String[1];
325 args[0] = "(type=SuperVision)";
326 JsonElement result = invokeRPC("HDAccess/getHomeDevices", args);
328 for (JsonElement obj : result.getAsJsonArray()) {
329 HomeDevice hd = gson.fromJson(obj, HomeDevice.class);
332 } catch (Exception e) {
333 logger.debug("An exception occurred while getting the home devices :'{}'", e.getMessage());
339 private Runnable eventListenerRunnable = () -> {
340 if (IP_PATTERN.matcher((String) getConfig().get(INTERFACE)).matches()) {
342 // Get the address that we are going to connect to.
343 InetAddress address1 = null;
344 InetAddress address2 = null;
346 address1 = InetAddress.getByName(JSON_RPC_MULTICAST_IP1);
347 address2 = InetAddress.getByName(JSON_RPC_MULTICAST_IP2);
348 } catch (UnknownHostException e) {
349 logger.debug("An exception occurred while setting up the multicast receiver : '{}'",
353 byte[] buf = new byte[256];
354 MulticastSocket clientSocket = null;
358 clientSocket = new MulticastSocket(JSON_RPC_PORT);
359 clientSocket.setSoTimeout(100);
361 clientSocket.setInterface(InetAddress.getByName((String) getConfig().get(INTERFACE)));
362 clientSocket.joinGroup(address1);
363 clientSocket.joinGroup(address2);
368 DatagramPacket packet = new DatagramPacket(buf, buf.length);
369 clientSocket.receive(packet);
371 String event = new String(packet.getData());
372 logger.debug("Received a multicast event '{}' from '{}:{}'", event, packet.getAddress(),
375 DeviceProperty dp = new DeviceProperty();
378 String[] parts = StringUtils.split(event, "&");
379 for (String p : parts) {
380 String[] subparts = StringUtils.split(p, "=");
381 switch (subparts[0]) {
383 dp.Name = subparts[1];
387 dp.Value = subparts[1];
397 for (ApplianceStatusListener listener : applianceStatusListeners) {
398 listener.onAppliancePropertyChanged(uid, dp);
400 } catch (SocketTimeoutException e) {
404 } catch (Exception ex) {
405 logger.debug("An exception occurred while receiving multicast packets : '{}'", ex.getMessage());
408 // restart the cycle with a clean slate
410 if (clientSocket != null) {
411 clientSocket.leaveGroup(address1);
412 clientSocket.leaveGroup(address2);
414 } catch (IOException e) {
415 logger.debug("An exception occurred while leaving multicast group : '{}'", e.getMessage());
417 if (clientSocket != null) {
418 clientSocket.close();
423 logger.debug("Invalid IP address for the multicast interface : '{}'", getConfig().get(INTERFACE));
427 public JsonElement invokeOperation(String UID, String modelID, String methodName) {
428 return invokeOperation(UID, modelID, methodName, HDM_ZIGBEE);
431 public JsonElement invokeOperation(String UID, String modelID, String methodName, String protocol) {
432 if (getThing().getStatus() == ThingStatus.ONLINE) {
433 Object[] args = new Object[4];
434 args[0] = protocol + UID;
435 args[1] = "com.miele.xgw3000.gateway.hdm.deviceclasses.Miele" + modelID;
436 args[2] = methodName;
438 return invokeRPC("HDAccess/invokeDCOOperation", args);
440 logger.debug("The Bridge is offline - operations can not be invoked.");
445 protected JsonElement invokeRPC(String methodName, Object[] args) {
446 int id = rand.nextInt(Integer.MAX_VALUE);
448 JsonObject req = new JsonObject();
449 req.addProperty("jsonrpc", "2.0");
450 req.addProperty("id", id);
451 req.addProperty("method", methodName);
453 JsonElement result = null;
455 JsonArray params = new JsonArray();
457 for (Object o : args) {
458 params.add(gson.toJsonTree(o));
461 req.add("params", params);
463 String requestData = req.toString();
464 String responseData = null;
466 responseData = post(url, headers, requestData);
467 } catch (Exception e) {
468 logger.debug("An exception occurred while posting data : '{}'", e.getMessage());
471 if (responseData != null) {
472 logger.debug("The request '{}' yields '{}'", requestData, responseData);
473 JsonParser parser = new JsonParser();
474 JsonObject resp = (JsonObject) parser.parse(new StringReader(responseData));
476 result = resp.get("result");
477 JsonElement error = resp.get("error");
479 if (error != null && !error.isJsonNull()) {
480 if (error.isJsonPrimitive()) {
481 logger.debug("A remote exception occurred: '{}'", error.getAsString());
482 } else if (error.isJsonObject()) {
483 JsonObject o = error.getAsJsonObject();
484 Integer code = (o.has("code") ? o.get("code").getAsInt() : null);
485 String message = (o.has("message") ? o.get("message").getAsString() : null);
486 String data = (o.has("data") ? (o.get("data") instanceof JsonObject ? o.get("data").toString()
487 : o.get("data").getAsString()) : null);
488 logger.debug("A remote exception occurred: '{}':'{}':'{}'", code, message, data);
490 logger.debug("An unknown remote exception occurred: '{}'", error.toString());
498 protected String post(URL url, Map<String, String> headers, String data) throws IOException {
499 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
501 if (headers != null) {
502 for (Map.Entry<String, String> entry : headers.entrySet()) {
503 connection.addRequestProperty(entry.getKey(), entry.getValue());
507 connection.addRequestProperty("Accept-Encoding", "gzip");
509 connection.setRequestMethod("POST");
510 connection.setDoOutput(true);
511 connection.connect();
513 OutputStream out = null;
516 out = connection.getOutputStream();
518 out.write(data.getBytes());
521 int statusCode = connection.getResponseCode();
522 if (statusCode != HttpURLConnection.HTTP_OK) {
523 logger.debug("An unexpected status code was returned: '{}'", statusCode);
531 String responseEncoding = connection.getHeaderField("Content-Encoding");
532 responseEncoding = (responseEncoding == null ? "" : responseEncoding.trim());
534 ByteArrayOutputStream bos = new ByteArrayOutputStream();
536 InputStream in = connection.getInputStream();
538 in = connection.getInputStream();
539 if ("gzip".equalsIgnoreCase(responseEncoding)) {
540 in = new GZIPInputStream(in);
542 in = new BufferedInputStream(in);
544 byte[] buff = new byte[1024];
546 while ((n = in.read(buff)) > 0) {
547 bos.write(buff, 0, n);
557 return bos.toString();
560 private synchronized void onUpdate() {
561 logger.debug("Scheduling the Miele polling job");
562 if (pollingJob == null || pollingJob.isCancelled()) {
563 logger.trace("Scheduling the Miele polling job period is {}", POLLING_PERIOD);
564 pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, POLLING_PERIOD, TimeUnit.SECONDS);
565 logger.trace("Scheduling the Miele polling job Job is done ?{}", pollingJob.isDone());
567 logger.debug("Scheduling the Miele event listener job");
569 if (eventListenerJob == null || eventListenerJob.isCancelled()) {
570 eventListenerJob = scheduler.schedule(eventListenerRunnable, 0, TimeUnit.SECONDS);
575 * This method is called whenever the connection to the given {@link MieleBridge} is lost.
578 public void onConnectionLost() {
579 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
583 * This method is called whenever the connection to the given {@link MieleBridge} is resumed.
585 * @param bridge the hue bridge the connection is resumed to
587 public void onConnectionResumed() {
588 updateStatus(ThingStatus.ONLINE);
589 for (Thing thing : getThing().getThings()) {
590 MieleApplianceHandler<?> handler = (MieleApplianceHandler<?>) thing.getHandler();
591 if (handler != null) {
592 handler.onBridgeConnectionResumed();
597 public boolean registerApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
598 if (applianceStatusListener == null) {
599 throw new IllegalArgumentException("It's not allowed to pass a null ApplianceStatusListener.");
601 boolean result = applianceStatusListeners.add(applianceStatusListener);
602 if (result && isInitialized()) {
605 for (HomeDevice hd : getHomeDevices()) {
606 applianceStatusListener.onApplianceAdded(hd);
612 public boolean unregisterApplianceStatusListener(ApplianceStatusListener applianceStatusListener) {
613 boolean result = applianceStatusListeners.remove(applianceStatusListener);
614 if (result && isInitialized()) {
621 public void handleCommand(ChannelUID channelUID, Command command) {
622 // Nothing to do here - the XGW bridge does not handle commands, for now
623 if (command instanceof RefreshType) {
624 // Placeholder for future refinement
630 public void dispose() {
632 if (pollingJob != null) {
633 pollingJob.cancel(true);