]> git.basschouten.com Git - openhab-addons.git/blob
59b43d5413801ff2a1db88a7de9eb7688c6f212a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.insteon.internal;
14
15 import java.io.IOException;
16 import java.util.ArrayList;
17 import java.util.Collections;
18 import java.util.Comparator;
19 import java.util.HashMap;
20 import java.util.HashSet;
21 import java.util.Iterator;
22 import java.util.List;
23 import java.util.Map;
24 import java.util.Map.Entry;
25 import java.util.Set;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.concurrent.ScheduledExecutorService;
28
29 import javax.xml.parsers.ParserConfigurationException;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.insteon.internal.config.InsteonChannelConfiguration;
34 import org.openhab.binding.insteon.internal.config.InsteonNetworkConfiguration;
35 import org.openhab.binding.insteon.internal.device.DeviceFeature;
36 import org.openhab.binding.insteon.internal.device.DeviceFeatureListener;
37 import org.openhab.binding.insteon.internal.device.DeviceType;
38 import org.openhab.binding.insteon.internal.device.DeviceTypeLoader;
39 import org.openhab.binding.insteon.internal.device.InsteonAddress;
40 import org.openhab.binding.insteon.internal.device.InsteonDevice;
41 import org.openhab.binding.insteon.internal.device.InsteonDevice.DeviceStatus;
42 import org.openhab.binding.insteon.internal.device.RequestQueueManager;
43 import org.openhab.binding.insteon.internal.driver.Driver;
44 import org.openhab.binding.insteon.internal.driver.DriverListener;
45 import org.openhab.binding.insteon.internal.driver.ModemDBEntry;
46 import org.openhab.binding.insteon.internal.driver.Poller;
47 import org.openhab.binding.insteon.internal.driver.Port;
48 import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
49 import org.openhab.binding.insteon.internal.handler.InsteonNetworkHandler;
50 import org.openhab.binding.insteon.internal.message.FieldException;
51 import org.openhab.binding.insteon.internal.message.Msg;
52 import org.openhab.binding.insteon.internal.message.MsgListener;
53 import org.openhab.binding.insteon.internal.utils.Utils;
54 import org.openhab.core.io.transport.serial.SerialPortManager;
55 import org.openhab.core.thing.ChannelUID;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.State;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60 import org.xml.sax.SAXException;
61
62 /**
63  * A majority of the code in this file is from the openHAB 1 binding
64  * org.openhab.binding.insteonplm.InsteonPLMActiveBinding. Including the comments below.
65  *
66  * -----------------------------------------------------------------------------------------------
67  *
68  * This class represents the actual implementation of the binding, and controls the high level flow
69  * of messages to and from the InsteonModem.
70  *
71  * Writing this binding has been an odyssey through the quirks of the Insteon protocol
72  * and Insteon devices. A substantial redesign was necessary at some point along the way.
73  * Here are some of the hard learned lessons that should be considered by anyone who wants
74  * to re-architect the binding:
75  *
76  * 1) The entries of the link database of the modem are not reliable. The category/subcategory entries in
77  * particular have junk data. Forget about using the modem database to generate a list of devices.
78  * The database should only be used to verify that a device has been linked.
79  *
80  * 2) Querying devices for their product information does not work either. First of all, battery operated devices
81  * (and there are a lot of those) have their radio switched off, and may generally not respond to product
82  * queries. Even main stream hardwired devices sold presently (like the 2477s switch and the 2477d dimmer)
83  * don't even have a product ID. Although supposedly part of the Insteon protocol, we have yet to
84  * encounter a device that would cough up a product id when queried, even among very recent devices. They
85  * simply return zeros as product id. Lesson: forget about querying devices to generate a device list.
86  *
87  * 3) Polling is a thorny issue: too much traffic on the network, and messages will be dropped left and right,
88  * and not just the poll related ones, but others as well. In particular sending back-to-back messages
89  * seemed to result in the second message simply never getting sent, without flow control back pressure
90  * (NACK) from the modem. For now the work-around is to space out the messages upon sending, and
91  * in general poll as infrequently as acceptable.
92  *
93  * 4) Instantiating and tracking devices when reported by the modem (either from the database, or when
94  * messages are received) leads to complicated state management because there is no guarantee at what
95  * point (if at all) the binding configuration will be available. It gets even more difficult when
96  * items are created, destroyed, and modified while the binding runs.
97  *
98  * For the above reasons, devices are only instantiated when they are referenced by binding information.
99  * As nice as it would be to discover devices and their properties dynamically, we have abandoned that
100  * path because it had led to a complicated and fragile system which due to the technical limitations
101  * above was inherently squirrely.
102  *
103  *
104  * @author Bernd Pfrommer - Initial contribution
105  * @author Daniel Pfrommer - openHAB 1 insteonplm binding
106  * @author Rob Nielsen - Port to openHAB 2 insteon binding
107  */
108 @NonNullByDefault
109 public class InsteonBinding {
110     private static final int DEAD_DEVICE_COUNT = 10;
111
112     private final Logger logger = LoggerFactory.getLogger(InsteonBinding.class);
113
114     private Driver driver;
115     private Map<InsteonAddress, InsteonDevice> devices = new ConcurrentHashMap<>();
116     private Map<String, InsteonChannelConfiguration> bindingConfigs = new ConcurrentHashMap<>();
117     private PortListener portListener = new PortListener();
118     private int devicePollIntervalMilliseconds = 300000;
119     private int deadDeviceTimeout = -1;
120     private boolean driverInitialized = false;
121     private int messagesReceived = 0;
122     private boolean isActive = false; // state of binding
123     private int x10HouseUnit = -1;
124     private InsteonNetworkHandler handler;
125
126     public InsteonBinding(InsteonNetworkHandler handler, InsteonNetworkConfiguration config,
127             SerialPortManager serialPortManager, ScheduledExecutorService scheduler) {
128         this.handler = handler;
129
130         String port = config.getPort();
131         logger.debug("port = '{}'", Utils.redactPassword(port));
132
133         driver = new Driver(port, portListener, serialPortManager, scheduler);
134         driver.addMsgListener(portListener);
135
136         Integer devicePollIntervalSeconds = config.getDevicePollIntervalSeconds();
137         if (devicePollIntervalSeconds != null) {
138             devicePollIntervalMilliseconds = devicePollIntervalSeconds * 1000;
139         }
140         logger.debug("device poll interval set to {} seconds", devicePollIntervalMilliseconds / 1000);
141
142         String additionalDevices = config.getAdditionalDevices();
143         if (additionalDevices != null) {
144             try {
145                 DeviceTypeLoader instance = DeviceTypeLoader.instance();
146                 if (instance != null) {
147                     instance.loadDeviceTypesXML(additionalDevices);
148                     logger.debug("read additional device definitions from {}", additionalDevices);
149                 } else {
150                     logger.warn("device type loader instance is null");
151                 }
152             } catch (ParserConfigurationException | SAXException | IOException e) {
153                 logger.warn("error reading additional devices from {}", additionalDevices, e);
154             }
155         }
156
157         String additionalFeatures = config.getAdditionalFeatures();
158         if (additionalFeatures != null) {
159             logger.debug("reading additional feature templates from {}", additionalFeatures);
160             DeviceFeature.readFeatureTemplates(additionalFeatures);
161         }
162
163         deadDeviceTimeout = devicePollIntervalMilliseconds * DEAD_DEVICE_COUNT;
164         logger.debug("dead device timeout set to {} seconds", deadDeviceTimeout / 1000);
165     }
166
167     public Driver getDriver() {
168         return driver;
169     }
170
171     public boolean isDriverInitialized() {
172         return driverInitialized;
173     }
174
175     public boolean startPolling() {
176         logger.debug("starting to poll {}", driver.getPortName());
177         driver.start();
178         return driver.isRunning();
179     }
180
181     public void setIsActive(boolean isActive) {
182         this.isActive = isActive;
183     }
184
185     public void sendCommand(String channelName, Command command) {
186         if (!isActive) {
187             logger.debug("not ready to handle commands yet, returning.");
188             return;
189         }
190
191         InsteonChannelConfiguration bindingConfig = bindingConfigs.get(channelName);
192         if (bindingConfig == null) {
193             logger.warn("unable to find binding config for channel {}", channelName);
194             return;
195         }
196
197         InsteonDevice dev = getDevice(bindingConfig.getAddress());
198         if (dev == null) {
199             logger.warn("no device found with insteon address {}", bindingConfig.getAddress());
200             return;
201         }
202
203         dev.processCommand(driver, bindingConfig, command);
204
205         logger.debug("found binding config for channel {}", channelName);
206     }
207
208     public void addFeatureListener(InsteonChannelConfiguration bindingConfig) {
209         logger.debug("adding listener for channel {}", bindingConfig.getChannelName());
210
211         InsteonAddress address = bindingConfig.getAddress();
212         InsteonDevice dev = getDevice(address);
213         if (dev == null) {
214             logger.warn("device for address {} is null", address);
215             return;
216         }
217         @Nullable
218         DeviceFeature f = dev.getFeature(bindingConfig.getFeature());
219         if (f == null || f.isFeatureGroup()) {
220             StringBuilder buf = new StringBuilder();
221             ArrayList<String> names = new ArrayList<>(dev.getFeatures().keySet());
222             Collections.sort(names);
223             for (String name : names) {
224                 DeviceFeature feature = dev.getFeature(name);
225                 if (feature != null && !feature.isFeatureGroup()) {
226                     if (buf.length() > 0) {
227                         buf.append(", ");
228                     }
229                     buf.append(name);
230                 }
231             }
232
233             logger.warn("channel {} references unknown feature: {}, it will be ignored. Known features for {} are: {}.",
234                     bindingConfig.getChannelName(), bindingConfig.getFeature(), bindingConfig.getProductKey(),
235                     buf.toString());
236             return;
237         }
238
239         DeviceFeatureListener fl = new DeviceFeatureListener(this, bindingConfig.getChannelUID(),
240                 bindingConfig.getChannelName());
241         fl.setParameters(bindingConfig.getParameters());
242         f.addListener(fl);
243
244         bindingConfigs.put(bindingConfig.getChannelName(), bindingConfig);
245     }
246
247     public void removeFeatureListener(ChannelUID channelUID) {
248         String channelName = channelUID.getAsString();
249
250         logger.debug("removing listener for channel {}", channelName);
251
252         for (Iterator<Entry<InsteonAddress, InsteonDevice>> it = devices.entrySet().iterator(); it.hasNext();) {
253             InsteonDevice dev = it.next().getValue();
254             boolean removedListener = dev.removeFeatureListener(channelName);
255             if (removedListener) {
256                 logger.trace("removed feature listener {} from dev {}", channelName, dev);
257             }
258         }
259     }
260
261     public void updateFeatureState(ChannelUID channelUID, State state) {
262         handler.updateState(channelUID, state);
263     }
264
265     public @Nullable InsteonDevice makeNewDevice(InsteonAddress addr, String productKey,
266             Map<String, Object> deviceConfigMap) {
267         DeviceTypeLoader instance = DeviceTypeLoader.instance();
268         if (instance == null) {
269             return null;
270         }
271         DeviceType dt = instance.getDeviceType(productKey);
272         if (dt == null) {
273             return null;
274         }
275         InsteonDevice dev = InsteonDevice.makeDevice(dt);
276         dev.setAddress(addr);
277         dev.setProductKey(productKey);
278         dev.setDriver(driver);
279         dev.setIsModem(productKey.equals(InsteonDeviceHandler.PLM_PRODUCT_KEY));
280         dev.setDeviceConfigMap(deviceConfigMap);
281         if (!dev.hasValidPollingInterval()) {
282             dev.setPollInterval(devicePollIntervalMilliseconds);
283         }
284         if (driver.isModemDBComplete() && dev.getStatus() != DeviceStatus.POLLING) {
285             int ndev = checkIfInModemDatabase(dev);
286             if (dev.hasModemDBEntry()) {
287                 dev.setStatus(DeviceStatus.POLLING);
288                 Poller.instance().startPolling(dev, ndev);
289             }
290         }
291         devices.put(addr, dev);
292
293         handler.insteonDeviceWasCreated();
294
295         return (dev);
296     }
297
298     public void removeDevice(InsteonAddress addr) {
299         InsteonDevice dev = devices.remove(addr);
300         if (dev == null) {
301             return;
302         }
303
304         if (dev.getStatus() == DeviceStatus.POLLING) {
305             Poller.instance().stopPolling(dev);
306         }
307     }
308
309     /**
310      * Checks if a device is in the modem link database, and, if the database
311      * is complete, logs a warning if the device is not present
312      *
313      * @param dev The device to search for in the modem database
314      * @return number of devices in modem database
315      */
316     private int checkIfInModemDatabase(InsteonDevice dev) {
317         try {
318             InsteonAddress addr = dev.getAddress();
319             Map<InsteonAddress, ModemDBEntry> dbes = driver.lockModemDBEntries();
320             if (dbes.containsKey(addr)) {
321                 if (!dev.hasModemDBEntry()) {
322                     logger.debug("device {} found in the modem database and {}.", addr, getLinkInfo(dbes, addr, true));
323                     dev.setHasModemDBEntry(true);
324                 }
325             } else {
326                 if (driver.isModemDBComplete() && !addr.isX10()) {
327                     logger.warn("device {} not found in the modem database. Did you forget to link?", addr);
328                     handler.deviceNotLinked(addr);
329                 }
330             }
331             return dbes.size();
332         } finally {
333             driver.unlockModemDBEntries();
334         }
335     }
336
337     public Map<String, String> getDatabaseInfo() {
338         try {
339             Map<String, String> databaseInfo = new HashMap<>();
340             Map<InsteonAddress, ModemDBEntry> dbes = driver.lockModemDBEntries();
341             for (InsteonAddress addr : dbes.keySet()) {
342                 String a = addr.toString();
343                 databaseInfo.put(a, a + ": " + getLinkInfo(dbes, addr, false));
344             }
345
346             return databaseInfo;
347         } finally {
348             driver.unlockModemDBEntries();
349         }
350     }
351
352     public boolean reconnect() {
353         driver.stop();
354         return startPolling();
355     }
356
357     /**
358      * Everything below was copied from Insteon PLM v1
359      */
360
361     /**
362      * Clean up all state.
363      */
364     public void shutdown() {
365         logger.debug("shutting down Insteon bridge");
366         driver.stop();
367         devices.clear();
368         RequestQueueManager.destroyInstance();
369         Poller.instance().stop();
370         isActive = false;
371     }
372
373     /**
374      * Method to find a device by address
375      *
376      * @param aAddr the insteon address to search for
377      * @return reference to the device, or null if not found
378      */
379     public @Nullable InsteonDevice getDevice(@Nullable InsteonAddress aAddr) {
380         InsteonDevice dev = (aAddr == null) ? null : devices.get(aAddr);
381         return (dev);
382     }
383
384     private String getLinkInfo(Map<InsteonAddress, ModemDBEntry> dbes, InsteonAddress a, boolean prefix) {
385         ModemDBEntry dbe = dbes.get(a);
386         if (dbe == null) {
387             return "";
388         }
389         List<Byte> controls = dbe.getControls();
390         List<Byte> responds = dbe.getRespondsTo();
391
392         Port port = dbe.getPort();
393         if (port == null) {
394             return "";
395         }
396         String deviceName = port.getDeviceName();
397         String s = deviceName.startsWith("/hub") ? "hub" : "plm";
398         StringBuilder buf = new StringBuilder();
399         if (port.isModem(a)) {
400             if (prefix) {
401                 buf.append("it is the ");
402             }
403             buf.append(s);
404             buf.append(" (");
405             buf.append(Utils.redactPassword(deviceName));
406             buf.append(")");
407         } else {
408             if (prefix) {
409                 buf.append("the ");
410             }
411             buf.append(s);
412             buf.append(" controls groups (");
413             buf.append(toGroupString(controls));
414             buf.append(") and responds to groups (");
415             buf.append(toGroupString(responds));
416             buf.append(")");
417         }
418
419         return buf.toString();
420     }
421
422     private String toGroupString(List<Byte> group) {
423         List<Byte> sorted = new ArrayList<>(group);
424         Collections.sort(sorted, new Comparator<>() {
425             @Override
426             public int compare(Byte b1, Byte b2) {
427                 int i1 = b1 & 0xFF;
428                 int i2 = b2 & 0xFF;
429                 return i1 < i2 ? -1 : i1 == i2 ? 0 : 1;
430             }
431         });
432
433         StringBuilder buf = new StringBuilder();
434         for (Byte b : sorted) {
435             if (buf.length() > 0) {
436                 buf.append(",");
437             }
438             buf.append(b & 0xFF);
439         }
440
441         return buf.toString();
442     }
443
444     public void logDeviceStatistics() {
445         String msg = String.format("devices: %3d configured, %3d polling, msgs received: %5d", devices.size(),
446                 Poller.instance().getSizeOfQueue(), messagesReceived);
447         logger.debug("{}", msg);
448         messagesReceived = 0;
449         for (InsteonDevice dev : devices.values()) {
450             if (dev.isModem()) {
451                 continue;
452             }
453             if (deadDeviceTimeout > 0 && dev.getPollOverDueTime() > deadDeviceTimeout) {
454                 logger.debug("device {} has not responded to polls for {} sec", dev.toString(),
455                         dev.getPollOverDueTime() / 3600);
456             }
457         }
458     }
459
460     /**
461      * Handles messages that come in from the ports.
462      * Will only process one message at a time.
463      */
464     private class PortListener implements MsgListener, DriverListener {
465         @Override
466         public void msg(Msg msg) {
467             if (msg.isEcho() || msg.isPureNack()) {
468                 return;
469             }
470             messagesReceived++;
471             logger.debug("got msg: {}", msg);
472             if (msg.isX10()) {
473                 handleX10Message(msg);
474             } else {
475                 handleInsteonMessage(msg);
476             }
477         }
478
479         @Override
480         public void driverCompletelyInitialized() {
481             List<String> missing = new ArrayList<>();
482             try {
483                 Map<InsteonAddress, ModemDBEntry> dbes = driver.lockModemDBEntries();
484                 logger.debug("modem database has {} entries!", dbes.size());
485                 if (dbes.isEmpty()) {
486                     logger.warn("the modem link database is empty!");
487                 }
488                 for (InsteonAddress k : dbes.keySet()) {
489                     logger.debug("modem db entry: {}", k);
490                 }
491                 Set<InsteonAddress> addrs = new HashSet<>();
492                 for (InsteonDevice dev : devices.values()) {
493                     InsteonAddress a = dev.getAddress();
494                     if (!dbes.containsKey(a)) {
495                         if (!a.isX10()) {
496                             logger.warn("device {} not found in the modem database. Did you forget to link?", a);
497                             handler.deviceNotLinked(a);
498                         }
499                     } else {
500                         if (!dev.hasModemDBEntry()) {
501                             addrs.add(a);
502                             logger.debug("device {} found in the modem database and {}.", a,
503                                     getLinkInfo(dbes, a, true));
504                             dev.setHasModemDBEntry(true);
505                         }
506                         if (dev.getStatus() != DeviceStatus.POLLING) {
507                             Poller.instance().startPolling(dev, dbes.size());
508                         }
509                     }
510                 }
511
512                 for (InsteonAddress k : dbes.keySet()) {
513                     if (!addrs.contains(k)) {
514                         logger.debug("device {} found in the modem database, but is not configured as a thing and {}.",
515                                 k, getLinkInfo(dbes, k, true));
516
517                         missing.add(k.toString());
518                     }
519                 }
520             } finally {
521                 driver.unlockModemDBEntries();
522             }
523
524             if (!missing.isEmpty()) {
525                 handler.addMissingDevices(missing);
526             }
527
528             driverInitialized = true;
529         }
530
531         @Override
532         public void disconnected() {
533             handler.bindingDisconnected();
534         }
535
536         private void handleInsteonMessage(Msg msg) {
537             InsteonAddress toAddr = msg.getAddr("toAddress");
538             if (!msg.isBroadcast() && !driver.isMsgForUs(toAddr)) {
539                 // not for one of our modems, do not process
540                 return;
541             }
542             InsteonAddress fromAddr = msg.getAddr("fromAddress");
543             if (fromAddr == null) {
544                 logger.debug("invalid fromAddress, ignoring msg {}", msg);
545                 return;
546             }
547             handleMessage(fromAddr, msg);
548         }
549
550         private void handleX10Message(Msg msg) {
551             try {
552                 int x10Flag = msg.getByte("X10Flag") & 0xff;
553                 int rawX10 = msg.getByte("rawX10") & 0xff;
554                 if (x10Flag == 0x80) { // actual command
555                     if (x10HouseUnit != -1) {
556                         InsteonAddress fromAddr = new InsteonAddress((byte) x10HouseUnit);
557                         handleMessage(fromAddr, msg);
558                     }
559                 } else if (x10Flag == 0) {
560                     // what unit the next cmd will apply to
561                     x10HouseUnit = rawX10 & 0xFF;
562                 }
563             } catch (FieldException e) {
564                 logger.warn("got bad X10 message: {}", msg, e);
565                 return;
566             }
567         }
568
569         private void handleMessage(InsteonAddress fromAddr, Msg msg) {
570             InsteonDevice dev = getDevice(fromAddr);
571             if (dev == null) {
572                 logger.debug("dropping message from unknown device with address {}", fromAddr);
573             } else {
574                 dev.handleMessage(msg);
575             }
576         }
577     }
578 }