2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.plugwise.internal;
15 import static java.util.stream.Collectors.*;
16 import static org.openhab.binding.plugwise.internal.PlugwiseBindingConstants.*;
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;
24 import java.util.Map.Entry;
26 import java.util.concurrent.ConcurrentHashMap;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
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;
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.
59 * @author Wouter Born, Karel Goderis - Initial contribution
62 public class PlugwiseThingDiscoveryService extends AbstractDiscoveryService
63 implements PlugwiseMessageListener, PlugwiseStickStatusListener, ThingHandlerService {
65 private static class CurrentRoleCall {
66 private boolean isRoleCalling;
67 private int currentNodeID;
69 private Instant lastRequest = Instant.MIN;
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;
77 private Instant lastRequest = Instant.MIN;
79 public DiscoveredNode(MACAddress macAddress) {
80 this.macAddress = macAddress;
83 public boolean isDataComplete() {
84 return deviceType != DeviceType.UNKNOWN && !properties.isEmpty();
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));
92 private static final int MIN_NODE_ID = 0;
93 private static final int MAX_NODE_ID = 63;
95 private static final int MESSAGE_RETRY_ATTEMPTS = 5;
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);
101 private final Logger logger = LoggerFactory.getLogger(PlugwiseThingDiscoveryService.class);
103 private @NonNullByDefault({}) PlugwiseStickHandler stickHandler;
105 private @Nullable ScheduledFuture<?> discoveryJob;
106 private @Nullable ScheduledFuture<?> watchJob;
107 private CurrentRoleCall currentRoleCall = new CurrentRoleCall();
109 private final Map<MACAddress, DiscoveredNode> discoveredNodes = new ConcurrentHashMap<>();
111 public PlugwiseThingDiscoveryService() throws IllegalArgumentException {
112 super(DISCOVERED_THING_TYPES_UIDS, 1, true);
116 public synchronized void abortScan() {
117 logger.debug("Aborting nodes discovery");
119 currentRoleCall.isRoleCalling = false;
120 stopDiscoveryWatchJob();
124 public void activate() {
125 super.activate(new HashMap<>());
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);
135 thingDiscovered(DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUID)
136 .withLabel("Plugwise " + node.deviceType.toString())
137 .withProperty(PlugwiseBindingConstants.CONFIG_PROPERTY_MAC_ADDRESS, mac)
138 .withProperties(new HashMap<>(node.properties))
139 .withRepresentationProperty(PlugwiseBindingConstants.PROPERTY_MAC_ADDRESS).build());
144 public void deactivate() {
146 stickHandler.removeMessageListener(this);
147 stickHandler.removeStickStatusListener(this);
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();
158 logger.debug("Already discovered node ({})", macAddress);
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)");
171 stickHandler.addMessageListener(this);
172 discoveredNodes.clear();
173 currentRoleCall.isRoleCalling = true;
174 currentRoleCall.currentNodeID = Integer.MIN_VALUE;
176 discoverNewNodeDetails(circlePlusMAC);
178 logger.debug("Discovering nodes with role call on Circle+ ({})", circlePlusMAC);
179 roleCall(MIN_NODE_ID);
180 startDiscoveryWatchJob();
184 private @Nullable MACAddress getCirclePlusMAC() {
185 return stickHandler.getCirclePlusMAC();
188 private ThingStatus getStickStatus() {
189 return stickHandler.getThing().getStatus();
193 public @Nullable ThingHandler getThingHandler() {
197 private void handleAnnounceAwakeRequest(AnnounceAwakeRequestMessage message) {
198 discoverNewNodeDetails(message.getMACAddress());
201 private void handleInformationResponse(InformationResponseMessage message) {
202 MACAddress mac = message.getMACAddress();
203 DiscoveredNode node = discoveredNodes.get(mac);
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);
213 logger.debug("Received information response for already discovered node ({})", mac);
218 public void handleReponseMessage(Message message) {
219 switch (message.getType()) {
220 case ANNOUNCE_AWAKE_REQUEST:
221 handleAnnounceAwakeRequest((AnnounceAwakeRequestMessage) message);
223 case DEVICE_INFORMATION_RESPONSE:
224 handleInformationResponse((InformationResponseMessage) message);
226 case DEVICE_ROLE_CALL_RESPONSE:
227 handleRoleCallResponse((RoleCallResponseMessage) message);
230 logger.trace("Received unhandled {} message from {}", message.getType(), message.getMACAddress());
235 private void handleRoleCallResponse(RoleCallResponseMessage message) {
236 logger.debug("Node with ID {} has MAC address: {}", message.getNodeID(), message.getNodeMAC());
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);
245 currentRoleCall.isRoleCalling = false;
248 currentRoleCall.isRoleCalling = false;
251 if (!currentRoleCall.isRoleCalling) {
252 logger.debug("Finished discovering devices with role call on Circle+ ({})", getCirclePlusMAC());
256 private boolean isAlreadyDiscovered(MACAddress macAddress) {
257 Thing thing = stickHandler.getThingByMAC(macAddress);
259 logger.debug("Node ({}) has existing thing: {}", macAddress, thing.getUID());
261 return thing != null;
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)
269 * @param nodeID of the device to role call
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;
277 currentRoleCall.attempts++;
279 currentRoleCall.currentNodeID = nodeID;
280 currentRoleCall.lastRequest = Instant.now();
282 logger.warn("Invalid node ID for role call: {}", nodeID);
286 private void sendMessage(Message message) {
287 stickHandler.sendMessage(message, PlugwiseMessagePriority.UPDATE_AND_DISCOVERY);
291 public void setThingHandler(ThingHandler handler) {
292 if (handler instanceof PlugwiseStickHandler) {
293 stickHandler = (PlugwiseStickHandler) handler;
294 stickHandler.addStickStatusListener(this);
299 protected void startBackgroundDiscovery() {
300 logger.debug("Starting Plugwise device background discovery");
302 Runnable discoveryRunnable = () -> {
303 logger.debug("Discover nodes (background discovery)");
307 ScheduledFuture<?> localDiscoveryJob = discoveryJob;
308 if (localDiscoveryJob == null || localDiscoveryJob.isCancelled()) {
309 discoveryJob = scheduler.scheduleWithFixedDelay(discoveryRunnable, 0, DISCOVERY_INTERVAL.toMillis(),
310 TimeUnit.MILLISECONDS);
314 private void startDiscoveryWatchJob() {
315 logger.debug("Starting Plugwise discovery watch job");
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;
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();
342 } else if (node.attempts >= MESSAGE_RETRY_ATTEMPTS) {
343 logger.debug("Giving up on information request for node ({})", node.macAddress);
348 if (!currentRoleCall.isRoleCalling && discoveredNodes.isEmpty()) {
349 logger.debug("Discovery no longer needs to be watched");
350 stopDiscoveryWatchJob();
354 ScheduledFuture<?> localWatchJob = watchJob;
355 if (localWatchJob == null || localWatchJob.isCancelled()) {
356 watchJob = scheduler.scheduleWithFixedDelay(watchRunnable, WATCH_INTERVAL.toMillis(),
357 WATCH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS);
362 protected void startScan() {
363 logger.debug("Discover nodes (manual discovery)");
368 public void stickStatusChanged(ThingStatus status) {
369 if (status.equals(ThingStatus.ONLINE)) {
370 logger.debug("Discover nodes (Stick online)");
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);
383 stopDiscoveryWatchJob();
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);
395 private void updateInformation(MACAddress macAddress) {
396 sendMessage(new InformationRequestMessage(macAddress));