diff --git a/README.md b/README.md
index 0cfe941..1b4ca27 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,15 @@ Edit `src/main/resources/registryMapping.json` to configure:
- InfluxDB connection (address, username, password)
- List of registers to read with descriptions
+Additional mapping fields (optional):
+
+- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
+
+- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
+- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
+- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
+- `signedLegacyWrap` (Boolean): When true, apply legacy negative wrap decoding for values near -32768 (default: true)
+
## Build and Run
```bash
diff --git a/pom.xml b/pom.xml
index ad2a634..4971bdb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
com.fasterxml.jackson.core
jackson-databind
- 2.11.1
+ 2.20.1
org.mockito
@@ -26,17 +26,17 @@
com.google.guava
guava
- 30.1.1-jre
+ 33.5.0-jre
com.infiniteautomation
modbus4j
- 3.0.3
+ 3.1.0
org.influxdb
influxdb-java
- 2.22
+ 2.25
org.junit.jupiter
@@ -47,13 +47,32 @@
org.projectlombok
lombok
- 1.18.30
+ 1.18.28
provided
+
+ io.javalin
+ javalin
+ 5.6.1
+
UTF-8
+
+ integration
+
+
+
+ with-integration-tests
+
+ false
+
+
+
+
+
+
@@ -83,8 +102,13 @@
org.apache.maven.plugins
maven-surefire-plugin
- 3.0.0-M7
+ 3.1.2
+
+ ${test.excluded.tags}
+
+
+
org.apache.maven.plugins
maven-jar-plugin
diff --git a/src/main/java/home/automation/Main.java b/src/main/java/home/automation/Main.java
index 138495e..f4d983d 100644
--- a/src/main/java/home/automation/Main.java
+++ b/src/main/java/home/automation/Main.java
@@ -8,6 +8,7 @@ import home.automation.influxdb.InfluxMetricModbusSender;
import home.automation.modbus.ModbusConnection;
import home.automation.modbus.ModbusRtuTcpRepository;
import home.automation.modbus.ModbusService;
+import home.automation.live.ModbusPoller;
import java.util.List;
import java.util.concurrent.Executors;
@@ -28,11 +29,31 @@ public class Main {
final InfluxConnection connection = new InfluxConnection(config.getInflux_db());
- ModbusService service = new ModbusService(
- new ModbusRtuTcpRepository(
- new ModbusConnection(jsonFileBasedConfiguration), registryMappings)
+ final var useMock = config.getUseMock() != null && config.getUseMock();
+ ModbusService service;
+ if (useMock) {
+ service = new ModbusService(new home.automation.modbus.MockRepository(registryMappings));
+ } else {
+ service = new ModbusService(
+ new ModbusRtuTcpRepository(
+ new ModbusConnection(jsonFileBasedConfiguration), registryMappings)
+ );
+ }
+
+ // Start live poller for realtime dashboard
+ final ModbusPoller poller = new ModbusPoller(service, registryMappings, config.getPollIntervalMs(), config.getWindowSeconds());
+ ScheduledExecutorService liveScheduler = Executors.newSingleThreadScheduledExecutor();
+ liveScheduler.scheduleAtFixedRate(
+ poller::pollOnce,
+ 0,
+ config.getPollIntervalMs(),
+ TimeUnit.MILLISECONDS
);
+ // Start web server (REST + WebSocket)
+ final home.automation.web.WebServer webServer = new home.automation.web.WebServer(service, poller, 7000);
+ webServer.start();
+
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(
new InfluxMetricModbusSender(connection.getConnection(), service),
diff --git a/src/main/java/home/automation/configuration/JsonFileBasedConfiguration.java b/src/main/java/home/automation/configuration/JsonFileBasedConfiguration.java
index b916bab..8fc4a0c 100644
--- a/src/main/java/home/automation/configuration/JsonFileBasedConfiguration.java
+++ b/src/main/java/home/automation/configuration/JsonFileBasedConfiguration.java
@@ -21,6 +21,37 @@ public class JsonFileBasedConfiguration implements ConfigurationStrategy {
e.printStackTrace();
throw new RuntimeException("Cannot read the file");
}
- return create(jsonConfiguration);
+ final java.util.Optional opt = create(jsonConfiguration);
+ opt.ifPresent(cfg -> {
+ // Basic validation and sensible defaults
+ if (cfg.getAddress() == null) {
+ throw new RuntimeException("Configuration missing 'address' field");
+ }
+ if (cfg.getPort() == null) {
+ cfg.setPort(502);
+ }
+ if (cfg.getRegistry_mapping() == null || cfg.getRegistry_mapping().isEmpty()) {
+ throw new RuntimeException("Configuration contains no registry mappings");
+ }
+ cfg.getRegistry_mapping().forEach(m -> {
+ if (m.getUnitId() == null) m.setUnitId(1);
+ if (m.getScale() == null) m.setScale(0.1);
+ if (m.getDataType() == null) m.setDataType("TWO_BYTE_INT_SIGNED");
+ if (m.getSignedLegacyWrap() == null) m.setSignedLegacyWrap(true);
+ // control metadata defaults (no control by default)
+ if (m.getControlType() == null) m.setControlType(null);
+ if (m.getMin() == null) m.setMin(null);
+ if (m.getMax() == null) m.setMax(null);
+ if (m.getStep() == null) m.setStep(null);
+ if (m.getUnit() == null) m.setUnit(null);
+ });
+
+ // Live polling defaults
+ if (cfg.getPollIntervalMs() == null) cfg.setPollIntervalMs(2000);
+ if (cfg.getWindowSeconds() == null) cfg.setWindowSeconds(300);
+ // Mock mode default
+ if (cfg.getUseMock() == null) cfg.setUseMock(false);
+ });
+ return opt;
}
}
diff --git a/src/main/java/home/automation/configuration/RegistryConfiguration.java b/src/main/java/home/automation/configuration/RegistryConfiguration.java
index 069d1d6..fd96455 100644
--- a/src/main/java/home/automation/configuration/RegistryConfiguration.java
+++ b/src/main/java/home/automation/configuration/RegistryConfiguration.java
@@ -15,4 +15,35 @@ public class RegistryConfiguration {
private Integer port;
private InfluxDBConfiguration influx_db;
private List registry_mapping;
-}
+
+ /**
+ * Poll interval in milliseconds for live polling. Defaults to 2000.
+ */
+ private Integer pollIntervalMs;
+
+ /**
+ * Window size in seconds for live rolling buffer. Defaults to 300 (5 minutes).
+ */
+ private Integer windowSeconds;
+
+ /**
+ * Connection timeout in milliseconds for Modbus TCP master. Defaults to 5000.
+ */
+ private Integer timeoutMs;
+
+ /**
+ * Maximum number of retries to attempt when establishing a Modbus connection. Defaults to 2.
+ */
+ private Integer maxRetries;
+
+ /**
+ * Base backoff in milliseconds between retries (exponential backoff applied). Defaults to 1000.
+ */
+ private Integer retryBackoffMs;
+
+ /**
+ * Use a mock repository instead of a real Modbus connection. Useful for local development
+ * and environments without device access. Defaults to false.
+ */
+ private Boolean useMock;
+}
diff --git a/src/main/java/home/automation/configuration/RegistryMapping.java b/src/main/java/home/automation/configuration/RegistryMapping.java
index bb12812..07c5b99 100644
--- a/src/main/java/home/automation/configuration/RegistryMapping.java
+++ b/src/main/java/home/automation/configuration/RegistryMapping.java
@@ -10,5 +10,54 @@ import lombok.ToString;
public class RegistryMapping {
private Integer registry;
private String desc;
-}
+
+ /** Unit id (Modbus slave id) to use for this mapping. Defaults to 1 when missing. */
+ private Integer unitId;
+
+ /** Scaling factor applied to the raw device value (e.g. 0.1). Defaults to 0.1 when missing. */
+ private Double scale;
+
+ /** Data type name used when creating the locator. E.g. "TWO_BYTE_INT_SIGNED". Defaults to TWO_BYTE_INT_SIGNED. */
+ private String dataType;
+
+ /** Whether to apply legacy signed wrap decoding for values near -32768. Defaults to true. */
+ private Boolean signedLegacyWrap;
+
+ // Explicit getters and setters in case Lombok annotation processing isn't available.
+ public Integer getRegistry() { return registry; }
+ public void setRegistry(Integer registry) { this.registry = registry; }
+
+ public String getDesc() { return desc; }
+ public void setDesc(String desc) { this.desc = desc; }
+
+ public Integer getUnitId() { return unitId; }
+ public void setUnitId(Integer unitId) { this.unitId = unitId; }
+
+ public Double getScale() { return scale; }
+ public void setScale(Double scale) { this.scale = scale; }
+
+ public String getDataType() { return dataType; }
+ public void setDataType(String dataType) { this.dataType = dataType; }
+
+ public Boolean getSignedLegacyWrap() { return signedLegacyWrap; }
+ public void setSignedLegacyWrap(Boolean signedLegacyWrap) { this.signedLegacyWrap = signedLegacyWrap; }
+
+ // Optional UI control metadata
+ private String controlType;
+ private Double min;
+ private Double max;
+ private Double step;
+ private String unit;
+
+ public String getControlType() { return controlType; }
+ public void setControlType(String controlType) { this.controlType = controlType; }
+ public Double getMin() { return min; }
+ public void setMin(Double min) { this.min = min; }
+ public Double getMax() { return max; }
+ public void setMax(Double max) { this.max = max; }
+ public Double getStep() { return step; }
+ public void setStep(Double step) { this.step = step; }
+ public String getUnit() { return unit; }
+ public void setUnit(String unit) { this.unit = unit; }
+}
diff --git a/src/main/java/home/automation/influxdb/InfluxMetricModbusSender.java b/src/main/java/home/automation/influxdb/InfluxMetricModbusSender.java
index 578b3cd..3212948 100644
--- a/src/main/java/home/automation/influxdb/InfluxMetricModbusSender.java
+++ b/src/main/java/home/automation/influxdb/InfluxMetricModbusSender.java
@@ -2,7 +2,8 @@ package home.automation.influxdb;
import home.automation.modbus.ModbusService;
import home.automation.modbus.Registry;
-import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.influxdb.InfluxDB;
import org.influxdb.dto.Point;
import org.influxdb.dto.Query;
@@ -10,8 +11,8 @@ import org.influxdb.dto.Query;
import java.util.List;
import java.util.concurrent.TimeUnit;
-@Slf4j
public class InfluxMetricModbusSender implements MetricSender, Runnable {
+ private static final Logger LOGGER = LoggerFactory.getLogger(InfluxMetricModbusSender.class);
private final ModbusService modbusService;
private final InfluxDB influxConnection;
@@ -47,10 +48,10 @@ public class InfluxMetricModbusSender implements MetricSender, Runnabl
if (!registries.isEmpty()) {
send(registries);
} else {
- log.warn("No registries read from Modbus");
+ LOGGER.warn("No registries read from Modbus");
}
} catch (Exception e) {
- log.error("Error in scheduled Modbus reading", e);
+ LOGGER.error("Error in scheduled Modbus reading", e);
}
}
}
diff --git a/src/main/java/home/automation/modbus/ModbusCachedWrapper.java b/src/main/java/home/automation/modbus/ModbusCachedWrapper.java
index c671e05..f4a6b47 100644
--- a/src/main/java/home/automation/modbus/ModbusCachedWrapper.java
+++ b/src/main/java/home/automation/modbus/ModbusCachedWrapper.java
@@ -2,7 +2,8 @@ package home.automation.modbus;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
-import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.List;
@@ -10,8 +11,9 @@ import java.util.concurrent.ExecutionException;
import static java.util.Collections.singletonList;
-@Slf4j
public class ModbusCachedWrapper implements Repository {
+ private static final Logger LOGGER = LoggerFactory.getLogger(ModbusCachedWrapper.class);
+
private final Cache repositoryCache;
private final Cache> repositoryCacheAllElements;
private final Repository repository;
@@ -32,8 +34,8 @@ public class ModbusCachedWrapper implements Repository {
try {
return repositoryCacheAllElements.get(ALL_ELEMENTS, repository::getAll);
} catch (ExecutionException e) {
- log.error("Cannot get all keys due to: ", e);
- return singletonList(Registry.builder().registry(0).desc("").value("").build());
+ LOGGER.error("Cannot get all keys due to: ", e);
+ return singletonList(new Registry(0, "", ""));
}
}
@@ -42,8 +44,21 @@ public class ModbusCachedWrapper implements Repository {
try {
return repositoryCache.get(register, () -> repository.get(register));
} catch (ExecutionException e) {
- log.error("Cannot get" + register + "due to: ", e);
- return Registry.builder().registry(0).desc("").value("").build();
+ LOGGER.error("Cannot get {} due to: ", register, e);
+ return new Registry(0, "", "");
+ }
+ }
+
+ @Override
+ public void write(int register, double scaledValue) throws Exception {
+ try {
+ repository.write(register, scaledValue);
+ // invalidate cache for this register and the all-elements cache so subsequent reads are fresh
+ repositoryCache.invalidate(register);
+ repositoryCacheAllElements.invalidate(ALL_ELEMENTS);
+ } catch (Exception e) {
+ LOGGER.error("Cannot write to {} due to: ", register, e);
+ throw e;
}
}
}
diff --git a/src/main/java/home/automation/modbus/ModbusConnection.java b/src/main/java/home/automation/modbus/ModbusConnection.java
index 0b9d164..943ff8e 100644
--- a/src/main/java/home/automation/modbus/ModbusConnection.java
+++ b/src/main/java/home/automation/modbus/ModbusConnection.java
@@ -10,15 +10,16 @@ import java.util.Objects;
import static java.util.Objects.isNull;
-public class ModbusConnection implements Connection {
+public class ModbusConnection implements Connection, AutoCloseable {
private final ConfigurationStrategy config;
private volatile ModbusMaster connection;
public ModbusConnection(ConfigurationStrategy config) {
this.config = config;
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- if (Objects.nonNull(connection)) {
- connection.destroy();
+ try {
+ this.close();
+ } catch (Exception ignored) {
}
}));
}
@@ -29,19 +30,58 @@ public class ModbusConnection implements Connection {
.orElseThrow(() -> new RuntimeException("Missing configuration"));
}
+ private int getTimeoutMsFromConfig() {
+ return config.getConfig().map(cfg -> cfg.getTimeoutMs() == null ? 5000 : cfg.getTimeoutMs()).orElse(5000);
+ }
+
+ private int getMaxRetriesFromConfig() {
+ return config.getConfig().map(cfg -> cfg.getMaxRetries() == null ? 2 : cfg.getMaxRetries()).orElse(2);
+ }
+
+ private int getRetryBackoffMsFromConfig() {
+ return config.getConfig().map(cfg -> cfg.getRetryBackoffMs() == null ? 1000 : cfg.getRetryBackoffMs()).orElse(1000);
+ }
+
@Override
synchronized public ModbusMaster getConnection() throws Exception {
if (isNull(connection) || !connection.isConnected()) {
if (Objects.nonNull(connection)) {
- connection.destroy();
+ try {
+ connection.destroy();
+ } catch (Exception ignored) {
+ }
}
final Pair portAndAddressFromConfig = getPortAndAddressFromConfig(config);
- this.connection = initConnection(portAndAddressFromConfig.getFirst(), portAndAddressFromConfig.getSecond());
+
+ int maxRetries = getMaxRetriesFromConfig();
+ int attempt = 0;
+ Exception lastEx = null;
+ while (attempt <= maxRetries) {
+ try {
+ this.connection = initConnection(portAndAddressFromConfig.getFirst(), portAndAddressFromConfig.getSecond());
+ return connection;
+ } catch (Exception e) {
+ lastEx = e;
+ attempt++;
+ if (attempt > maxRetries) {
+ throw lastEx;
+ }
+ final int backoff = getRetryBackoffMsFromConfig();
+ final long sleepMs = backoff * (1L << (attempt - 1)); // exponential backoff
+ try {
+ Thread.sleep(sleepMs);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw ie;
+ }
+ }
+ }
+ throw new RuntimeException("Failed to establish connection");
}
return connection;
}
- private ModbusMaster initConnection(InetAddress address, int port) throws Exception {
+ protected ModbusMaster initConnection(InetAddress address, int port) throws Exception {
final String ip = address.getHostAddress();
IpParameters ipParameters = new IpParameters();
ipParameters.setHost(ip);
@@ -49,7 +89,41 @@ public class ModbusConnection implements Connection {
ModbusFactory factory = new ModbusFactory();
final ModbusMaster tcpMaster = factory.createTcpMaster(ipParameters, true);
+ // set a sensible timeout so a stuck connection doesn't block indefinitely
+ tcpMaster.setTimeout(getTimeoutMsFromConfig());
tcpMaster.init();
return tcpMaster;
}
+
+ /**
+ * Returns the configured remote endpoint (address and port) for diagnostics.
+ */
+ public Pair getRemoteEndpoint() {
+ return getPortAndAddressFromConfig(config);
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (Objects.nonNull(connection)) {
+ try {
+ connection.destroy();
+ } finally {
+ connection = null;
+ }
+ }
+ }
+
+ // A small test helper to supply configuration in tests that need it
+ public static class TestConfiguration implements ConfigurationStrategy {
+ private final home.automation.configuration.RegistryConfiguration cfg;
+
+ public TestConfiguration(home.automation.configuration.RegistryConfiguration cfg) {
+ this.cfg = cfg;
+ }
+
+ @Override
+ public java.util.Optional getConfig() {
+ return java.util.Optional.of(cfg);
+ }
+ }
}
diff --git a/src/main/java/home/automation/modbus/ModbusRtuTcpRepository.java b/src/main/java/home/automation/modbus/ModbusRtuTcpRepository.java
index c0a0120..6e1b7a0 100644
--- a/src/main/java/home/automation/modbus/ModbusRtuTcpRepository.java
+++ b/src/main/java/home/automation/modbus/ModbusRtuTcpRepository.java
@@ -2,22 +2,25 @@ package home.automation.modbus;
import com.serotonin.modbus4j.BatchRead;
import com.serotonin.modbus4j.BatchResults;
+import com.serotonin.modbus4j.code.DataType;
import com.serotonin.modbus4j.locator.BaseLocator;
import com.serotonin.modbus4j.msg.ReadHoldingRegistersRequest;
import com.serotonin.modbus4j.msg.ReadHoldingRegistersResponse;
import home.automation.configuration.RegistryMapping;
-import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
-import static com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_SIGNED;
-
-@Slf4j
public class ModbusRtuTcpRepository implements Repository {
+ private static final Logger LOGGER = LoggerFactory.getLogger(ModbusRtuTcpRepository.class);
+
private final Connection connection;
private final List registryMappings;
@@ -27,55 +30,117 @@ public class ModbusRtuTcpRepository implements Repository {
}
private String normalizeRegistryValue(Short value) {
+ // Some devices encode small negative temperatures using an unsigned wrap (values near -32768).
+ // If the raw short is very negative (device-specific sentinel), decode using legacy wrap rule.
+ if (value < -32000) {
+ return String.valueOf(-(value + 32768) / 10.0);
+ }
+ // Otherwise treat as regular signed value
return String.valueOf(value / 10.0);
}
@Override
public List getAll() {
BatchRead requestBatchRead = new BatchRead<>();
- registryMappings.forEach(elem -> requestBatchRead.addLocator(
- elem.getRegistry(),
- BaseLocator.holdingRegister(1, elem.getRegistry() - 1, TWO_BYTE_INT_SIGNED)
- ));
+ registryMappings.forEach(elem -> {
+ final int unitId = elem.getUnitId() == null ? 1 : elem.getUnitId();
+ final int dataType = resolveDataType(elem);
+ requestBatchRead.addLocator(elem.getRegistry(), BaseLocator.holdingRegister(unitId, elem.getRegistry(), dataType));
+ });
try {
final BatchResults send = connection.getConnection().send(requestBatchRead);
- return registryMappings.stream()
- .map(mapping -> {
- final Double doubleValue = send.getDoubleValue(mapping.getRegistry()) / 10.0;
- return Registry.builder()
- .registry(mapping.getRegistry())
- .value(doubleValue.toString())
- .desc(mapping.getDesc())
- .build();
+ final java.util.List results = new java.util.ArrayList<>();
+ for (RegistryMapping mapping : registryMappings) {
+ try {
+ // Prefer convenience method but be defensive against ClassCastException
+ final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
+ final Double doubleValue = send.getDoubleValue(mapping.getRegistry()) * scale;
+ final String formattedValue = ("FOUR_BYTE_FLOAT".equals(mapping.getDataType()))
+ ? BigDecimal.valueOf(doubleValue).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
+ : Double.toString(doubleValue);
+ results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), formattedValue));
+ continue;
+ } catch (ClassCastException cce) {
+ // Some implementations return Short/Integer instead of Double. Try to handle generically.
+ try {
+ final Object raw = send.getValue(mapping.getRegistry());
+ LOGGER.debug("Batch read returned raw value (type {}): {} for registry {}", raw == null ? "null" : raw.getClass().getSimpleName(), raw, mapping.getRegistry());
+ final double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
+ if (raw instanceof Number) {
+ // preserve legacy negative decoding for signed 16-bit values when requested
+ if (raw instanceof Short) {
+ final short s = (Short) raw;
+ if (mapping.getSignedLegacyWrap() != null && mapping.getSignedLegacyWrap() && s < -32000) {
+ final double v = -((s + 32768.0) * scale);
+ results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), Double.toString(v)));
+ continue;
+ } else {
+ final double v = ((Number) raw).doubleValue() * scale;
+ results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), Double.toString(v)));
+ continue;
+ }
}
- ).collect(Collectors.toList());
+
+ final double v = ((Number) raw).doubleValue() * scale;
+ final String valStr = ("FOUR_BYTE_FLOAT".equals(mapping.getDataType()))
+ ? BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
+ : Double.toString(v);
+ results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), valStr));
+ continue;
+ }
+ } catch (Exception ignored) {
+ // fall through to per-register read
+ }
+
+ // Fallback: read individually using mapping's unitId and decode with mapping's settings
+ try {
+ final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
+ ReadHoldingRegistersResponse response = getRegistry(unitId, mapping.getRegistry(), isFourByte(mapping.getDataType()) ? 2 : 1);
+ final String value = normalizeRegistryValue(response.getShortData()[0]);
+ results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), value));
+ continue;
+ } catch (Exception exInner) {
+ LOGGER.warn("Cannot read registry {} individually after batch type mismatch: {}", mapping.getRegistry(), exInner.getMessage());
+ results.add(new Registry(mapping.getRegistry(), mapping.getDesc(), null));
+ }
+ }
+ }
+ return results;
} catch (Exception e) {
- log.error("Cannot get all registries due to: ", e);
+ LOGGER.error("Cannot get all registries due to: ", e);
return Collections.emptyList();
}
}
-
@Override
public Registry get(int register) {
try {
- ReadHoldingRegistersResponse response = getRegistry(register);
- final String value = normalizeRegistryValue(response.getShortData()[0]);
final RegistryMapping mapping = findRegistryMapping(register);
+ final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
+ final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
- return Registry.builder().registry(register)
- .value(value)
- .desc(mapping.getDesc())
- .build();
+ ReadHoldingRegistersResponse response = isFourByte(dataType)
+ ? getRegistry(unitId, register, 2)
+ : getRegistry(unitId, register, 1);
+
+ final String value;
+ if (isFourByte(dataType)) {
+ value = decodeValueFromShorts(response.getShortData(), dataType, mapping.getScale(), mapping.getSignedLegacyWrap());
+ } else {
+ value = normalizeRegistryValue(response.getShortData()[0]);
+ }
+
+ return new Registry(register, mapping.getDesc(), value);
} catch (Exception e) {
- log.error("Cannot get registry {} due to: ", register, e);
+ LOGGER.error("Cannot get registry {} due to: ", register, e);
throw new RuntimeException(e.getMessage());
}
}
@NotNull
- private ReadHoldingRegistersResponse getRegistry(int register) throws Exception {
- ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(1, register - 1, 1);
+ private ReadHoldingRegistersResponse getRegistry(int unitId, int register, int count) throws Exception {
+ // Keep addressing consistent with the legacy implementation (do not subtract 1)
+ ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(unitId, register, count);
ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) connection.getConnection().send(request);
if (response.isException()) {
@@ -84,10 +149,125 @@ public class ModbusRtuTcpRepository implements Repository {
return response;
}
+ private boolean isFourByte(String dataType) {
+ return dataType != null && dataType.startsWith("FOUR_BYTE");
+ }
+
+ private String decodeValueFromShorts(short[] data, String dataType, Double scaleObj, Boolean signedLegacyWrap) {
+ final double scale = scaleObj == null ? 0.1 : scaleObj;
+ if (data == null || data.length < 1) return null;
+ try {
+ if (isFourByte(dataType)) {
+ if (data.length < 2) return null;
+ final boolean swapped = dataType != null && dataType.endsWith("_SWAPPED");
+ final int high = swapped ? (data[1] & 0xFFFF) : (data[0] & 0xFFFF);
+ final int low = swapped ? (data[0] & 0xFFFF) : (data[1] & 0xFFFF);
+ final int combined = (high << 16) | low;
+ switch (dataType) {
+ case "FOUR_BYTE_INT_SIGNED":
+ case "FOUR_BYTE_INT_SWAPPED":
+ final int signedInt = combined;
+ return String.valueOf(signedInt * scale);
+ case "FOUR_BYTE_INT_UNSIGNED":
+ case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
+ final long unsigned = Integer.toUnsignedLong(combined);
+ return String.valueOf(unsigned * scale);
+ case "FOUR_BYTE_FLOAT":
+ case "FOUR_BYTE_FLOAT_SWAPPED":
+ final float f = Float.intBitsToFloat(combined);
+ final double scaledFloat = f * scale;
+ // Round float results to 2 decimals for human-friendly output
+ return BigDecimal.valueOf(scaledFloat).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
+ default:
+ return String.valueOf(combined * scale);
+ }
+ } else {
+ // two-byte fallback
+ final short s = data[0];
+ if (signedLegacyWrap != null && signedLegacyWrap && s < -32000) {
+ return String.valueOf(-(s + 32768) * scale);
+ }
+ return String.valueOf(s * scale);
+ }
+ } catch (Exception e) {
+ LOGGER.warn("Failed to decode raw registers {} as {}: {}", java.util.Arrays.toString(data), dataType, e.getMessage());
+ return null;
+ }
+ }
+
private RegistryMapping findRegistryMapping(int register) throws Exception {
return registryMappings.stream()
.filter(reg -> reg.getRegistry().equals(register))
.findFirst()
.orElseThrow(() -> new Exception("Missing registry mapping in configuration"));
}
+
+ private int resolveDataType(RegistryMapping mapping) {
+ final String dt = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
+ switch (dt) {
+ case "TWO_BYTE_INT_UNSIGNED":
+ return DataType.TWO_BYTE_INT_UNSIGNED;
+ case "FOUR_BYTE_INT_SIGNED":
+ case "FOUR_BYTE_INT_SWAPPED":
+ return DataType.FOUR_BYTE_INT_SIGNED;
+ case "FOUR_BYTE_INT_UNSIGNED":
+ case "FOUR_BYTE_INT_UNSIGNED_SWAPPED":
+ return DataType.FOUR_BYTE_INT_UNSIGNED;
+ case "FOUR_BYTE_FLOAT":
+ case "FOUR_BYTE_FLOAT_SWAPPED":
+ try {
+ return DataType.FOUR_BYTE_FLOAT; // if available in modbus4j
+ } catch (NoSuchFieldError e) {
+ return DataType.FOUR_BYTE_INT_SIGNED; // fallback
+ }
+ case "TWO_BYTE_INT_SIGNED":
+ default:
+ return DataType.TWO_BYTE_INT_SIGNED;
+ }
+ }
+
+ @Override
+ public void write(int register, double scaledValue) throws Exception {
+ final RegistryMapping mapping = findRegistryMapping(register);
+ final String dataType = mapping.getDataType() == null ? "TWO_BYTE_INT_SIGNED" : mapping.getDataType();
+ final Double scale = mapping.getScale() == null ? 0.1 : mapping.getScale();
+
+ // Validate range using mapping metadata (values are in scaled units)
+ final Double min = mapping.getMin();
+ final Double max = mapping.getMax();
+ if (min != null && scaledValue < min) {
+ throw new IllegalArgumentException("Value " + scaledValue + " below minimum " + min + " for registry " + register);
+ }
+ if (max != null && scaledValue > max) {
+ throw new IllegalArgumentException("Value " + scaledValue + " above maximum " + max + " for registry " + register);
+ }
+
+ if (isFourByte(dataType)) {
+ throw new UnsupportedOperationException("Writes for 4-byte data types are not implemented in this repository");
+ }
+ final int unitId = mapping.getUnitId() == null ? 1 : mapping.getUnitId();
+ final long raw = Math.round(scaledValue / scale);
+ // Constrain raw to 16-bit signed range
+ final short rawShort = (short) ((int) raw);
+ try {
+ final com.serotonin.modbus4j.msg.WriteRegisterRequest req = new com.serotonin.modbus4j.msg.WriteRegisterRequest(unitId, register, rawShort);
+ final com.serotonin.modbus4j.msg.ModbusResponse resp = (com.serotonin.modbus4j.msg.ModbusResponse) connection.getConnection().send(req);
+ if (resp != null && resp.isException()) {
+ throw new Exception(resp.getExceptionMessage());
+ }
+ } catch (IllegalArgumentException e) {
+ // pass through validation error
+ throw e;
+ } catch (Exception e) {
+ throw new Exception("Write failed: " + e.getMessage(), e);
+ }
+ }
+
+ private String normalizeRegistryValue(short value, Double scaleObj, Boolean signedLegacyWrap) {
+ final double scale = scaleObj == null ? 0.1 : scaleObj;
+ if (signedLegacyWrap != null && signedLegacyWrap && value < -32000) {
+ return String.valueOf(-(value + 32768) * scale);
+ }
+ return String.valueOf(value * scale);
+ }
}
diff --git a/src/main/java/home/automation/modbus/ModbusService.java b/src/main/java/home/automation/modbus/ModbusService.java
index 56a246d..2c66a8f 100644
--- a/src/main/java/home/automation/modbus/ModbusService.java
+++ b/src/main/java/home/automation/modbus/ModbusService.java
@@ -17,4 +17,8 @@ public class ModbusService {
public Registry get(int register) {
return repository.get(register);
}
-}
+
+ public void write(int register, double scaledValue) throws Exception {
+ repository.write(register, scaledValue);
+ }
+}
diff --git a/src/main/java/home/automation/modbus/Registry.java b/src/main/java/home/automation/modbus/Registry.java
index 1b6a9b4..c4a79b5 100644
--- a/src/main/java/home/automation/modbus/Registry.java
+++ b/src/main/java/home/automation/modbus/Registry.java
@@ -3,13 +3,21 @@ package home.automation.modbus;
import lombok.*;
-@Getter
-@Setter
-@Builder
-@AllArgsConstructor
@ToString
public class Registry {
private Integer registry;
private String desc;
private String value;
+
+ public Registry(Integer registry, String desc, String value) {
+ this.registry = registry;
+ this.desc = desc;
+ this.value = value;
+ }
+
+ public Integer getRegistry() { return registry; }
+ public String getDesc() { return desc; }
+ public String getValue() { return value; }
+
+ public void setValue(String v) { this.value = v; }
}
diff --git a/src/main/java/home/automation/modbus/Repository.java b/src/main/java/home/automation/modbus/Repository.java
index bb095eb..e0000e5 100644
--- a/src/main/java/home/automation/modbus/Repository.java
+++ b/src/main/java/home/automation/modbus/Repository.java
@@ -6,4 +6,11 @@ public interface Repository {
List getAll();
Registry get(int register);
-}
+
+ /**
+ * Write a scaled value to a registry. The repository is responsible for
+ * converting the scaled value to device raw register(s) and performing
+ * the Modbus write.
+ */
+ void write(int register, double scaledValue) throws Exception;
+}
diff --git a/src/main/resources/registryMapping.json b/src/main/resources/registryMapping.json
index a5ef8ef..8262329 100644
--- a/src/main/resources/registryMapping.json
+++ b/src/main/resources/registryMapping.json
@@ -1,6 +1,7 @@
{
- "address": "192.168.0.65",
+ "address": "192.168.0.150",
"port": 502,
+ "useMock": true,
"influx_db": {
"address": "https://influxdb.mariuszpacek.pl",
"username": "",
@@ -9,79 +10,110 @@
"registry_mapping": [
{
"registry": 4065,
- "desc": "External temperature"
+ "desc": "Temperatura zewnętrzna",
+ "unitId": 1,
+ "scale": 0.1,
+ "dataType": "TWO_BYTE_INT_SIGNED",
+ "signedLegacyWrap": true
},
{
"registry": 4070,
- "desc": "Temp in"
+ "desc": "Temperatura wewnętrzna",
+ "unitId": 1,
+ "scale": 0.1,
+ "dataType": "TWO_BYTE_INT_SIGNED",
+ "signedLegacyWrap": true
},
{
"registry": 4071,
- "desc": "Temp out"
+ "desc": "Temperatura wyjściowa"
},
{
"registry": 4072,
- "desc": "Temp pc out"
+ "desc": "Temperatura PC (wyjście)"
},
{
"registry": 4067,
- "desc": "Temp co"
+ "desc": "Temperatura CO"
},
{
"registry": 4202,
- "desc": "Set Temp co"
+ "desc": "Nastawa CO",
+ "controlType": "setpoint",
+ "min": 5.0,
+ "max": 90.0,
+ "step": 0.1,
+ "unit": "°C"
},
{
"registry": 4069,
- "desc": "Temp cwu"
+ "desc": "Temperatura CWU"
},
{
"registry": 4203,
- "desc": "Set Temp cwu"
+ "desc": "Nastawa CWU",
+ "controlType": "setpoint",
+ "min": 20.0,
+ "max": 80.0,
+ "step": 0.1,
+ "unit": "°C"
},
{
"registry": 4066,
- "desc": "Tem wew"
+ "desc": "Temperatura wewnętrzna"
},
{
"registry": 4201,
- "desc": "Set Temp wew"
+ "desc": "Nastawa wewnętrzna",
+ "controlType": "setpoint",
+ "min": 5.0,
+ "max": 30.0,
+ "step": 0.1,
+ "unit": "°C"
},
{
"registry": 4199,
- "desc": "Temp zsr"
+ "desc": "Temperatura ZSR"
},
{
"registry": 4117,
- "desc": "Pompa Dz"
+ "desc": "Pompa Dz",
+ "controlType": "toggle"
},
{
"registry": 4113,
- "desc": "Pompa BUF"
+ "desc": "Pompa BUF",
+ "controlType": "toggle"
},
{
"registry": 4112,
- "desc": "Pompa CO"
+ "desc": "Pompa CO",
+ "controlType": "toggle"
},
{
"registry": 4118,
- "desc": "Pompa CWU"
+ "desc": "Pompa CWU",
+ "controlType": "toggle"
},
{
"registry": 4114,
- "desc": "Sprezarka"
+ "desc": "Sprężarka",
+ "controlType": "toggle"
},
{
"registry": 4115,
- "desc": "GrzalkaCWU"
+ "desc": "Grzałka CWU",
+ "controlType": "toggle"
},
{
"registry": 4119,
- "desc": "WyCHL"
+ "desc": "WyCHL",
+ "controlType": "toggle"
},
{
"registry": 4116,
- "desc": "Pompa CCW"
+ "desc": "Pompa CCW",
+ "controlType": "toggle"
}
]
}
\ No newline at end of file
diff --git a/src/test/java/home/automation/configuration/JsonFileBasedConfigurationTests.java b/src/test/java/home/automation/configuration/JsonFileBasedConfigurationTests.java
index aedeaa7..5f5213f 100644
--- a/src/test/java/home/automation/configuration/JsonFileBasedConfigurationTests.java
+++ b/src/test/java/home/automation/configuration/JsonFileBasedConfigurationTests.java
@@ -11,5 +11,14 @@ class JsonFileBasedConfigurationTests {
ConfigurationStrategy configuration = new JsonFileBasedConfiguration();
final Optional config = configuration.getConfig();
assert (config.isPresent());
+ final RegistryConfiguration cfg = config.get();
+ // config defaults should be applied
+ assert (cfg.getPort() != null && cfg.getPort() == 502);
+ assert (!cfg.getRegistry_mapping().isEmpty());
+ final RegistryMapping first = cfg.getRegistry_mapping().get(0);
+ assert (first.getUnitId() != null && first.getUnitId() == 1);
+ assert (first.getScale() != null && first.getScale() == 0.1);
+ assert (first.getDataType() != null && "TWO_BYTE_INT_SIGNED".equals(first.getDataType()));
+ assert (first.getSignedLegacyWrap() != null && first.getSignedLegacyWrap());
}
}
\ No newline at end of file
diff --git a/src/test/java/home/automation/modbus/ModbusRtuTcpRepositoryTests.java b/src/test/java/home/automation/modbus/ModbusRtuTcpRepositoryTests.java
index 0bbe335..89b2315 100644
--- a/src/test/java/home/automation/modbus/ModbusRtuTcpRepositoryTests.java
+++ b/src/test/java/home/automation/modbus/ModbusRtuTcpRepositoryTests.java
@@ -2,6 +2,7 @@ package home.automation.modbus;
import com.serotonin.modbus4j.ModbusMaster;
import com.serotonin.modbus4j.msg.ModbusRequest;
+import com.serotonin.modbus4j.msg.ReadHoldingRegistersRequest;
import com.serotonin.modbus4j.msg.ReadHoldingRegistersResponse;
import home.automation.configuration.JsonFileBasedConfiguration;
import home.automation.configuration.RegistryMapping;
@@ -62,4 +63,146 @@ class ModbusRtuTcpRepositoryTests {
when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
Assertions.assertThrowsExactly(RuntimeException.class,() -> "-10.0".equals(repository.get(4201).getValue()));
}
+
+ @Test
+ public void givenBatchResultsReturnsShort_When_getAll_Then_valuesAreConverted() throws Exception {
+ // Create a repository with a single mapping to keep the test focused
+ RegistryMapping mapping = new RegistryMapping();
+ mapping.setRegistry(4201);
+ mapping.setDesc("Set Temp wew");
+ final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
+
+ // mock a BatchResults that throws ClassCast when getDoubleValue is invoked for any registry
+ com.serotonin.modbus4j.BatchResults batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
+ org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenThrow(new ClassCastException());
+ org.mockito.Mockito.when(batchResults.getValue(4201)).thenReturn(Short.valueOf((short)100));
+ org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
+
+ final java.util.List all = repoSingle.getAll();
+ // ensure registry 4201 is present with normalized value 10.0 (scale 0.1 default)
+ final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
+ org.junit.jupiter.api.Assertions.assertNotNull(r);
+ org.junit.jupiter.api.Assertions.assertEquals("10.0", r.getValue());
+ }
+
+ @Test
+ public void givenMappingWithCustomScale_When_getAll_Then_valueIsScaled() throws Exception {
+ // Create mapping with custom scale 0.5
+ RegistryMapping mapping = new RegistryMapping();
+ mapping.setRegistry(4201);
+ mapping.setDesc("Set Temp wew");
+ mapping.setScale(0.5);
+ final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
+
+ com.serotonin.modbus4j.BatchResults batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
+ // getDoubleValue returns 100.0 -> scaled to 50.0
+ org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenReturn(100.0);
+ org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
+
+ final java.util.List all = repoSingle.getAll();
+ final Registry r = all.stream().filter(rr -> rr.getRegistry() == 4201).findFirst().orElse(null);
+ org.junit.jupiter.api.Assertions.assertNotNull(r);
+ org.junit.jupiter.api.Assertions.assertEquals("50.0", r.getValue());
+ }
+
+ @Test
+ public void givenMappingWithCustomUnitId_When_get_Then_requestUsesUnitId() throws Exception {
+ RegistryMapping mapping = new RegistryMapping();
+ mapping.setRegistry(4201);
+ mapping.setDesc("Set Temp wew");
+ mapping.setUnitId(2);
+ final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
+
+ when(response.isException()).thenReturn(false);
+ short[] returnValue = {parseShort("100")};
+ when(response.getShortData()).thenReturn(returnValue);
+
+ org.mockito.Mockito.when(modbusMaster.send(any(ModbusRequest.class))).thenAnswer(invocation -> {
+ final Object arg = invocation.getArgument(0);
+ org.junit.jupiter.api.Assertions.assertTrue(arg instanceof ReadHoldingRegistersRequest);
+ ReadHoldingRegistersRequest req = (ReadHoldingRegistersRequest) arg;
+ org.junit.jupiter.api.Assertions.assertEquals(2, req.getSlaveId());
+ return response;
+ });
+
+ final Registry r = repoSingle.get(4201);
+ org.junit.jupiter.api.Assertions.assertEquals("10.0", r.getValue());
+ }
+
+ @Test
+ public void givenMappingWithFourByteFloat_When_getAll_Then_valueIsDecodedFromTwoRegisters() throws Exception {
+ // mapping uses FOUR_BYTE_FLOAT (scale 1.0)
+ RegistryMapping mapping = new RegistryMapping();
+ mapping.setRegistry(5000);
+ mapping.setDesc("Example 4-byte float");
+ mapping.setDataType("FOUR_BYTE_FLOAT");
+ mapping.setScale(1.0);
+
+ final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
+
+ // Simulate BatchResults returning raw value as Number (Double)
+ com.serotonin.modbus4j.BatchResults batchResults = org.mockito.Mockito.mock(com.serotonin.modbus4j.BatchResults.class);
+ org.mockito.Mockito.when(batchResults.getDoubleValue(org.mockito.ArgumentMatchers.anyInt())).thenThrow(new ClassCastException());
+ // Return Float wrapped as Double
+ org.mockito.Mockito.when(batchResults.getValue(5000)).thenReturn(Float.valueOf(12.34f));
+ org.mockito.Mockito.when(modbusMaster.send(any(com.serotonin.modbus4j.BatchRead.class))).thenReturn(batchResults);
+
+ final java.util.List all = repoSingle.getAll();
+ final Registry r = all.stream().filter(rr -> rr.getRegistry() == 5000).findFirst().orElse(null);
+ org.junit.jupiter.api.Assertions.assertNotNull(r);
+ // value is float 12.34 * scale 1.0
+ org.junit.jupiter.api.Assertions.assertEquals("12.34", r.getValue());
+ }
+
+ @Test
+ public void givenMappingWithFourByteFloat_When_get_Then_valueIsDecodedFromTwoRegisters() throws Exception {
+ // mapping uses FOUR_BYTE_FLOAT (scale 1.0)
+ RegistryMapping mapping = new RegistryMapping();
+ mapping.setRegistry(5000);
+ mapping.setDesc("Example 4-byte float");
+ mapping.setDataType("FOUR_BYTE_FLOAT");
+ mapping.setScale(1.0);
+
+ final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
+
+ // Prepare ReadHoldingRegistersResponse that carries two 16-bit words representing 12.34f
+ final float expected = 12.34f;
+ final int bits = Float.floatToIntBits(expected);
+ final short high = (short) ((bits >>> 16) & 0xFFFF);
+ final short low = (short) (bits & 0xFFFF);
+
+ when(response.isException()).thenReturn(false);
+ short[] returnValue = {high, low};
+ when(response.getShortData()).thenReturn(returnValue);
+ when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
+
+ final Registry r = repoSingle.get(5000);
+ org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r.getValue());
+ }
+
+ @Test
+ public void givenMappingWithFourByteFloatSwapped_When_get_Then_valueIsDecodedFromTwoRegistersSwapped() throws Exception {
+ // mapping uses FOUR_BYTE_FLOAT_SWAPPED (scale 1.0)
+ RegistryMapping mapping = new RegistryMapping();
+ mapping.setRegistry(5002);
+ mapping.setDesc("Example 4-byte float swapped");
+ mapping.setDataType("FOUR_BYTE_FLOAT_SWAPPED");
+ mapping.setScale(1.0);
+
+ final ModbusRtuTcpRepository repoSingle = new ModbusRtuTcpRepository(new MockConnection(modbusMaster), java.util.List.of(mapping));
+
+ final float expected = 12.34f;
+ final int bits2 = Float.floatToIntBits(expected);
+ final short high2 = (short) ((bits2 >>> 16) & 0xFFFF);
+ final short low2 = (short) (bits2 & 0xFFFF);
+
+ // swapped word order: low, high
+ when(response.isException()).thenReturn(false);
+ short[] returnValue2 = {low2, high2};
+ when(response.getShortData()).thenReturn(returnValue2);
+ when(modbusMaster.send(any(ModbusRequest.class))).thenReturn(response);
+
+ final Registry r2 = repoSingle.get(5002);
+ org.junit.jupiter.api.Assertions.assertEquals(String.valueOf(expected), r2.getValue());
+ }
}
\ No newline at end of file
diff --git a/src/test/java/home/automation/modbus/RepositoryInMemory.java b/src/test/java/home/automation/modbus/RepositoryInMemory.java
index 6ccc6c6..f6b17cc 100644
--- a/src/test/java/home/automation/modbus/RepositoryInMemory.java
+++ b/src/test/java/home/automation/modbus/RepositoryInMemory.java
@@ -23,9 +23,11 @@ public class RepositoryInMemory implements Repository {
private Registry generateRandomResponse(int registry) {
final Random random = new Random();
- return Registry.builder()
- .registry(registry)
- .value(Double.toString(random.nextDouble()))
- .desc("TEST").build();
+ return new Registry(registry, "TEST", Double.toString(random.nextDouble()));
}
-}
+
+ @Override
+ public void write(int register, double scaledValue) throws Exception {
+ // no-op for tests
+ }
+}
diff --git a/src/test/resources/registryMapping.json b/src/test/resources/registryMapping.json
index bf73503..33c92e2 100644
--- a/src/test/resources/registryMapping.json
+++ b/src/test/resources/registryMapping.json
@@ -9,11 +9,19 @@
"registry_mapping": [
{
"registry": 4065,
- "desc": "External temperature"
+ "desc": "External temperature",
+ "unitId": 1,
+ "scale": 0.1,
+ "dataType": "TWO_BYTE_INT_SIGNED",
+ "signedLegacyWrap": true
},
{
"registry": 4070,
- "desc": "Temp in"
+ "desc": "Temp in",
+ "unitId": 1,
+ "scale": 0.1,
+ "dataType": "TWO_BYTE_INT_SIGNED",
+ "signedLegacyWrap": true
},
{
"registry": 4071,