2 * Copyright (c) 2010-2023 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.nikobus.internal.handler;
15 import static org.openhab.binding.nikobus.internal.NikobusBindingConstants.CONFIG_REFRESH_INTERVAL;
17 import java.io.IOException;
18 import java.io.OutputStream;
19 import java.util.Collection;
20 import java.util.Collections;
21 import java.util.HashMap;
22 import java.util.LinkedList;
23 import java.util.List;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
29 import java.util.function.Consumer;
30 import java.util.stream.Collectors;
31 import java.util.stream.Stream;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.nikobus.internal.NikobusBindingConstants;
36 import org.openhab.binding.nikobus.internal.discovery.NikobusDiscoveryService;
37 import org.openhab.binding.nikobus.internal.protocol.NikobusCommand;
38 import org.openhab.binding.nikobus.internal.protocol.NikobusConnection;
39 import org.openhab.binding.nikobus.internal.utils.Utils;
40 import org.openhab.core.io.transport.serial.SerialPortManager;
41 import org.openhab.core.thing.Bridge;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.binding.BaseBridgeHandler;
47 import org.openhab.core.thing.binding.ThingHandler;
48 import org.openhab.core.thing.binding.ThingHandlerService;
49 import org.openhab.core.types.Command;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
54 * The {@link NikobusPcLinkHandler} is responsible for handling commands, which
55 * are sent or received from the PC-Link Nikobus component.
57 * @author Boris Krivonog - Initial contribution
60 public class NikobusPcLinkHandler extends BaseBridgeHandler {
61 private final Logger logger = LoggerFactory.getLogger(NikobusPcLinkHandler.class);
62 private final Map<String, Runnable> commandListeners = Collections.synchronizedMap(new HashMap<>());
63 private final LinkedList<NikobusCommand> pendingCommands = new LinkedList<>();
64 private final StringBuilder stringBuilder = new StringBuilder();
65 private final SerialPortManager serialPortManager;
66 private @Nullable NikobusConnection connection;
67 private @Nullable NikobusCommand currentCommand;
68 private @Nullable ScheduledFuture<?> scheduledRefreshFuture;
69 private @Nullable ScheduledFuture<?> scheduledSendCommandWatchdogFuture;
70 private @Nullable String ack;
71 private @Nullable Consumer<String> unhandledCommandsProcessor;
72 private int refreshThingIndex = 0;
74 public NikobusPcLinkHandler(Bridge bridge, SerialPortManager serialPortManager) {
76 this.serialPortManager = serialPortManager;
80 public void initialize() {
82 stringBuilder.setLength(0);
84 updateStatus(ThingStatus.UNKNOWN);
86 String portName = (String) getConfig().get(NikobusBindingConstants.CONFIG_PORT_NAME);
87 if (portName == null) {
88 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Port must be set!");
92 connection = new NikobusConnection(serialPortManager, portName, this::processReceivedValue);
94 int refreshInterval = ((Number) getConfig().get(CONFIG_REFRESH_INTERVAL)).intValue();
95 scheduledRefreshFuture = scheduler.scheduleWithFixedDelay(this::refresh, refreshInterval, refreshInterval,
100 public void dispose() {
103 Utils.cancel(scheduledSendCommandWatchdogFuture);
104 scheduledSendCommandWatchdogFuture = null;
106 Utils.cancel(scheduledRefreshFuture);
107 scheduledRefreshFuture = null;
109 NikobusConnection connection = this.connection;
110 this.connection = null;
112 if (connection != null) {
118 public void handleCommand(ChannelUID channelUID, Command command) {
123 public Collection<Class<? extends ThingHandlerService>> getServices() {
124 return Set.of(NikobusDiscoveryService.class);
127 private void processReceivedValue(byte value) {
128 logger.trace("Received {}", value);
131 String command = stringBuilder.toString();
132 stringBuilder.setLength(0);
134 logger.debug("Received command '{}', ack = '{}'", command, ack);
137 if (command.startsWith("$")) {
138 String ack = this.ack;
141 processResponse(command, ack);
143 Runnable listener = commandListeners.get(command);
144 if (listener != null) {
147 Consumer<String> processor = unhandledCommandsProcessor;
148 if (processor != null) {
149 processor.accept(command);
153 } catch (RuntimeException e) {
154 logger.debug("Processing command '{}' failed due {}", command, e.getMessage(), e);
157 stringBuilder.append((char) value);
159 // Take ACK part, i.e. "$0512"
160 if (stringBuilder.length() == 5) {
161 String payload = stringBuilder.toString();
162 if (payload.startsWith("$05")) {
164 logger.debug("Received ack '{}'", ack);
165 stringBuilder.setLength(0);
167 } else if (stringBuilder.length() > 128) {
168 // Fuse, if for some reason we don't receive \r don't fill buffer.
169 stringBuilder.setLength(0);
170 logger.warn("Resetting read buffer, should not happen, am I connected to Nikobus?");
175 public void addListener(String command, Runnable listener) {
176 if (commandListeners.put(command, listener) != null) {
177 logger.warn("Multiple registrations for '{}'", command);
181 public void removeListener(String command) {
182 commandListeners.remove(command);
185 public void setUnhandledCommandProcessor(Consumer<String> processor) {
186 if (unhandledCommandsProcessor != null) {
187 logger.debug("Unexpected override of unhandledCommandsProcessor");
189 unhandledCommandsProcessor = processor;
192 public void resetUnhandledCommandProcessor() {
193 unhandledCommandsProcessor = null;
196 private void processResponse(String commandPayload, @Nullable String ack) {
197 NikobusCommand command;
198 synchronized (pendingCommands) {
199 command = currentCommand;
202 if (command == null) {
203 logger.debug("Processing response but no command pending");
207 NikobusCommand.ResponseHandler responseHandler = command.getResponseHandler();
208 if (responseHandler == null) {
209 logger.debug("No response expected for current command");
214 logger.debug("No ack received");
218 String requestCommandId = command.getPayload().substring(3, 5);
219 String ackCommandId = ack.substring(3, 5);
220 if (!ackCommandId.equals(requestCommandId)) {
221 logger.debug("Unexpected command's ack '{}' != '{}'", requestCommandId, ackCommandId);
225 // Check if response has expected length.
226 if (commandPayload.length() != responseHandler.getResponseLength()) {
227 logger.debug("Unexpected response length");
231 if (!commandPayload.startsWith(responseHandler.getResponseCode())) {
232 logger.debug("Unexpected response command code");
236 String requestCommandAddress = command.getPayload().substring(5, 9);
237 String ackCommandAddress = commandPayload.substring(responseHandler.getAddressStart(),
238 responseHandler.getAddressStart() + 4);
239 if (!requestCommandAddress.equals(ackCommandAddress)) {
240 logger.debug("Unexpected response address");
244 if (responseHandler.complete(commandPayload)) {
245 resetProcessingAndProcessNext();
249 public void sendCommand(NikobusCommand command) {
250 synchronized (pendingCommands) {
251 pendingCommands.addLast(command);
254 scheduler.submit(this::processCommand);
257 private void processCommand() {
258 NikobusCommand command;
259 synchronized (pendingCommands) {
260 if (currentCommand != null) {
264 command = pendingCommands.pollFirst();
265 if (command == null) {
269 currentCommand = command;
271 sendCommand(command, 3);
274 private void sendCommand(NikobusCommand command, int retry) {
275 logger.debug("Sending retry = {}, command '{}'", retry, command.getPayload());
277 NikobusConnection connection = this.connection;
278 if (connection == null) {
283 connectIfNeeded(connection);
285 OutputStream outputStream = connection.getOutputStream();
286 if (outputStream == null) {
289 outputStream.write(command.getPayload().getBytes());
290 outputStream.flush();
291 } catch (IOException e) {
292 logger.debug("Sending command failed due {}", e.getMessage(), e);
294 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
296 NikobusCommand.ResponseHandler responseHandler = command.getResponseHandler();
297 if (responseHandler == null) {
298 resetProcessingAndProcessNext();
299 } else if (retry > 0) {
300 scheduleSendCommandTimeout(() -> {
301 if (!responseHandler.isCompleted()) {
302 sendCommand(command, retry - 1);
306 scheduleSendCommandTimeout(() -> processTimeout(responseHandler));
311 private void scheduleSendCommandTimeout(Runnable command) {
312 scheduledSendCommandWatchdogFuture = scheduler.schedule(command, 2, TimeUnit.SECONDS);
315 private void processTimeout(NikobusCommand.ResponseHandler responseHandler) {
316 if (responseHandler.completeExceptionally(new TimeoutException("Waiting for response timed-out."))) {
317 resetProcessingAndProcessNext();
321 private void resetProcessingAndProcessNext() {
322 Utils.cancel(scheduledSendCommandWatchdogFuture);
323 synchronized (pendingCommands) {
324 currentCommand = null;
326 scheduler.submit(this::processCommand);
329 private void refresh() {
330 List<Thing> things = getThing().getThings().stream()
331 .filter(thing -> thing.getHandler() instanceof NikobusModuleHandler).collect(Collectors.toList());
333 // if there are command listeners (buttons) then we need an open connection even if no modules exist
334 if (!commandListeners.isEmpty()) {
335 NikobusConnection connection = this.connection;
336 if (connection == null) {
340 connectIfNeeded(connection);
341 } catch (IOException e) {
343 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
348 if (things.isEmpty()) {
349 logger.debug("Nothing to refresh");
353 refreshThingIndex = (refreshThingIndex + 1) % things.size();
355 ThingHandler thingHandler = things.get(refreshThingIndex).getHandler();
356 if (thingHandler == null) {
360 NikobusModuleHandler handler = (NikobusModuleHandler) thingHandler;
361 handler.refreshModule();
364 private synchronized void connectIfNeeded(NikobusConnection connection) throws IOException {
365 if (!connection.isConnected()) {
366 connection.connect();
368 // Send connection sequence, mimicking the Nikobus software. If this is not
369 // sent, PC-Link sometimes does not forward button presses via serial interface.
370 Stream.of(new String[] { "++++", "ATH0", "ATZ", "$10110000B8CF9D", "#L0", "#E0", "#L0", "#E1" })
371 .map(NikobusCommand::new).forEach(this::sendCommand);
373 updateStatus(ThingStatus.ONLINE);