[danfossairunit] Fix network reliability issues and setting of all channel values to zero (#11172)

* Fix Potential null pointer accesses
* Added constants for TCP port and poll interval in seconds.
* Extract interface from DanfossAirUnitCommunicationController.
* Remove unused constants which seems to be left-overs from skeleton.
* Added constant for discovery timeout value for readability.
* Created handler subfolder for DanfossAirUnitHandler (like discovery) for consistency with other bindings.
* Handle lost connection gracefully without updating all channels to zero.
* Fix infinitly blocking network calls preventing proper error handling.
* Fix thing status being reset to ONLINE after failing to update all channels.
* Fix error handling when receiving invalid manual fan step.

Fixes #11167
Fixes #11188

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
This commit is contained in:
jlaur 2021-09-27 07:58:10 +02:00 committed by GitHub
parent 2f4a27217f
commit 8255f29320
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 301 additions and 99 deletions

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.danfossairunit.internal;
import java.io.IOException;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* This interface defines a communication controller that can be used to send requests to the Danfoss Air Unit.
*
* @author Jacob Laursen - Refactoring, bugfixes and enhancements
*/
@NonNullByDefault
public interface CommunicationController {
void connect() throws IOException;
void disconnect();
byte[] sendRobustRequest(byte[] operation, byte[] register) throws IOException;
byte[] sendRobustRequest(byte[] operation, byte[] register, byte[] value) throws IOException;
}

View File

@ -17,7 +17,6 @@ import static org.openhab.binding.danfossairunit.internal.Commands.*;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.time.DateTimeException;
import java.time.ZoneId;
@ -43,30 +42,22 @@ import org.openhab.core.types.Command;
*
* @author Ralf Duckstein - Initial contribution
* @author Robert Bach - heavy refactorings
* @author Jacob Laursen - Refactoring, bugfixes and enhancements
*/
@SuppressWarnings("SameParameterValue")
@NonNullByDefault
public class DanfossAirUnit {
private final DanfossAirUnitCommunicationController communicationController;
private final CommunicationController communicationController;
public DanfossAirUnit(InetAddress inetAddr, int port) {
this.communicationController = new DanfossAirUnitCommunicationController(inetAddr, port);
}
public void cleanUp() {
this.communicationController.disconnect();
public DanfossAirUnit(CommunicationController communicationController) {
this.communicationController = communicationController;
}
private boolean getBoolean(byte[] operation, byte[] register) throws IOException {
return communicationController.sendRobustRequest(operation, register)[0] != 0;
}
private void setSetting(byte[] register, boolean value) throws IOException {
setSetting(register, value ? (byte) 1 : (byte) 0);
}
private short getWord(byte[] operation, byte[] register) throws IOException {
byte[] resultBytes = communicationController.sendRobustRequest(operation, register);
return (short) ((resultBytes[0] << 8) | (resultBytes[1] & 0xFF));
@ -87,14 +78,6 @@ public class DanfossAirUnit {
communicationController.sendRobustRequest(operation, register, valueArray);
}
private void set(byte[] operation, byte[] register, short value) throws IOException {
communicationController.sendRobustRequest(operation, register, shortToBytes(value));
}
private byte[] shortToBytes(short s) {
return new byte[] { (byte) ((s & 0xFF00) >> 8), (byte) (s & 0x00FF) };
}
private short getShort(byte[] operation, byte[] register) throws IOException {
byte[] result = communicationController.sendRobustRequest(operation, register);
return (short) ((result[0] << 8) + (result[1] & 0xff));
@ -141,14 +124,6 @@ public class DanfossAirUnit {
return f * 100 / 255;
}
private void setSetting(byte[] register, short value) throws IOException {
byte[] valueArray = new byte[2];
valueArray[0] = (byte) (value >> 8);
valueArray[1] = (byte) value;
communicationController.sendRobustRequest(REGISTER_1_WRITE, register, valueArray);
}
public String getUnitName() throws IOException {
return getString(REGISTER_1_READ, UNIT_NAME);
}
@ -161,8 +136,12 @@ public class DanfossAirUnit {
return new StringType(Mode.values()[getByte(REGISTER_1_READ, MODE)].name());
}
public PercentType getManualFanStep() throws IOException {
return new PercentType(BigDecimal.valueOf(getByte(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP) * 10));
public PercentType getManualFanStep() throws IOException, UnexpectedResponseValueException {
byte value = getByte(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP);
if (value < 0 || value > 10) {
throw new UnexpectedResponseValueException(String.format("Invalid fan step: %d", value));
}
return new PercentType(BigDecimal.valueOf(value * 10));
}
public DecimalType getSupplyFanSpeed() throws IOException {

View File

@ -30,12 +30,6 @@ public class DanfossAirUnitBindingConstants {
public static String BINDING_ID = "danfossairunit";
// List of all Thing Type UIDs
public static ThingTypeUID THING_TYPE_SAMPLE = new ThingTypeUID(BINDING_ID, "sample");
// List of all Channel ids
public static String CHANNEL_1 = "channel1";
// The only thing type UIDs
public static ThingTypeUID THING_TYPE_AIRUNIT = new ThingTypeUID(BINDING_ID, "airunit");

View File

@ -30,10 +30,13 @@ import org.slf4j.LoggerFactory;
* The {@link DanfossAirUnitCommunicationController} class does the actual network communication with the air unit.
*
* @author Robert Bach - initial contribution
* @author Jacob Laursen - Refactoring, bugfixes and enhancements
*/
@NonNullByDefault
public class DanfossAirUnitCommunicationController {
public class DanfossAirUnitCommunicationController implements CommunicationController {
private static final int SOCKET_TIMEOUT_MILLISECONDS = 5_000;
private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitCommunicationController.class);
@ -41,8 +44,8 @@ public class DanfossAirUnitCommunicationController {
private final int port;
private boolean connected = false;
private @Nullable Socket socket;
private @Nullable OutputStream oStream;
private @Nullable InputStream iStream;
private @Nullable OutputStream outputStream;
private @Nullable InputStream inputStream;
public DanfossAirUnitCommunicationController(InetAddress inetAddr, int port) {
this.inetAddr = inetAddr;
@ -53,9 +56,11 @@ public class DanfossAirUnitCommunicationController {
if (connected) {
return;
}
socket = new Socket(inetAddr, port);
oStream = socket.getOutputStream();
iStream = socket.getInputStream();
Socket localSocket = new Socket(inetAddr, port);
localSocket.setSoTimeout(SOCKET_TIMEOUT_MILLISECONDS);
this.outputStream = localSocket.getOutputStream();
this.inputStream = localSocket.getInputStream();
this.socket = localSocket;
connected = true;
}
@ -64,15 +69,16 @@ public class DanfossAirUnitCommunicationController {
return;
}
try {
if (socket != null) {
socket.close();
Socket localSocket = this.socket;
if (localSocket != null) {
localSocket.close();
}
} catch (IOException ioe) {
logger.debug("Connection to air unit could not be closed gracefully. {}", ioe.getMessage());
} finally {
socket = null;
iStream = null;
oStream = null;
this.socket = null;
this.inputStream = null;
this.outputStream = null;
}
connected = false;
}
@ -98,21 +104,27 @@ public class DanfossAirUnitCommunicationController {
}
private synchronized byte[] sendRequestInternal(byte[] request) throws IOException {
OutputStream localOutputStream = this.outputStream;
if (oStream == null) {
if (localOutputStream == null) {
throw new IOException(
String.format("Output stream is null while sending request: %s", Arrays.toString(request)));
}
oStream.write(request);
oStream.flush();
localOutputStream.write(request);
localOutputStream.flush();
byte[] result = new byte[63];
if (iStream == null) {
InputStream localInputStream = this.inputStream;
if (localInputStream == null) {
throw new IOException(
String.format("Input stream is null while sending request: %s", Arrays.toString(request)));
}
// noinspection ResultOfMethodCallIgnored
iStream.read(result, 0, 63);
int bytesRead = localInputStream.read(result, 0, 63);
if (bytesRead < 63) {
throw new IOException(String.format(
"Error reading from stream, read returned %d as number of bytes read into the buffer", bytesRead));
}
return result;
}

View File

@ -57,7 +57,6 @@ public class ValueCache {
return writeToCache;
}
@NonNullByDefault
private static class StateWithTimestamp {
State state;
long timestamp;

View File

@ -51,11 +51,12 @@ public class DanfossAirUnitDiscoveryService extends AbstractDiscoveryService {
private static final int BROADCAST_PORT = 30045;
private static final byte[] DISCOVER_SEND = { 0x0c, 0x00, 0x30, 0x00, 0x11, 0x00, 0x12, 0x00, 0x13 };
private static final byte[] DISCOVER_RECEIVE = { 0x0d, 0x00, 0x07, 0x00, 0x02, 0x02, 0x00 };
private static final int TIMEOUT_IN_SECONDS = 15;
private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitDiscoveryService.class);
public DanfossAirUnitDiscoveryService() {
super(SUPPORTED_THING_TYPES_UIDS, 15, true);
super(SUPPORTED_THING_TYPES_UIDS, TIMEOUT_IN_SECONDS, true);
}
@Override

View File

@ -40,15 +40,19 @@ import org.slf4j.LoggerFactory;
*
* @author Ralf Duckstein - Initial contribution
* @author Robert Bach - heavy refactorings
* @author Jacob Laursen - Refactoring, bugfixes and enhancements
*/
@NonNullByDefault
public class DanfossAirUnitHandler extends BaseThingHandler {
private static final int TCP_PORT = 30046;
private static final int POLLING_INTERVAL_SECONDS = 5;
private final Logger logger = LoggerFactory.getLogger(DanfossAirUnitHandler.class);
private @NonNullByDefault({}) DanfossAirUnitConfiguration config;
private @Nullable ValueCache valueCache;
private @Nullable ScheduledFuture<?> pollingJob;
private @Nullable DanfossAirUnit hrv;
private @Nullable DanfossAirUnitCommunicationController communicationController;
private @Nullable DanfossAirUnit airUnit;
public DanfossAirUnitHandler(Thing thing) {
super(thing);
@ -60,12 +64,12 @@ public class DanfossAirUnitHandler extends BaseThingHandler {
updateAllChannels();
} else {
try {
DanfossAirUnit danfossAirUnit = hrv;
if (danfossAirUnit != null) {
DanfossAirUnit localAirUnit = this.airUnit;
if (localAirUnit != null) {
Channel channel = Channel.getByName(channelUID.getIdWithoutGroup());
DanfossAirUnitWriteAccessor writeAccessor = channel.getWriteAccessor();
if (writeAccessor != null) {
updateState(channelUID, writeAccessor.access(danfossAirUnit, command));
updateState(channelUID, writeAccessor.access(localAirUnit, command));
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE,
@ -86,14 +90,16 @@ public class DanfossAirUnitHandler extends BaseThingHandler {
config = getConfigAs(DanfossAirUnitConfiguration.class);
valueCache = new ValueCache(config.updateUnchangedValuesEveryMillis);
try {
hrv = new DanfossAirUnit(InetAddress.getByName(config.host), 30046);
DanfossAirUnit danfossAirUnit = hrv;
var localCommunicationController = new DanfossAirUnitCommunicationController(
InetAddress.getByName(config.host), TCP_PORT);
this.communicationController = localCommunicationController;
var localAirUnit = new DanfossAirUnit(localCommunicationController);
this.airUnit = localAirUnit;
scheduler.execute(() -> {
try {
thing.setProperty(PROPERTY_UNIT_NAME, danfossAirUnit.getUnitName());
thing.setProperty(PROPERTY_SERIAL, danfossAirUnit.getUnitSerialNumber());
pollingJob = scheduler.scheduleWithFixedDelay(this::updateAllChannels, 5, config.refreshInterval,
TimeUnit.SECONDS);
thing.setProperty(PROPERTY_UNIT_NAME, localAirUnit.getUnitName());
thing.setProperty(PROPERTY_SERIAL, localAirUnit.getUnitSerialNumber());
startPolling();
updateStatus(ThingStatus.ONLINE);
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
@ -107,33 +113,37 @@ public class DanfossAirUnitHandler extends BaseThingHandler {
}
private void updateAllChannels() {
DanfossAirUnit danfossAirUnit = hrv;
if (danfossAirUnit != null) {
logger.debug("Updating DanfossHRV data '{}'", getThing().getUID());
DanfossAirUnit localAirUnit = this.airUnit;
if (localAirUnit == null) {
return;
}
for (Channel channel : Channel.values()) {
if (Thread.interrupted()) {
logger.debug("Polling thread interrupted...");
return;
}
try {
updateState(channel.getGroup().getGroupName(), channel.getChannelName(),
channel.getReadAccessor().access(danfossAirUnit));
} catch (UnexpectedResponseValueException e) {
updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
logger.debug(
"Cannot update channel {}: an unexpected or invalid response has been received from the air unit: {}",
channel.getChannelName(), e.getMessage());
} catch (IOException e) {
updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
logger.debug("Cannot update channel {}: an error occurred retrieving the value: {}",
channel.getChannelName(), e.getMessage());
}
logger.debug("Updating DanfossHRV data '{}'", getThing().getUID());
for (Channel channel : Channel.values()) {
if (Thread.interrupted()) {
logger.debug("Polling thread interrupted...");
return;
}
if (getThing().getStatus() == ThingStatus.OFFLINE) {
updateStatus(ThingStatus.ONLINE);
try {
updateState(channel.getGroup().getGroupName(), channel.getChannelName(),
channel.getReadAccessor().access(localAirUnit));
if (getThing().getStatus() == ThingStatus.OFFLINE) {
updateStatus(ThingStatus.ONLINE);
}
} catch (UnexpectedResponseValueException e) {
updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
logger.debug(
"Cannot update channel {}: an unexpected or invalid response has been received from the air unit: {}",
channel.getChannelName(), e.getMessage());
if (getThing().getStatus() == ThingStatus.OFFLINE) {
updateStatus(ThingStatus.ONLINE);
}
} catch (IOException e) {
updateState(channel.getGroup().getGroupName(), channel.getChannelName(), UnDefType.UNDEF);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
logger.debug("Cannot update channel {}: an error occurred retrieving the value: {}",
channel.getChannelName(), e.getMessage());
}
}
}
@ -142,19 +152,37 @@ public class DanfossAirUnitHandler extends BaseThingHandler {
public void dispose() {
logger.debug("Disposing Danfoss HRV handler '{}'", getThing().getUID());
if (pollingJob != null) {
pollingJob.cancel(true);
pollingJob = null;
}
stopPolling();
if (hrv != null) {
hrv.cleanUp();
hrv = null;
this.airUnit = null;
DanfossAirUnitCommunicationController localCommunicationController = this.communicationController;
if (localCommunicationController != null) {
localCommunicationController.disconnect();
}
this.communicationController = null;
}
private synchronized void startPolling() {
this.pollingJob = scheduler.scheduleWithFixedDelay(this::updateAllChannels, POLLING_INTERVAL_SECONDS,
config.refreshInterval, TimeUnit.SECONDS);
}
private synchronized void stopPolling() {
ScheduledFuture<?> localPollingJob = this.pollingJob;
if (localPollingJob != null) {
localPollingJob.cancel(true);
}
this.pollingJob = null;
}
private void updateState(String groupId, String channelId, State state) {
if (valueCache.updateValue(channelId, state)) {
ValueCache cache = valueCache;
if (cache == null) {
return;
}
if (cache.updateValue(channelId, state)) {
updateState(new ChannelUID(thing.getUID(), groupId, channelId), state);
}
}

View File

@ -0,0 +1,156 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.danfossairunit.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.openhab.binding.danfossairunit.internal.Commands.*;
import java.io.IOException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.test.java.JavaTest;
/**
* This class provides test cases for {@link DanfossAirUnit}
*
* @author Jacob Laursen - Refactoring, bugfixes and enhancements
*/
public class DanfossAirUnitTest extends JavaTest {
private CommunicationController communicationController;
@BeforeEach
private void setUp() {
this.communicationController = mock(CommunicationController.class);
}
@Test
public void getUnitNameIsReturned() throws IOException {
byte[] response = new byte[] { (byte) 0x05, (byte) 'w', (byte) '2', (byte) '/', (byte) 'a', (byte) '2' };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, UNIT_NAME)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals("w2/a2", airUnit.getUnitName());
}
@Test
public void getHumidityWhenNearestNeighborIsBelowRoundsDown() throws IOException {
byte[] response = new byte[] { (byte) 0x64 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, HUMIDITY)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(new QuantityType<>("39.2 %"), airUnit.getHumidity());
}
@Test
public void getHumidityWhenNearestNeighborIsAboveRoundsUp() throws IOException {
byte[] response = new byte[] { (byte) 0x67 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, HUMIDITY)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(new QuantityType<>("40.4 %"), airUnit.getHumidity());
}
@Test
public void getSupplyTemperatureWhenNearestNeighborIsBelowRoundsDown()
throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x09, (byte) 0xf0 }; // 0x09f0 = 2544 => 25.44
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(new QuantityType<>("25.4 °C"), airUnit.getSupplyTemperature());
}
@Test
public void getSupplyTemperatureWhenBothNeighborsAreEquidistantRoundsUp()
throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x09, (byte) 0xf1 }; // 0x09f1 = 2545 => 25.45
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(new QuantityType<>("25.5 °C"), airUnit.getSupplyTemperature());
}
@Test
public void getSupplyTemperatureWhenBelowValidRangeThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x94, (byte) 0xf8 }; // 0x94f8 = -27400 => -274
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getSupplyTemperature());
}
@Test
public void getSupplyTemperatureWhenAboveValidRangeThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x27, (byte) 0x11 }; // 0x2711 = 10001 => 100,01
when(this.communicationController.sendRobustRequest(REGISTER_4_READ, SUPPLY_TEMPERATURE)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getSupplyTemperature());
}
@Test
public void getCurrentTimeWhenWellFormattedIsParsed() throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x03, (byte) 0x02, (byte) 0x0f, (byte) 0x1d, (byte) 0x08, (byte) 0x15 }; // 29.08.21
// 15:02:03
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, CURRENT_TIME)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(new DateTimeType(ZonedDateTime.of(2021, 8, 29, 15, 2, 3, 0, ZoneId.systemDefault())),
airUnit.getCurrentTime());
}
@Test
public void getCurrentTimeWhenInvalidDateThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x03, (byte) 0x02, (byte) 0x0f, (byte) 0x20, (byte) 0x08, (byte) 0x15 }; // 32.08.21
// 15:02:03
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, CURRENT_TIME)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getCurrentTime());
}
@Test
public void getBoostWhenZeroIsOff() throws IOException {
byte[] response = new byte[] { (byte) 0x00 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, BOOST)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(OnOffType.OFF, airUnit.getBoost());
}
@Test
public void getBoostWhenNonZeroIsOn() throws IOException {
byte[] response = new byte[] { (byte) 0x66 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, BOOST)).thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(OnOffType.ON, airUnit.getBoost());
}
@Test
public void getManualFanStepWhenWithinValidRangeIsConvertedIntoPercent()
throws IOException, UnexpectedResponseValueException {
byte[] response = new byte[] { (byte) 0x05 };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP))
.thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertEquals(new PercentType(50), airUnit.getManualFanStep());
}
@Test
public void getManualFanStepWhenOutOfRangeThrows() throws IOException {
byte[] response = new byte[] { (byte) 0x0b };
when(this.communicationController.sendRobustRequest(REGISTER_1_READ, MANUAL_FAN_SPEED_STEP))
.thenReturn(response);
var airUnit = new DanfossAirUnit(communicationController);
assertThrows(UnexpectedResponseValueException.class, () -> airUnit.getManualFanStep());
}
}