]> git.basschouten.com Git - openhab-addons.git/blob
579162c3460845780ffee547cd30800b303fdaac
[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.plugwise.internal;
14
15 import static java.util.stream.Collectors.*;
16 import static org.openhab.binding.plugwise.internal.PlugwiseBindingConstants.*;
17
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.util.Collections;
21 import java.util.HashMap;
22 import java.util.Iterator;
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.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.plugwise.internal.handler.PlugwiseStickHandler;
33 import org.openhab.binding.plugwise.internal.listener.PlugwiseMessageListener;
34 import org.openhab.binding.plugwise.internal.listener.PlugwiseStickStatusListener;
35 import org.openhab.binding.plugwise.internal.protocol.AnnounceAwakeRequestMessage;
36 import org.openhab.binding.plugwise.internal.protocol.InformationRequestMessage;
37 import org.openhab.binding.plugwise.internal.protocol.InformationResponseMessage;
38 import org.openhab.binding.plugwise.internal.protocol.Message;
39 import org.openhab.binding.plugwise.internal.protocol.RoleCallRequestMessage;
40 import org.openhab.binding.plugwise.internal.protocol.RoleCallResponseMessage;
41 import org.openhab.binding.plugwise.internal.protocol.field.DeviceType;
42 import org.openhab.binding.plugwise.internal.protocol.field.MACAddress;
43 import org.openhab.core.config.discovery.AbstractDiscoveryService;
44 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingTypeUID;
48 import org.openhab.core.thing.ThingUID;
49 import org.openhab.core.thing.binding.ThingHandler;
50 import org.openhab.core.thing.binding.ThingHandlerService;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 /**
55  * Discovers Plugwise devices by periodically reading the Circle+ node/MAC table with {@link RoleCallRequestMessage}s.
56  * Sleeping end devices are discovered when they announce being awake with an {@link AnnounceAwakeRequestMessage}. To
57  * reduce network traffic {@link InformationRequestMessage}s are only sent to undiscovered devices.
58  *
59  * @author Wouter Born, Karel Goderis - Initial contribution
60  */
61 @NonNullByDefault
62 public class PlugwiseThingDiscoveryService extends AbstractDiscoveryService
63         implements PlugwiseMessageListener, PlugwiseStickStatusListener, ThingHandlerService {
64
65     private static class CurrentRoleCall {
66         private boolean isRoleCalling;
67         private int currentNodeID;
68         private int attempts;
69         private Instant lastRequest = Instant.MIN;
70     }
71
72     private static class DiscoveredNode {
73         private final MACAddress macAddress;
74         private final Map<String, String> properties = new HashMap<>();
75         private DeviceType deviceType = DeviceType.UNKNOWN;
76         private int attempts;
77         private Instant lastRequest = Instant.MIN;
78
79         public DiscoveredNode(MACAddress macAddress) {
80             this.macAddress = macAddress;
81         }
82
83         public boolean isDataComplete() {
84             return deviceType != DeviceType.UNKNOWN && !properties.isEmpty();
85         }
86     }
87
88     private static final Set<ThingTypeUID> DISCOVERED_THING_TYPES_UIDS = SUPPORTED_THING_TYPES_UIDS.stream()
89             .filter(thingTypeUID -> !thingTypeUID.equals(THING_TYPE_STICK))
90             .collect(collectingAndThen(toSet(), Collections::unmodifiableSet));
91
92     private static final int MIN_NODE_ID = 0;
93     private static final int MAX_NODE_ID = 63;
94
95     private static final int MESSAGE_RETRY_ATTEMPTS = 5;
96
97     private static final Duration DISCOVERY_INTERVAL = Duration.ofMinutes(3);
98     private static final Duration MESSAGE_TIMEOUT = Duration.ofSeconds(15);
99     private static final Duration WATCH_INTERVAL = Duration.ofSeconds(1);
100
101     private final Logger logger = LoggerFactory.getLogger(PlugwiseThingDiscoveryService.class);
102
103     private @NonNullByDefault({}) PlugwiseStickHandler stickHandler;
104
105     private @Nullable ScheduledFuture<?> discoveryJob;
106     private @Nullable ScheduledFuture<?> watchJob;
107     private CurrentRoleCall currentRoleCall = new CurrentRoleCall();
108
109     private final Map<MACAddress, DiscoveredNode> discoveredNodes = new ConcurrentHashMap<>();
110
111     public PlugwiseThingDiscoveryService() throws IllegalArgumentException {
112         super(DISCOVERED_THING_TYPES_UIDS, 1, true);
113     }
114
115     @Override
116     public synchronized void abortScan() {
117         logger.debug("Aborting nodes discovery");
118         super.abortScan();
119         currentRoleCall.isRoleCalling = false;
120         stopDiscoveryWatchJob();
121     }
122
123     @Override
124     public void activate() {
125         super.activate(new HashMap<>());
126     }
127
128     private void createDiscoveryResult(DiscoveredNode node) {
129         String mac = node.macAddress.toString();
130         ThingUID bridgeUID = stickHandler.getThing().getUID();
131         ThingTypeUID thingTypeUID = PlugwiseUtils.getThingTypeUID(node.deviceType);
132         if (thingTypeUID != null) {
133             ThingUID thingUID = new ThingUID(thingTypeUID, bridgeUID, mac);
134
135             thingDiscovered(DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUID)
136                     .withLabel("Plugwise " + node.deviceType)
137                     .withProperty(PlugwiseBindingConstants.CONFIG_PROPERTY_MAC_ADDRESS, mac)
138                     .withProperties(new HashMap<>(node.properties))
139                     .withRepresentationProperty(PlugwiseBindingConstants.PROPERTY_MAC_ADDRESS).build());
140         }
141     }
142
143     @Override
144     public void deactivate() {
145         super.deactivate();
146         stickHandler.removeMessageListener(this);
147         stickHandler.removeStickStatusListener(this);
148     }
149
150     private void discoverNewNodeDetails(MACAddress macAddress) {
151         if (!isAlreadyDiscovered(macAddress)) {
152             logger.debug("Discovered new node ({})", macAddress);
153             DiscoveredNode node = new DiscoveredNode(macAddress);
154             discoveredNodes.put(macAddress, node);
155             updateInformation(macAddress);
156             node.lastRequest = Instant.now();
157         } else {
158             logger.debug("Already discovered node ({})", macAddress);
159         }
160     }
161
162     protected void discoverNodes() {
163         MACAddress circlePlusMAC = getCirclePlusMAC();
164         if (getStickStatus() != ThingStatus.ONLINE) {
165             logger.debug("Discovery with role call not possible (Stick status is {})", getStickStatus());
166         } else if (circlePlusMAC == null) {
167             logger.debug("Discovery with role call not possible (Circle+ MAC address is null)");
168         } else if (currentRoleCall.isRoleCalling) {
169             logger.debug("Discovery with role call not possible (already role calling)");
170         } else {
171             stickHandler.addMessageListener(this);
172             discoveredNodes.clear();
173             currentRoleCall.isRoleCalling = true;
174             currentRoleCall.currentNodeID = Integer.MIN_VALUE;
175
176             discoverNewNodeDetails(circlePlusMAC);
177
178             logger.debug("Discovering nodes with role call on Circle+ ({})", circlePlusMAC);
179             roleCall(MIN_NODE_ID);
180             startDiscoveryWatchJob();
181         }
182     }
183
184     private @Nullable MACAddress getCirclePlusMAC() {
185         return stickHandler.getCirclePlusMAC();
186     }
187
188     private ThingStatus getStickStatus() {
189         return stickHandler.getThing().getStatus();
190     }
191
192     @Override
193     public @Nullable ThingHandler getThingHandler() {
194         return stickHandler;
195     }
196
197     private void handleAnnounceAwakeRequest(AnnounceAwakeRequestMessage message) {
198         discoverNewNodeDetails(message.getMACAddress());
199     }
200
201     private void handleInformationResponse(InformationResponseMessage message) {
202         MACAddress mac = message.getMACAddress();
203         DiscoveredNode node = discoveredNodes.get(mac);
204         if (node != null) {
205             node.deviceType = message.getDeviceType();
206             PlugwiseUtils.updateProperties(node.properties, message);
207             if (node.isDataComplete()) {
208                 createDiscoveryResult(node);
209                 discoveredNodes.remove(mac);
210                 logger.debug("Finished discovery of {} ({})", node.deviceType, mac);
211             }
212         } else {
213             logger.debug("Received information response for already discovered node ({})", mac);
214         }
215     }
216
217     @Override
218     public void handleResponseMessage(Message message) {
219         switch (message.getType()) {
220             case ANNOUNCE_AWAKE_REQUEST:
221                 handleAnnounceAwakeRequest((AnnounceAwakeRequestMessage) message);
222                 break;
223             case DEVICE_INFORMATION_RESPONSE:
224                 handleInformationResponse((InformationResponseMessage) message);
225                 break;
226             case DEVICE_ROLE_CALL_RESPONSE:
227                 handleRoleCallResponse((RoleCallResponseMessage) message);
228                 break;
229             default:
230                 logger.trace("Received unhandled {} message from {}", message.getType(), message.getMACAddress());
231                 break;
232         }
233     }
234
235     private void handleRoleCallResponse(RoleCallResponseMessage message) {
236         logger.debug("Node with ID {} has MAC address: {}", message.getNodeID(), message.getNodeMAC());
237
238         if (message.getNodeID() <= MAX_NODE_ID && (message.getNodeMAC() != null)) {
239             discoverNewNodeDetails(message.getNodeMAC());
240             // Check if there is any other on the network
241             int nextNodeID = message.getNodeID() + 1;
242             if (nextNodeID <= MAX_NODE_ID) {
243                 roleCall(nextNodeID);
244             } else {
245                 currentRoleCall.isRoleCalling = false;
246             }
247         } else {
248             currentRoleCall.isRoleCalling = false;
249         }
250
251         if (!currentRoleCall.isRoleCalling) {
252             logger.debug("Finished discovering devices with role call on Circle+ ({})", getCirclePlusMAC());
253         }
254     }
255
256     private boolean isAlreadyDiscovered(MACAddress macAddress) {
257         Thing thing = stickHandler.getThingByMAC(macAddress);
258         if (thing != null) {
259             logger.debug("Node ({}) has existing thing: {}", macAddress, thing.getUID());
260         }
261         return thing != null;
262     }
263
264     /**
265      * Role calling is basically asking the Circle+ to return all the devices known to it. Up to 64 devices
266      * are supported in a Plugwise network, and role calling is done by sequentially sending
267      * {@link RoleCallRequestMessage} for all possible IDs in the network (0 <= ID <= 63)
268      *
269      * @param nodeID of the device to role call
270      */
271     private void roleCall(int nodeID) {
272         if (MIN_NODE_ID <= nodeID && nodeID <= MAX_NODE_ID) {
273             sendMessage(new RoleCallRequestMessage(getCirclePlusMAC(), nodeID));
274             if (nodeID != currentRoleCall.currentNodeID) {
275                 currentRoleCall.attempts = 0;
276             } else {
277                 currentRoleCall.attempts++;
278             }
279             currentRoleCall.currentNodeID = nodeID;
280             currentRoleCall.lastRequest = Instant.now();
281         } else {
282             logger.warn("Invalid node ID for role call: {}", nodeID);
283         }
284     }
285
286     private void sendMessage(Message message) {
287         stickHandler.sendMessage(message, PlugwiseMessagePriority.UPDATE_AND_DISCOVERY);
288     }
289
290     @Override
291     public void setThingHandler(ThingHandler handler) {
292         if (handler instanceof PlugwiseStickHandler plugwiseStickHandler) {
293             stickHandler = plugwiseStickHandler;
294             stickHandler.addStickStatusListener(this);
295         }
296     }
297
298     @Override
299     protected void startBackgroundDiscovery() {
300         logger.debug("Starting Plugwise device background discovery");
301
302         Runnable discoveryRunnable = () -> {
303             logger.debug("Discover nodes (background discovery)");
304             discoverNodes();
305         };
306
307         ScheduledFuture<?> localDiscoveryJob = discoveryJob;
308         if (localDiscoveryJob == null || localDiscoveryJob.isCancelled()) {
309             discoveryJob = scheduler.scheduleWithFixedDelay(discoveryRunnable, 0, DISCOVERY_INTERVAL.toMillis(),
310                     TimeUnit.MILLISECONDS);
311         }
312     }
313
314     private void startDiscoveryWatchJob() {
315         logger.debug("Starting Plugwise discovery watch job");
316
317         Runnable watchRunnable = () -> {
318             if (currentRoleCall.isRoleCalling) {
319                 Duration durationSinceLastRequest = Duration.between(currentRoleCall.lastRequest, Instant.now());
320                 if (durationSinceLastRequest.compareTo(MESSAGE_TIMEOUT) > 0
321                         && currentRoleCall.attempts < MESSAGE_RETRY_ATTEMPTS) {
322                     logger.debug("Resending timed out role call message for node with ID {} on Circle+ ({})",
323                             currentRoleCall.currentNodeID, getCirclePlusMAC());
324                     roleCall(currentRoleCall.currentNodeID);
325                 } else if (currentRoleCall.attempts >= MESSAGE_RETRY_ATTEMPTS) {
326                     logger.debug("Giving up on role call for node with ID {} on Circle+ ({})",
327                             currentRoleCall.currentNodeID, getCirclePlusMAC());
328                     currentRoleCall.isRoleCalling = false;
329                 }
330             }
331
332             Iterator<Entry<MACAddress, DiscoveredNode>> it = discoveredNodes.entrySet().iterator();
333             while (it.hasNext()) {
334                 Entry<MACAddress, DiscoveredNode> entry = it.next();
335                 DiscoveredNode node = entry.getValue();
336                 Duration durationSinceLastRequest = Duration.between(node.lastRequest, Instant.now());
337                 if (durationSinceLastRequest.compareTo(MESSAGE_TIMEOUT) > 0 && node.attempts < MESSAGE_RETRY_ATTEMPTS) {
338                     logger.debug("Resending timed out information request message to node ({})", node.macAddress);
339                     updateInformation(node.macAddress);
340                     node.lastRequest = Instant.now();
341                     node.attempts++;
342                 } else if (node.attempts >= MESSAGE_RETRY_ATTEMPTS) {
343                     logger.debug("Giving up on information request for node ({})", node.macAddress);
344                     it.remove();
345                 }
346             }
347
348             if (!currentRoleCall.isRoleCalling && discoveredNodes.isEmpty()) {
349                 logger.debug("Discovery no longer needs to be watched");
350                 stopDiscoveryWatchJob();
351             }
352         };
353
354         ScheduledFuture<?> localWatchJob = watchJob;
355         if (localWatchJob == null || localWatchJob.isCancelled()) {
356             watchJob = scheduler.scheduleWithFixedDelay(watchRunnable, WATCH_INTERVAL.toMillis(),
357                     WATCH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS);
358         }
359     }
360
361     @Override
362     protected void startScan() {
363         logger.debug("Discover nodes (manual discovery)");
364         discoverNodes();
365     }
366
367     @Override
368     public void stickStatusChanged(ThingStatus status) {
369         if (status.equals(ThingStatus.ONLINE)) {
370             logger.debug("Discover nodes (Stick online)");
371             discoverNodes();
372         }
373     }
374
375     @Override
376     protected void stopBackgroundDiscovery() {
377         logger.debug("Stopping Plugwise device background discovery");
378         ScheduledFuture<?> localDiscoveryJob = discoveryJob;
379         if (localDiscoveryJob != null && !localDiscoveryJob.isCancelled()) {
380             localDiscoveryJob.cancel(true);
381             discoveryJob = null;
382         }
383         stopDiscoveryWatchJob();
384     }
385
386     private void stopDiscoveryWatchJob() {
387         logger.debug("Stopping Plugwise discovery watch job");
388         ScheduledFuture<?> localWatchJob = watchJob;
389         if (localWatchJob != null && !localWatchJob.isCancelled()) {
390             localWatchJob.cancel(true);
391             watchJob = null;
392         }
393     }
394
395     private void updateInformation(MACAddress macAddress) {
396         sendMessage(new InformationRequestMessage(macAddress));
397     }
398 }