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.nibeheatpump.internal.handler;
15 import static org.openhab.binding.nibeheatpump.internal.NibeHeatPumpBindingConstants.*;
17 import java.math.BigDecimal;
18 import java.math.RoundingMode;
19 import java.util.ArrayList;
20 import java.util.Collections;
21 import java.util.HashMap;
22 import java.util.List;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
27 import java.util.stream.Collectors;
29 import org.openhab.binding.nibeheatpump.internal.NibeHeatPumpCommandResult;
30 import org.openhab.binding.nibeheatpump.internal.NibeHeatPumpException;
31 import org.openhab.binding.nibeheatpump.internal.config.NibeHeatPumpConfiguration;
32 import org.openhab.binding.nibeheatpump.internal.connection.NibeHeatPumpConnector;
33 import org.openhab.binding.nibeheatpump.internal.connection.NibeHeatPumpEventListener;
34 import org.openhab.binding.nibeheatpump.internal.connection.SerialConnector;
35 import org.openhab.binding.nibeheatpump.internal.connection.SimulatorConnector;
36 import org.openhab.binding.nibeheatpump.internal.connection.UDPConnector;
37 import org.openhab.binding.nibeheatpump.internal.message.ModbusDataReadOutMessage;
38 import org.openhab.binding.nibeheatpump.internal.message.ModbusReadRequestMessage;
39 import org.openhab.binding.nibeheatpump.internal.message.ModbusReadResponseMessage;
40 import org.openhab.binding.nibeheatpump.internal.message.ModbusValue;
41 import org.openhab.binding.nibeheatpump.internal.message.ModbusWriteRequestMessage;
42 import org.openhab.binding.nibeheatpump.internal.message.ModbusWriteResponseMessage;
43 import org.openhab.binding.nibeheatpump.internal.message.NibeHeatPumpMessage;
44 import org.openhab.binding.nibeheatpump.internal.models.PumpModel;
45 import org.openhab.binding.nibeheatpump.internal.models.VariableInformation;
46 import org.openhab.binding.nibeheatpump.internal.models.VariableInformation.NibeDataType;
47 import org.openhab.binding.nibeheatpump.internal.models.VariableInformation.Type;
48 import org.openhab.core.io.transport.serial.SerialPortManager;
49 import org.openhab.core.library.types.DecimalType;
50 import org.openhab.core.library.types.OnOffType;
51 import org.openhab.core.library.types.OpenClosedType;
52 import org.openhab.core.library.types.QuantityType;
53 import org.openhab.core.library.types.StringType;
54 import org.openhab.core.library.types.UpDownType;
55 import org.openhab.core.thing.ChannelUID;
56 import org.openhab.core.thing.Thing;
57 import org.openhab.core.thing.ThingStatus;
58 import org.openhab.core.thing.ThingStatusDetail;
59 import org.openhab.core.thing.ThingTypeUID;
60 import org.openhab.core.thing.binding.BaseThingHandler;
61 import org.openhab.core.types.Command;
62 import org.openhab.core.types.RefreshType;
63 import org.openhab.core.types.State;
64 import org.openhab.core.types.UnDefType;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
69 * The {@link NibeHeatPumpHandler} is responsible for handling commands, which
70 * are sent to one of the channels.
72 * @author Pauli Anttila - Initial contribution
74 public class NibeHeatPumpHandler extends BaseThingHandler implements NibeHeatPumpEventListener {
76 private static final int TIMEOUT = 4500;
77 private final Logger logger = LoggerFactory.getLogger(NibeHeatPumpHandler.class);
78 private final PumpModel pumpModel;
79 private final SerialPortManager serialPortManager;
80 private final List<Integer> itemsToPoll = Collections.synchronizedList(new ArrayList<>());
81 private final List<Integer> itemsToEnableWrite = new ArrayList<>();
82 private final Map<Integer, CacheObject> stateMap = Collections.synchronizedMap(new HashMap<>());
83 private NibeHeatPumpConfiguration configuration;
84 private NibeHeatPumpConnector connector;
85 private boolean reconnectionRequest;
86 private NibeHeatPumpCommandResult writeResult;
87 private NibeHeatPumpCommandResult readResult;
88 private final Runnable pollingRunnable = new Runnable() {
91 if (!configuration.enableReadCommands) {
92 logger.trace("All read commands denied, skip polling!");
97 synchronized (itemsToPoll) {
98 items = new ArrayList<>(itemsToPoll);
101 for (int item : items) {
102 if (connector != null && connector.isConnected()
103 && getThing().getStatusInfo().getStatus() == ThingStatus.ONLINE) {
104 CacheObject oldValue = stateMap.get(item);
106 || (oldValue.lastUpdateTime + refreshIntervalMillis()) < System.currentTimeMillis()) {
107 // it's time to refresh data
108 logger.debug("Time to refresh variable '{}' data", item);
110 ModbusReadRequestMessage request = new ModbusReadRequestMessage.MessageBuilder()
111 .coilAddress(item).build();
114 readResult = sendMessageToNibe(request);
115 ModbusReadResponseMessage result = (ModbusReadResponseMessage) readResult.get(TIMEOUT,
116 TimeUnit.MILLISECONDS);
117 if (result != null) {
118 if (request.getCoilAddress() != result.getCoilAddress()) {
119 logger.debug("Data from wrong register '{}' received, expected '{}'",
120 result.getCoilAddress(), request.getCoilAddress());
122 // update variable anyway
123 handleVariableUpdate(pumpModel, result.getValueAsModbusValue());
125 } catch (TimeoutException e) {
126 logger.debug("Message sending to heat pump failed, no response");
127 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
128 } catch (InterruptedException e) {
129 logger.debug("Message sending to heat pump failed, sending interrupted");
130 } catch (NibeHeatPumpException e) {
131 logger.debug("Message sending to heat pump failed, exception {}", e.getMessage());
132 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
141 private ScheduledFuture<?> connectorTask;
142 private ScheduledFuture<?> pollingJob;
143 private long lastUpdateTime = 0;
145 public NibeHeatPumpHandler(Thing thing, PumpModel pumpModel, SerialPortManager serialPortManager) {
147 this.pumpModel = pumpModel;
148 this.serialPortManager = serialPortManager;
151 private NibeHeatPumpConnector getConnector() throws NibeHeatPumpException {
152 ThingTypeUID type = thing.getThingTypeUID();
154 if (THING_TYPE_F1X45_UDP.equals(type) || THING_TYPE_F1X55_UDP.equals(type) || THING_TYPE_SMO40_UDP.equals(type)
155 || THING_TYPE_F750_UDP.equals(type) || THING_TYPE_F470_UDP.equals(type)) {
156 return new UDPConnector();
157 } else if (THING_TYPE_F1X45_SERIAL.equals(type) || THING_TYPE_F1X55_SERIAL.equals(type)
158 || THING_TYPE_SMO40_SERIAL.equals(type) || THING_TYPE_F750_SERIAL.equals(type)
159 || THING_TYPE_F470_SERIAL.equals(type)) {
160 return new SerialConnector(serialPortManager);
161 } else if (THING_TYPE_F1X45_SIMULATOR.equals(type) || THING_TYPE_F1X55_SIMULATOR.equals(type)
162 || THING_TYPE_SMO40_SIMULATOR.equals(type) || THING_TYPE_F750_SIMULATOR.equals(type)
163 || THING_TYPE_F470_SIMULATOR.equals(type)) {
164 return new SimulatorConnector();
167 String description = String.format("Unknown connector type %s", type);
168 throw new NibeHeatPumpException(description);
172 public void handleCommand(ChannelUID channelUID, Command command) {
173 logger.debug("Received channel: {}, command: {}", channelUID, command);
175 int coilAddress = parseCoilAddressFromChannelUID(channelUID);
177 if (command.equals(RefreshType.REFRESH)) {
178 logger.debug("Clearing cache value for channel '{}' to refresh channel data", channelUID);
179 clearCache(coilAddress);
183 if (!configuration.enableWriteCommands) {
185 "All write commands denied, ignoring command! Change Nibe heat pump binding configuration if you want to enable write commands.");
189 if (!itemsToEnableWrite.contains(coilAddress)) {
191 "Write commands to register '{}' not allowed, ignoring command! Add this register to Nibe heat pump binding configuration if you want to enable write commands.",
196 if (connector != null) {
197 VariableInformation variableInfo = VariableInformation.getVariableInfo(pumpModel, coilAddress);
198 logger.debug("Using variable information for register {}: {}", coilAddress, variableInfo);
200 if (variableInfo != null && variableInfo.type == VariableInformation.Type.SETTING) {
202 int value = convertCommandToNibeValue(variableInfo, command);
204 ModbusWriteRequestMessage msg = new ModbusWriteRequestMessage.MessageBuilder()
205 .coilAddress(coilAddress).value(value).build();
207 writeResult = sendMessageToNibe(msg);
208 ModbusWriteResponseMessage result = (ModbusWriteResponseMessage) writeResult.get(TIMEOUT,
209 TimeUnit.MILLISECONDS);
210 if (result != null) {
211 if (result.isSuccessfull()) {
212 logger.debug("Write message sending to heat pump succeeded");
214 logger.error("Message sending to heat pump failed, value not accepted by the heat pump");
217 logger.debug("Something weird happen, result for write command is null");
219 } catch (TimeoutException e) {
220 logger.warn("Message sending to heat pump failed, no response");
221 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
222 "No response received from the heat pump");
223 } catch (InterruptedException e) {
224 logger.debug("Message sending to heat pump failed, sending interrupted");
225 } catch (NibeHeatPumpException e) {
226 logger.debug("Message sending to heat pump failed, exception {}", e.getMessage());
227 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
228 } catch (CommandTypeNotSupportedException e) {
229 logger.warn("Unsupported command type {} received for channel {}, coil address {}.",
230 command.getClass().getName(), channelUID.getId(), coilAddress);
235 // Clear cache value to refresh coil data from the pump.
236 // We might not know if write message have succeed or not, so let's always refresh it.
237 logger.debug("Clearing cache value for channel '{}' to refresh channel data", channelUID);
238 clearCache(coilAddress);
240 logger.debug("Command to channel '{}' rejected, because item is read only parameter", channelUID);
243 logger.debug("No connection to heat pump");
244 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_MISSING_ERROR);
249 public void channelLinked(ChannelUID channelUID) {
250 logger.debug("channelLinked: {}", channelUID);
252 // Add channel to polling loop
253 int coilAddress = parseCoilAddressFromChannelUID(channelUID);
254 synchronized (itemsToPoll) {
255 if (!itemsToPoll.contains(coilAddress)) {
256 logger.debug("New channel '{}' found, register '{}'", channelUID.getAsString(), coilAddress);
257 itemsToPoll.add(coilAddress);
260 clearCache(coilAddress);
264 public void channelUnlinked(ChannelUID channelUID) {
265 logger.debug("channelUnlinked: {}", channelUID);
267 // remove channel from polling loop
268 int coilAddress = parseCoilAddressFromChannelUID(channelUID);
269 synchronized (itemsToPoll) {
270 itemsToPoll.removeIf(c -> c.equals(coilAddress));
274 private int parseCoilAddressFromChannelUID(ChannelUID channelUID) {
275 if (channelUID.getId().contains("#")) {
276 String[] parts = channelUID.getId().split("#");
277 return Integer.parseInt(parts[parts.length - 1]);
279 return Integer.parseInt(channelUID.getId());
284 public void initialize() {
285 logger.debug("Initialized Nibe Heat Pump device handler for {}", getThing().getUID());
286 configuration = getConfigAs(NibeHeatPumpConfiguration.class);
287 logger.debug("Using configuration: {}", configuration.toString());
290 parseWriteEnabledItems();
291 connector = getConnector();
292 } catch (IllegalArgumentException | NibeHeatPumpException e) {
293 String description = String.format("Illegal configuration, %s", e.getMessage());
294 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, description);
299 itemsToPoll.addAll(this.getThing().getChannels().stream().filter(c -> isLinked(c.getUID())).map(c -> {
300 int coilAddress = parseCoilAddressFromChannelUID(c.getUID());
301 logger.debug("Linked channel '{}' found, register '{}'", c.getUID().getAsString(), coilAddress);
303 }).filter(c -> c != 0).collect(Collectors.toSet()));
305 logger.debug("Linked registers {}: {}", itemsToPoll.size(), itemsToPoll);
309 if (connectorTask == null || connectorTask.isCancelled()) {
310 connectorTask = scheduler.scheduleWithFixedDelay(() -> {
311 if (reconnectionRequest) {
312 logger.debug("Restarting requested, restarting...");
313 reconnectionRequest = false;
317 logger.debug("Checking Nibe Heat pump connection, thing status = {}", thing.getStatus());
319 }, 0, 10, TimeUnit.SECONDS);
323 private void connect() {
324 if (!connector.isConnected()) {
325 logger.debug("Connecting to heat pump");
327 connector.addEventListener(this);
328 connector.connect(configuration);
329 updateStatus(ThingStatus.ONLINE);
331 if (pollingJob == null || pollingJob.isCancelled()) {
332 logger.debug("Start refresh task, interval={}sec", 1);
333 pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 0, 1, TimeUnit.SECONDS);
335 } catch (NibeHeatPumpException e) {
336 logger.debug("Error occurred when connecting to heat pump, exception {}", e.getMessage());
337 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
340 logger.debug("Connection to heat pump already open");
345 public void dispose() {
346 logger.debug("Thing {} disposed.", getThing().getUID());
348 if (connectorTask != null && !connectorTask.isCancelled()) {
349 connectorTask.cancel(true);
350 connectorTask = null;
356 private void closeConnection() {
357 logger.debug("Closing connection to the heat pump");
359 if (pollingJob != null && !pollingJob.isCancelled()) {
360 pollingJob.cancel(true);
364 if (connector != null) {
365 connector.removeEventListener(this);
366 connector.disconnect();
370 private long refreshIntervalMillis() {
371 return configuration.refreshInterval * 1000;
374 private int convertCommandToNibeValue(VariableInformation variableInfo, Command command)
375 throws CommandTypeNotSupportedException {
378 if (command instanceof DecimalType || command instanceof QuantityType || command instanceof StringType) {
380 if (command instanceof DecimalType decimalCommand) {
381 v = decimalCommand.toBigDecimal();
382 } else if (command instanceof QuantityType quantityCommand) {
383 v = quantityCommand.toBigDecimal();
385 v = new BigDecimal(command.toString());
387 int decimals = (int) Math.log10(variableInfo.factor);
388 value = v.movePointRight(decimals).intValue();
389 } else if ((command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType)
390 && variableInfo.factor == 1) {
391 value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
392 || command.equals(OpenClosedType.OPEN)) ? 1 : 0;
394 throw new CommandTypeNotSupportedException();
400 private void parseWriteEnabledItems() throws IllegalArgumentException {
401 itemsToEnableWrite.clear();
402 if (configuration.enableWriteCommands && configuration.enableWriteCommandsToRegisters != null
403 && configuration.enableWriteCommandsToRegisters.length() > 0) {
404 String[] items = configuration.enableWriteCommandsToRegisters.replace(" ", "").split(",");
405 for (String item : items) {
407 int coilAddress = Integer.parseInt(item);
408 VariableInformation variableInformation = VariableInformation.getVariableInfo(pumpModel,
410 if (variableInformation == null) {
411 String description = String.format("Unknown register %s", coilAddress);
412 throw new IllegalArgumentException(description);
414 itemsToEnableWrite.add(coilAddress);
415 } catch (NumberFormatException e) {
416 String description = String.format("Illegal register %s", item);
417 throw new IllegalArgumentException(description);
421 logger.debug("Enabled registers for write commands: {}", itemsToEnableWrite);
424 private State convertNibeValueToState(VariableInformation variableInfo, int value, String acceptedItemType) {
425 State state = UnDefType.UNDEF;
428 NibeDataType dataType = variableInfo.dataType;
429 int decimals = (int) Math.log10(variableInfo.factor);
432 x = Byte.toUnsignedLong((byte) (value & 0xFF));
435 x = Short.toUnsignedLong((short) (value & 0xFFFF));
438 x = Integer.toUnsignedLong(value);
441 x = (byte) (value & 0xFF);
444 x = (short) (value & 0xFFFF);
452 BigDecimal converted = new BigDecimal(x).movePointLeft(decimals).setScale(decimals, RoundingMode.HALF_EVEN);
454 if ("String".equalsIgnoreCase(acceptedItemType)) {
455 state = new StringType(converted.toString());
457 } else if ("Switch".equalsIgnoreCase(acceptedItemType)) {
458 state = converted.intValue() == 0 ? OnOffType.OFF : OnOffType.ON;
460 } else if ("Number".equalsIgnoreCase(acceptedItemType)) {
461 state = new DecimalType(converted);
467 private void clearCache() {
472 private void clearCache(int coilAddress) {
473 stateMap.put(coilAddress, null);
476 private synchronized NibeHeatPumpCommandResult sendMessageToNibe(NibeHeatPumpMessage msg)
477 throws NibeHeatPumpException {
478 logger.debug("Sending message: {}", msg);
479 connector.sendDatagram(msg);
480 return new NibeHeatPumpCommandResult();
484 public void msgReceived(NibeHeatPumpMessage msg) {
486 if (logger.isTraceEnabled()) {
487 logger.trace("Received raw data: {}", msg.toHexString());
490 logger.debug("Received message: {}", msg);
492 updateStatus(ThingStatus.ONLINE);
494 if (msg instanceof ModbusReadResponseMessage readResponseMessage) {
495 handleReadResponseMessage(readResponseMessage);
496 } else if (msg instanceof ModbusWriteResponseMessage writeResponseMessage) {
497 handleWriteResponseMessage(writeResponseMessage);
498 } else if (msg instanceof ModbusDataReadOutMessage dataReadOutMessage) {
499 handleDataReadOutMessage(dataReadOutMessage);
501 logger.debug("Received unknown message: {}", msg.toString());
503 } catch (Exception e) {
504 logger.debug("Error occurred when parsing received message, reason: {}", e.getMessage());
509 public void errorOccurred(String error) {
510 logger.debug("Error '{}' occurred, re-establish the connection", error);
511 reconnectionRequest = true;
512 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, error);
515 private void handleReadResponseMessage(ModbusReadResponseMessage msg) {
516 if (readResult != null) {
521 private void handleWriteResponseMessage(ModbusWriteResponseMessage msg) {
522 if (writeResult != null) {
523 writeResult.set(msg);
527 private void handleDataReadOutMessage(ModbusDataReadOutMessage msg) {
528 boolean parse = true;
530 logger.debug("Received data read out message");
531 if (configuration.throttleTime > 0) {
532 if ((lastUpdateTime + configuration.throttleTime) > System.currentTimeMillis()) {
533 logger.debug("Skipping data read out message parsing");
539 logger.debug("Parsing data read out message");
540 lastUpdateTime = System.currentTimeMillis();
541 List<ModbusValue> regValues = msg.getValues();
543 if (regValues != null) {
544 for (ModbusValue val : regValues) {
545 handleVariableUpdate(pumpModel, val);
551 private void handleVariableUpdate(PumpModel pumpModel, ModbusValue value) {
552 logger.debug("Received variable update: {}", value);
553 int coilAddress = value.getCoilAddress();
555 VariableInformation variableInfo = VariableInformation.getVariableInfo(pumpModel, coilAddress);
557 if (variableInfo != null) {
558 logger.trace("Using variable information to register {}: {}", coilAddress, variableInfo);
560 int val = value.getValue();
561 logger.debug("{} = {}", coilAddress + ":" + variableInfo.variable + "/" + variableInfo.factor, val);
563 CacheObject oldValue = stateMap.get(coilAddress);
565 if (oldValue != null && val == oldValue.value
566 && (oldValue.lastUpdateTime + refreshIntervalMillis() / 2) >= System.currentTimeMillis()) {
567 logger.trace("Value did not change, ignoring update");
569 final String channelPrefix = (variableInfo.type == Type.SETTING ? "setting#" : "sensor#");
570 final String channelId = channelPrefix + coilAddress;
571 final String acceptedItemType = thing.getChannel(channelId).getAcceptedItemType();
573 logger.trace("AcceptedItemType for channel {} = {}", channelId, acceptedItemType);
574 State state = convertNibeValueToState(variableInfo, val, acceptedItemType);
575 logger.debug("Setting state {} = {}", coilAddress + ":" + variableInfo.variable, state);
576 stateMap.put(coilAddress, new CacheObject(System.currentTimeMillis(), val));
577 updateState(new ChannelUID(getThing().getUID(), channelId), state);
580 logger.debug("Unknown register {}", coilAddress);
584 protected class CacheObject {
586 /** Time when cache object updated in milliseconds */
587 final long lastUpdateTime;
593 * Initialize cache object.
595 * @param lastUpdateTime Time in milliseconds.
596 * @param value Cache value.
598 CacheObject(long lastUpdateTime, int value) {
599 this.lastUpdateTime = lastUpdateTime;