]> git.basschouten.com Git - openhab-addons.git/blob
c5e5de007ef4e9ce617dadb8e15696e73ccc2032
[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.device;
14
15 import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
16
17 import java.io.IOException;
18 import java.util.HashMap;
19 import java.util.LinkedHashMap;
20 import java.util.LinkedList;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.Objects;
24 import java.util.Optional;
25 import java.util.PriorityQueue;
26 import java.util.Queue;
27 import java.util.Set;
28 import java.util.function.Predicate;
29 import java.util.stream.Collectors;
30 import java.util.stream.Stream;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.insteon.internal.config.InsteonChannelConfiguration;
35 import org.openhab.binding.insteon.internal.device.DeviceFeature.QueryStatus;
36 import org.openhab.binding.insteon.internal.device.database.LinkDB;
37 import org.openhab.binding.insteon.internal.device.database.LinkDBChange;
38 import org.openhab.binding.insteon.internal.device.database.LinkDBRecord;
39 import org.openhab.binding.insteon.internal.device.database.ModemDB;
40 import org.openhab.binding.insteon.internal.device.database.ModemDBChange;
41 import org.openhab.binding.insteon.internal.device.database.ModemDBEntry;
42 import org.openhab.binding.insteon.internal.device.database.ModemDBRecord;
43 import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.KeypadButtonToggleMode;
44 import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
45 import org.openhab.binding.insteon.internal.transport.message.GroupMessageStateMachine;
46 import org.openhab.binding.insteon.internal.transport.message.GroupMessageStateMachine.GroupMessageType;
47 import org.openhab.binding.insteon.internal.transport.message.Msg;
48 import org.openhab.binding.insteon.internal.utils.BinaryUtils;
49 import org.openhab.core.library.types.DecimalType;
50 import org.openhab.core.library.types.OnOffType;
51 import org.openhab.core.library.types.QuantityType;
52 import org.openhab.core.library.unit.Units;
53 import org.openhab.core.types.Command;
54 import org.openhab.core.types.State;
55 import org.openhab.core.types.UnDefType;
56
57 /**
58  * The {@link InsteonDevice} represents an Insteon device
59  *
60  * @author Bernd Pfrommer - Initial contribution
61  * @author Rob Nielsen - Port to openHAB 2 insteon binding
62  * @author Jeremy Setton - Rewrite insteon binding
63  */
64 @NonNullByDefault
65 public class InsteonDevice extends BaseDevice<InsteonAddress, InsteonDeviceHandler> {
66     private static final int BCAST_STATE_TIMEOUT = 2000; // in milliseconds
67     private static final int DEFAULT_HEARTBEAT_TIMEOUT = 1440; // in minutes
68     private static final int FAILED_MSG_COUNT_THRESHOLD = 5;
69
70     private InsteonEngine engine = InsteonEngine.UNKNOWN;
71     private LinkDB linkDB;
72     private Map<String, DefaultLink> defaultLinks = new LinkedHashMap<>();
73     private List<Msg> storedMessages = new LinkedList<>();
74     private Queue<DeviceRequest> deferredQueue = new PriorityQueue<>();
75     private Map<Msg, DeviceRequest> deferredQueueHash = new HashMap<>();
76     private Map<Byte, Long> lastBroadcastReceived = new HashMap<>();
77     private Map<Integer, GroupMessageStateMachine> groupState = new HashMap<>();
78     private volatile int failedMsgCount = 0;
79     private volatile long lastMsgReceived = 0L;
80
81     public InsteonDevice() {
82         super(InsteonAddress.UNKNOWN);
83         this.linkDB = new LinkDB(this);
84     }
85
86     public InsteonEngine getInsteonEngine() {
87         return engine;
88     }
89
90     public LinkDB getLinkDB() {
91         return linkDB;
92     }
93
94     public @Nullable DefaultLink getDefaultLink(String name) {
95         synchronized (defaultLinks) {
96             return defaultLinks.get(name);
97         }
98     }
99
100     public List<DefaultLink> getDefaultLinks() {
101         synchronized (defaultLinks) {
102             return defaultLinks.values().stream().toList();
103         }
104     }
105
106     public List<Msg> getStoredMessages() {
107         synchronized (storedMessages) {
108             return storedMessages;
109         }
110     }
111
112     public List<DeviceFeature> getControllerFeatures() {
113         return getFeatures().stream().filter(DeviceFeature::isControllerFeature).toList();
114     }
115
116     public List<DeviceFeature> getResponderFeatures() {
117         return getFeatures().stream().filter(DeviceFeature::isResponderFeature).toList();
118     }
119
120     public List<DeviceFeature> getControllerOrResponderFeatures() {
121         return getFeatures().stream().filter(DeviceFeature::isControllerOrResponderFeature).toList();
122     }
123
124     public List<DeviceFeature> getFeatures(String type) {
125         return getFeatures().stream().filter(feature -> feature.getType().equals(type)).toList();
126     }
127
128     public @Nullable DeviceFeature getFeature(String type, int group) {
129         return getFeatures().stream().filter(feature -> feature.getType().equals(type) && feature.getGroup() == group)
130                 .findFirst().orElse(null);
131     }
132
133     public double getLastMsgValueAsDouble(String type, int group, double defaultValue) {
134         return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getLastMsgValue).map(Double::doubleValue)
135                 .orElse(defaultValue);
136     }
137
138     public int getLastMsgValueAsInteger(String type, int group, int defaultValue) {
139         return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getLastMsgValue).map(Double::intValue)
140                 .orElse(defaultValue);
141     }
142
143     public @Nullable State getFeatureState(String type, int group) {
144         return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getState).orElse(null);
145     }
146
147     public boolean isResponding() {
148         return failedMsgCount < FAILED_MSG_COUNT_THRESHOLD;
149     }
150
151     public boolean isBatteryPowered() {
152         return getFlag("batteryPowered", false);
153     }
154
155     public boolean isDeviceSyncEnabled() {
156         return getFlag("deviceSyncEnabled", false);
157     }
158
159     public boolean hasModemDBEntry() {
160         return getFlag("modemDBEntry", false);
161     }
162
163     public void setInsteonEngine(InsteonEngine engine) {
164         logger.trace("setting insteon engine for {} to {}", address, engine);
165         this.engine = engine;
166         // notify properties changed
167         propertiesChanged(false);
168     }
169
170     public void setHasModemDBEntry(boolean value) {
171         setFlag("modemDBEntry", value);
172         // notify status changed
173         statusChanged();
174     }
175
176     public void setIsDeviceSyncEnabled(boolean value) {
177         setFlag("deviceSyncEnabled", value);
178     }
179
180     /**
181      * Returns this device heartbeat timeout
182      *
183      * @return heartbeat timeout in minutes
184      */
185     public int getHeartbeatTimeout() {
186         DeviceFeature feature = getFeature(FEATURE_HEARTBEAT_INTERVAL);
187         if (feature != null) {
188             if (feature.getState() instanceof QuantityType<?> interval) {
189                 return Objects.requireNonNullElse(interval.toInvertibleUnit(Units.MINUTE), interval).intValue();
190             }
191             return 0;
192         }
193         return DEFAULT_HEARTBEAT_TIMEOUT;
194     }
195
196     /**
197      * Returns if this device has heartbeat
198      *
199      * @return true if has heartbeat feature and heartbeat on/off feature state on when available, otherise false
200      */
201     public boolean hasHeartbeat() {
202         return hasFeature(FEATURE_HEARTBEAT) && (!hasFeature(FEATURE_HEARTBEAT_ON_OFF)
203                 || OnOffType.ON.equals(getFeatureState(FEATURE_HEARTBEAT_ON_OFF)));
204     }
205
206     /**
207      * Returns if this device is awake
208      *
209      * @return true if device not battery powered or within awake time
210      */
211     public boolean isAwake() {
212         if (isBatteryPowered()) {
213             // define awake time based on the stay awake feature state (ON => 4 minutes; OFF => 3 seconds)
214             State state = getFeatureState(FEATURE_STAY_AWAKE);
215             int awakeTime = OnOffType.ON.equals(state) ? 240000 : 3000; // in msec
216             return System.currentTimeMillis() - lastMsgReceived <= awakeTime;
217         }
218         return true;
219     }
220
221     /**
222      * Returns if a broadcast message is duplicate
223      *
224      * @param cmd1 the cmd1 from the broadcast message received
225      * @param timestamp the timestamp from the broadcast message received
226      * @return true if the broadcast message is duplicate
227      */
228     public boolean isDuplicateBroadcastMsg(byte cmd1, long timestamp) {
229         synchronized (lastBroadcastReceived) {
230             long timelapse = timestamp - lastBroadcastReceived.getOrDefault(cmd1, timestamp);
231             if (timelapse > 0 && timelapse < BCAST_STATE_TIMEOUT) {
232                 return true;
233             } else {
234                 lastBroadcastReceived.put(cmd1, timestamp);
235                 return false;
236             }
237         }
238     }
239
240     /**
241      * Returns if a group message is duplicate
242      *
243      * @param cmd1 cmd1 from the group message received
244      * @param timestamp the timestamp from the broadcast message received
245      * @param group the broadcast group
246      * @param type the group message type that was received
247      * @return true if the group message is duplicate
248      */
249     public boolean isDuplicateGroupMsg(byte cmd1, long timestamp, int group, GroupMessageType type) {
250         synchronized (groupState) {
251             GroupMessageStateMachine stateMachine = groupState.get(group);
252             if (stateMachine == null) {
253                 stateMachine = new GroupMessageStateMachine();
254                 groupState.put(group, stateMachine);
255                 logger.trace("{} created group {} state", address, group);
256             }
257             if (stateMachine.getLastCommand() == cmd1 && stateMachine.getLastTimestamp() == timestamp) {
258                 logger.trace("{} using previous group {} state for {}", address, group, type);
259                 return stateMachine.isDuplicate();
260             } else {
261                 logger.trace("{} updating group {} state to {}", address, group, type);
262                 return stateMachine.update(address, group, cmd1, timestamp, type);
263             }
264         }
265     }
266
267     /**
268      * Returns if device is pollable
269      *
270      * @return true if parent pollable and not battery powered
271      */
272     @Override
273     public boolean isPollable() {
274         return super.isPollable() && !isBatteryPowered();
275     }
276
277     /**
278      * Polls this device
279      *
280      * @param delay scheduling delay (in milliseconds)
281      */
282     @Override
283     public void doPoll(long delay) {
284         // process deferred queue
285         processDeferredQueue(delay);
286         // poll insteon engine if unknown or its feature never queried
287         DeviceFeature engineFeature = getFeature(FEATURE_INSTEON_ENGINE);
288         if (engineFeature != null
289                 && (engine == InsteonEngine.UNKNOWN || engineFeature.getQueryStatus() == QueryStatus.NEVER_QUERIED)) {
290             engineFeature.doPoll(delay);
291             return; // insteon engine needs to be known before enqueueing more messages
292         }
293         // load this device link db if not complete or should be reloaded
294         if (!linkDB.isComplete() || linkDB.shouldReload()) {
295             linkDB.load(delay);
296             return; // link db needs to be complete before enqueueing more messages
297         }
298         // update this device link db if needed
299         if (linkDB.shouldUpdate()) {
300             linkDB.update(delay);
301         }
302
303         super.doPoll(delay);
304     }
305
306     /**
307      * Schedules polling for this device
308      *
309      * @param delay scheduling delay (in milliseconds)
310      * @param featureFilter feature filter to apply
311      * @return delay spacing
312      */
313     @Override
314     protected long schedulePoll(long delay, Predicate<DeviceFeature> featureFilter) {
315         long spacing = super.schedulePoll(delay, featureFilter);
316         // ping non-battery powered device if no other feature scheduled poll
317         if (!isBatteryPowered() && spacing == 0) {
318             Msg msg = pollFeature(FEATURE_PING, delay);
319             if (msg != null) {
320                 spacing += msg.getQuietTime();
321             }
322         }
323         return spacing;
324     }
325
326     /**
327      * Polls all responder features for this device
328      *
329      * @param delay scheduling delay (in milliseconds)
330      */
331     public void pollResponders(long delay) {
332         schedulePoll(delay, DeviceFeature::hasResponderFeatures);
333     }
334
335     /**
336      * Polls responder features for a controller address and group
337      *
338      * @param address the controller address
339      * @param group the controller group
340      * @param delay scheduling delay (in milliseconds)
341      */
342     public void pollResponders(InsteonAddress address, int group, long delay) {
343         // poll all responder features if link db not complete
344         if (!linkDB.isComplete()) {
345             getResponderFeatures().forEach(feature -> feature.triggerPoll(delay));
346             return;
347         }
348         // poll responder features matching record component id (data 3)
349         linkDB.getResponderRecords(address, group)
350                 .forEach(record -> getResponderFeatures().stream()
351                         .filter(feature -> feature.getComponentId() == record.getComponentId()).findFirst()
352                         .ifPresent(feature -> feature.triggerPoll(delay)));
353     }
354
355     /**
356      * Polls related devices to a controller group
357      *
358      * @param group the controller group
359      * @param delay scheduling delay (in milliseconds)
360      */
361     public void pollRelatedDevices(int group, long delay) {
362         InsteonModem modem = getModem();
363         if (modem != null) {
364             linkDB.getRelatedDevices(group).stream().map(modem::getInsteonDevice).filter(Objects::nonNull)
365                     .map(Objects::requireNonNull).forEach(device -> {
366                         logger.debug("polling related device {} to controller {} group {}", device.getAddress(),
367                                 address, group);
368                         device.pollResponders(address, group, delay);
369                     });
370         }
371     }
372
373     /**
374      * Adjusts responder features for a controller address and group
375      *
376      * @param address the controller address
377      * @param group the controller group
378      * @param onLevel the controller channel config
379      * @param cmd the cmd to adjust to
380      */
381     public void adjustResponders(InsteonAddress address, int group, InsteonChannelConfiguration config, Command cmd) {
382         // handle command for responder feature with group matching record component id (data 3)
383         linkDB.getResponderRecords(address, group)
384                 .forEach(record -> getResponderFeatures().stream()
385                         .filter(feature -> feature.getComponentId() == record.getComponentId()).findFirst()
386                         .ifPresent(feature -> {
387                             InsteonChannelConfiguration adjustConfig = InsteonChannelConfiguration.copyOf(config,
388                                     record.getOnLevel(), record.getRampRate());
389                             feature.handleCommand(adjustConfig, cmd);
390                         }));
391     }
392
393     /**
394      * Adjusts related devices to a controller group
395      *
396      * @param group the controller group
397      * @param config the controller channel config
398      * @param cmd the cmd to adjust to
399      */
400     public void adjustRelatedDevices(int group, InsteonChannelConfiguration config, Command cmd) {
401         InsteonModem modem = getModem();
402         if (modem != null) {
403             linkDB.getRelatedDevices(group).stream().map(modem::getInsteonDevice).filter(Objects::nonNull)
404                     .map(Objects::requireNonNull).forEach(device -> {
405                         logger.debug("adjusting related device {} to controller {} group {}", device.getAddress(),
406                                 address, group);
407                         device.adjustResponders(address, group, config, cmd);
408                     });
409         }
410     }
411
412     /**
413      * Returns broadcast group for a controller feature
414      *
415      * @param feature the device feature
416      * @return the brodcast group if found, otherwise -1
417      */
418     public int getBroadcastGroup(DeviceFeature feature) {
419         InsteonModem modem = getModem();
420         if (modem != null) {
421             List<InsteonAddress> relatedDevices = linkDB.getRelatedDevices(feature.getGroup());
422             // return broadcast group with matching link and modem db related devices
423             return linkDB.getBroadcastGroups(feature.getComponentId()).stream()
424                     .filter(group -> modem.getDB().getRelatedDevices(group).stream()
425                             .allMatch(address -> getAddress().equals(address) || relatedDevices.contains(address)))
426                     .findFirst().orElse(-1);
427         }
428         return -1;
429     }
430
431     /**
432      * Replays a list of messages
433      */
434     public void replayMessages(List<Msg> messages) {
435         for (Msg msg : messages) {
436             logger.trace("replaying msg: {}", msg);
437             msg.setIsReplayed(true);
438             handleMessage(msg);
439         }
440     }
441
442     /**
443      * Handles incoming message for this device by forwarding
444      * it to all features that this device supports
445      *
446      * @param msg the incoming message
447      */
448     @Override
449     public void handleMessage(Msg msg) {
450         // update last msg received if not failure report and more recent msg timestamp
451         if (!msg.isFailureReport() && msg.getTimestamp() > lastMsgReceived) {
452             lastMsgReceived = msg.getTimestamp();
453         }
454         // store message if no feature defined
455         if (!hasFeatures()) {
456             logger.debug("storing message for unknown device {}", address);
457
458             synchronized (storedMessages) {
459                 storedMessages.add(msg);
460             }
461             return;
462         }
463         // store current responding state
464         boolean isPrevResponding = isResponding();
465         // handle message depending if failure report or not
466         if (msg.isFailureReport()) {
467             getFeatures().stream().filter(feature -> feature.isMyDirectAckOrNack(msg)).findFirst()
468                     .ifPresent(feature -> {
469                         logger.debug("got a failure report reply of direct for {}", feature.getName());
470                         // increase failed message counter
471                         failedMsgCount++;
472                         // mark feature queried as processed and never queried
473                         setFeatureQueried(null);
474                         feature.setQueryMessage(null);
475                         feature.setQueryStatus(QueryStatus.NEVER_QUERIED);
476                         // poll feature again if device is responding
477                         if (isResponding()) {
478                             feature.doPoll(0L);
479                         }
480                     });
481         } else {
482             // update non-status features
483             getFeatures().stream().filter(feature -> !feature.isStatusFeature() && feature.handleMessage(msg))
484                     .findFirst().ifPresent(feature -> {
485                         logger.trace("handled reply of direct for {}", feature.getName());
486                         // reset failed message counter
487                         failedMsgCount = 0;
488                         // mark feature queried as processed and answered
489                         setFeatureQueried(null);
490                         feature.setQueryMessage(null);
491                         feature.setQueryStatus(QueryStatus.QUERY_ANSWERED);
492                     });
493             // update all status features (e.g. device last update time)
494             getFeatures().stream().filter(DeviceFeature::isStatusFeature)
495                     .forEach(feature -> feature.handleMessage(msg));
496         }
497         // notify if responding state changed
498         if (isPrevResponding != isResponding()) {
499             statusChanged();
500         }
501     }
502
503     /**
504      * Sends a message after a delay to this device
505      *
506      * @param msg the message to be sent
507      * @param feature device feature associated to the message
508      * @param delay time (in milliseconds) to delay before sending message
509      */
510     @Override
511     public void sendMessage(Msg msg, DeviceFeature feature, long delay) {
512         if (isAwake()) {
513             addDeviceRequest(msg, feature, delay);
514         } else {
515             addDeferredRequest(msg, feature);
516         }
517         // mark feature query status as scheduled for non-broadcast request message
518         if (!msg.isAllLinkBroadcast()) {
519             feature.setQueryStatus(QueryStatus.QUERY_SCHEDULED);
520         }
521     }
522
523     /**
524      * Processes deferred queue
525      *
526      * @param delay time (in milliseconds) to delay before sending message
527      */
528     private void processDeferredQueue(long delay) {
529         synchronized (deferredQueue) {
530             while (!deferredQueue.isEmpty()) {
531                 DeviceRequest request = deferredQueue.poll();
532                 if (request != null) {
533                     Msg msg = request.getMessage();
534                     DeviceFeature feature = request.getFeature();
535                     deferredQueueHash.remove(msg);
536                     request.setExpirationTime(delay);
537                     logger.trace("enqueuing deferred request for {}", feature.getName());
538                     addDeviceRequest(msg, feature, delay);
539                 }
540             }
541         }
542     }
543
544     /**
545      * Adds deferred request
546      *
547      * @param request device request to add
548      */
549     private void addDeferredRequest(Msg msg, DeviceFeature feature) {
550         logger.trace("deferring request for sleeping device {}", address);
551
552         synchronized (deferredQueue) {
553             DeviceRequest request = new DeviceRequest(feature, msg, 0L);
554             DeviceRequest prevRequest = deferredQueueHash.get(msg);
555             if (prevRequest != null) {
556                 logger.trace("overwriting existing deferred request for {}: {}", feature.getName(), msg);
557                 deferredQueue.remove(prevRequest);
558                 deferredQueueHash.remove(msg);
559             }
560             deferredQueue.add(request);
561             deferredQueueHash.put(msg, request);
562         }
563     }
564
565     /**
566      * Clears request queue
567      */
568     @Override
569     protected void clearRequestQueue() {
570         super.clearRequestQueue();
571
572         synchronized (deferredQueue) {
573             deferredQueue.clear();
574             deferredQueueHash.clear();
575         }
576     }
577
578     /**
579      * Updates product data for this device
580      *
581      * @param newData the new product data to use
582      */
583     public void updateProductData(ProductData newData) {
584         ProductData productData = getProductData();
585         if (productData == null) {
586             setProductData(newData);
587             propertiesChanged(true);
588         } else {
589             logger.trace("updating product data for {} to {}", address, newData);
590             if (productData.update(newData)) {
591                 propertiesChanged(true);
592             } else {
593                 propertiesChanged(false);
594                 resetFeaturesQueryStatus();
595             }
596         }
597     }
598
599     /**
600      * Updates this device type
601      *
602      * @param newType the new device type to use
603      */
604
605     public void updateType(DeviceType newType) {
606         ProductData productData = getProductData();
607         DeviceType currentType = getType();
608         if (productData != null && !newType.equals(currentType)) {
609             logger.trace("updating device type from {} to {} for {}",
610                     currentType != null ? currentType.getName() : "undefined", newType.getName(), address);
611             productData.setDeviceType(newType);
612             propertiesChanged(true);
613         }
614     }
615
616     /**
617      * Updates the default links
618      */
619     public void updateDefaultLinks() {
620         InsteonModem modem = getModem();
621         ProductData productData = getProductData();
622         DeviceType deviceType = getType();
623         State linkFFGroup = getFeatureState(FEATURE_LINK_FF_GROUP);
624         State twoGroups = getFeatureState(FEATURE_TWO_GROUPS);
625         if (modem == null || productData == null || deviceType == null || linkFFGroup == UnDefType.NULL
626                 || twoGroups == UnDefType.NULL || InsteonAddress.UNKNOWN.equals(modem.getAddress())) {
627             return;
628         }
629         // clear default links
630         synchronized (defaultLinks) {
631             defaultLinks.clear();
632         }
633         // iterate over device type default links
634         deviceType.getDefaultLinks().forEach((name, link) -> {
635             // skip default link if 2Groups feature is off and its group is 2
636             if (OnOffType.OFF.equals(twoGroups) && link.getGroup() == 2) {
637                 return;
638             }
639             // create link db record based on FFGroup feature state
640             LinkDBRecord linkDBRecord = LinkDBRecord.create(0, modem.getAddress(),
641                     OnOffType.ON.equals(linkFFGroup) ? 0xFF : link.getGroup(), link.isController(), link.getData());
642             // create modem db record
643             ModemDBRecord modemDBRecord = ModemDBRecord.create(address, link.getGroup(), !link.isController(),
644                     !link.isController() ? productData.getRecordData() : new byte[3]);
645             // create default link commands
646             List<Msg> commands = link.getCommands().stream().map(command -> command.getMessage(this))
647                     .filter(Objects::nonNull).map(Objects::requireNonNull).toList();
648             // add default link
649             addDefaultLink(new DefaultLink(name, linkDBRecord, modemDBRecord, commands));
650         });
651     }
652
653     /**
654      * Adds a default link for this device
655      *
656      * @param link the default link to add
657      */
658     private void addDefaultLink(DefaultLink link) {
659         logger.trace("adding default link {} for {}", link.getName(), address);
660
661         synchronized (defaultLinks) {
662             defaultLinks.put(link.getName(), link);
663         }
664     }
665
666     /**
667      * Returns a map of missing device links for this device
668      *
669      * @return map of missing link db records based on default links
670      */
671     public Map<String, LinkDBChange> getMissingDeviceLinks() {
672         Map<String, LinkDBChange> links = new LinkedHashMap<>();
673         if (linkDB.isComplete() && hasModemDBEntry()) {
674             for (DefaultLink link : getDefaultLinks()) {
675                 LinkDBRecord record = link.getLinkDBRecord();
676                 if ((record.getComponentId() > 0 && !linkDB.hasComponentIdRecord(record.getComponentId(), true))
677                         || !linkDB.hasGroupRecord(record.getGroup(), true)) {
678                     links.put(link.getName(), LinkDBChange.forAdd(record));
679                 }
680             }
681         }
682         return links;
683     }
684
685     /**
686      * Returns a map of missing modem links for this device
687      *
688      * @return map of missing modem db records based on default links
689      */
690     public Map<String, ModemDBChange> getMissingModemLinks() {
691         Map<String, ModemDBChange> links = new LinkedHashMap<>();
692         InsteonModem modem = getModem();
693         if (modem != null && modem.getDB().isComplete() && hasModemDBEntry()) {
694             for (DefaultLink link : getDefaultLinks()) {
695                 ModemDBRecord record = link.getModemDBRecord();
696                 if (!modem.getDB().hasRecord(record.getAddress(), record.getGroup(), record.isController())) {
697                     links.put(link.getName(), ModemDBChange.forAdd(record));
698                 }
699             }
700         }
701         return links;
702     }
703
704     /**
705      * Returns a set of missing links for this device
706      *
707      * @return a set of missing link names
708      */
709     public Set<String> getMissingLinks() {
710         return Stream.of(getMissingDeviceLinks().keySet(), getMissingModemLinks().keySet()).flatMap(Set::stream)
711                 .collect(Collectors.toSet());
712     }
713
714     /**
715      * Logs missing links for this device
716      */
717     public void logMissingLinks() {
718         Set<String> links = getMissingLinks();
719         if (!links.isEmpty()) {
720             logger.warn(
721                     "device {} has missing default links {}, "
722                             + "run 'insteon device addMissingLinks' command via openhab console to fix.",
723                     address, links);
724         }
725     }
726
727     /**
728      * Adds missing links to link db for this device
729      */
730     public void addMissingDeviceLinks() {
731         if (getDefaultLinks().isEmpty()) {
732             return;
733         }
734         List<LinkDBChange> changes = getMissingDeviceLinks().values().stream().distinct().toList();
735         if (changes.isEmpty()) {
736             logger.debug("no missing default links from link db to add for {}", address);
737         } else {
738             logger.trace("adding missing default links to link db for {}", address);
739             linkDB.clearChanges();
740             changes.forEach(linkDB::addChange);
741             linkDB.update();
742         }
743
744         InsteonModem modem = getModem();
745         if (modem != null) {
746             getMissingDeviceLinks().keySet().stream().map(this::getDefaultLink).filter(Objects::nonNull)
747                     .map(Objects::requireNonNull).flatMap(link -> link.getCommands().stream()).forEach(msg -> {
748                         try {
749                             modem.writeMessage(msg);
750                         } catch (IOException e) {
751                             logger.warn("message write failed for msg: {}", msg, e);
752                         }
753                     });
754         }
755     }
756
757     /**
758      * Adds missing links to modem db for this device
759      */
760     public void addMissingModemLinks() {
761         InsteonModem modem = getModem();
762         if (modem == null || getDefaultLinks().isEmpty()) {
763             return;
764         }
765         List<ModemDBChange> changes = getMissingModemLinks().values().stream().distinct().toList();
766         if (changes.isEmpty()) {
767             logger.debug("no missing default links from modem db to add for {}", address);
768         } else {
769             logger.trace("adding missing default links to modem db for {}", address);
770             ModemDB modemDB = modem.getDB();
771             modemDB.clearChanges();
772             changes.forEach(modemDB::addChange);
773             modemDB.update();
774         }
775     }
776
777     /**
778      * Sets a keypad button radio group
779      *
780      * @param buttons list of button groups to set
781      */
782     public void setButtonRadioGroup(List<Integer> buttons) {
783         // set each radio button to turn off each others when turned on if should set
784         for (int buttonGroup : buttons) {
785             DeviceFeature onMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_ON_MASK, buttonGroup);
786             DeviceFeature offMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_OFF_MASK, buttonGroup);
787
788             if (onMaskFeature != null && offMaskFeature != null) {
789                 int onMask = onMaskFeature.getLastMsgValueAsInteger(0);
790                 int offMask = offMaskFeature.getLastMsgValueAsInteger(0);
791
792                 for (int group : buttons) {
793                     int bit = group - 1;
794                     onMask = BinaryUtils.clearBit(onMask, bit);
795                     offMask = BinaryUtils.updateBit(offMask, bit, buttonGroup != group);
796                 }
797                 onMaskFeature.handleCommand(new DecimalType(onMask));
798                 offMaskFeature.handleCommand(new DecimalType(offMask));
799             }
800         }
801     }
802
803     /**
804      * Clears a keypad button radion group
805      *
806      * @param buttons list of button groups to clear
807      */
808     public void clearButtonRadioGroup(List<Integer> buttons) {
809         List<Integer> allButtons = getFeatures(FEATURE_TYPE_KEYPAD_BUTTON).stream().map(DeviceFeature::getGroup)
810                 .toList();
811         // clear each radio button and decouple from others
812         for (int buttonGroup : allButtons) {
813             DeviceFeature onMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_ON_MASK, buttonGroup);
814             DeviceFeature offMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_OFF_MASK, buttonGroup);
815
816             if (onMaskFeature != null && offMaskFeature != null) {
817                 int onMask = onMaskFeature.getLastMsgValueAsInteger(0);
818                 int offMask = offMaskFeature.getLastMsgValueAsInteger(0);
819
820                 for (int group : buttons.contains(buttonGroup) ? allButtons : buttons) {
821                     int bit = group - 1;
822                     onMask = BinaryUtils.clearBit(onMask, bit);
823                     offMask = BinaryUtils.clearBit(offMask, bit);
824                 }
825                 onMaskFeature.handleCommand(new DecimalType(onMask));
826                 offMaskFeature.handleCommand(new DecimalType(offMask));
827             }
828         }
829     }
830
831     /**
832      * Sets keypad button toggle mode
833      *
834      * @param buttons list of button groups to use
835      * @param mode toggle mode to set
836      */
837     public void setButtonToggleMode(List<Integer> buttons, KeypadButtonToggleMode mode) {
838         // use the first button group if available to set toggle mode
839         int buttonGroup = !buttons.isEmpty() ? buttons.get(0) : -1;
840         DeviceFeature toggleModeFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_TOGGLE_MODE, buttonGroup);
841
842         if (toggleModeFeature != null) {
843             int nonToggleMask = toggleModeFeature.getLastMsgValueAsInteger(0) >> 8;
844             int alwaysOnOffMask = toggleModeFeature.getLastMsgValueAsInteger(0) & 0xFF;
845
846             for (int group : buttons) {
847                 int bit = group - 1;
848                 nonToggleMask = BinaryUtils.updateBit(nonToggleMask, bit, mode != KeypadButtonToggleMode.TOGGLE);
849                 alwaysOnOffMask = BinaryUtils.updateBit(alwaysOnOffMask, bit, mode == KeypadButtonToggleMode.ALWAYS_ON);
850             }
851             toggleModeFeature.handleCommand(new DecimalType(nonToggleMask << 8 | alwaysOnOffMask));
852         }
853     }
854
855     /**
856      * Initializes this device
857      */
858     public void initialize() {
859         InsteonModem modem = getModem();
860         if (modem == null || !modem.getDB().isComplete()) {
861             return;
862         }
863
864         ModemDBEntry dbe = modem.getDB().getEntry(address);
865         if (dbe == null) {
866             logger.warn("device {} not found in the modem database. Did you forget to link?", address);
867             setHasModemDBEntry(false);
868             stopPolling();
869             return;
870         }
871
872         ProductData productData = dbe.getProductData();
873         if (productData != null) {
874             updateProductData(productData);
875         }
876
877         if (!hasModemDBEntry()) {
878             logger.debug("device {} found in the modem database.", address);
879             setHasModemDBEntry(true);
880         }
881
882         if (isPollable()) {
883             startPolling();
884         }
885
886         updateDefaultLinks();
887     }
888
889     /**
890      * Refreshes this device
891      */
892     @Override
893     public void refresh() {
894         initialize();
895
896         super.refresh();
897     }
898
899     /**
900      * Resets heartbeat monitor
901      */
902     public void resetHeartbeatMonitor() {
903         InsteonDeviceHandler handler = getHandler();
904         if (handler != null) {
905             handler.resetHeartbeatMonitor();
906         }
907     }
908
909     /**
910      * Notifies that the link db has been updated for this device
911      */
912     public void linkDBUpdated() {
913         logger.trace("link db for {} has been updated", address);
914
915         if (linkDB.isComplete()) {
916             if (isBatteryPowered() && isAwake() || getStatus() == DeviceStatus.POLLING) {
917                 // poll database delta feature
918                 pollFeature(FEATURE_DATABASE_DELTA, 0L);
919                 // poll remaining features for this device
920                 doPoll(0L);
921             }
922             // log missing links
923             logMissingLinks();
924         }
925         // notify device handler if defined
926         InsteonDeviceHandler handler = getHandler();
927         if (handler != null) {
928             handler.deviceLinkDBUpdated(this);
929         }
930     }
931
932     /**
933      * Notifies that the properties have changed for this device
934      *
935      * @param reset if the device should be reset
936      */
937     public void propertiesChanged(boolean reset) {
938         logger.trace("properties for {} has changed", address);
939
940         InsteonDeviceHandler handler = getHandler();
941         if (handler != null) {
942             if (reset) {
943                 handler.reset(this);
944             } else {
945                 handler.updateProperties(this);
946             }
947         }
948     }
949
950     /**
951      * Notifies that the status has changed for this device
952      */
953     public void statusChanged() {
954         logger.trace("status for {} has changed", address);
955
956         InsteonDeviceHandler handler = getHandler();
957         if (handler != null) {
958             handler.updateStatus();
959         }
960     }
961
962     /**
963      * Factory method for creating a InsteonDevice from a device address, modem and cache
964      *
965      * @param address the device address
966      * @param modem the device modem
967      * @param productData the device product data
968      * @return the newly created InsteonDevice
969      */
970     public static InsteonDevice makeDevice(InsteonAddress address, @Nullable InsteonModem modem,
971             @Nullable ProductData productData) {
972         InsteonDevice device = new InsteonDevice();
973         device.setAddress(address);
974         device.setModem(modem);
975
976         if (productData != null) {
977             DeviceType deviceType = productData.getDeviceType();
978             if (deviceType != null) {
979                 device.instantiateFeatures(deviceType);
980                 device.setFlags(deviceType.getFlags());
981             }
982             int location = productData.getFirstRecordLocation();
983             if (location != LinkDBRecord.LOCATION_ZERO) {
984                 device.getLinkDB().setFirstRecordLocation(location);
985             }
986             device.setProductData(productData);
987         }
988
989         return device;
990     }
991 }