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,