diff --git a/src/main/java/com/ghgande/j2mod/modbus/io/ModbusSerialTransport.java b/src/main/java/com/ghgande/j2mod/modbus/io/ModbusSerialTransport.java index 7abd4cf0..4c00d916 100644 --- a/src/main/java/com/ghgande/j2mod/modbus/io/ModbusSerialTransport.java +++ b/src/main/java/com/ghgande/j2mod/modbus/io/ModbusSerialTransport.java @@ -54,23 +54,22 @@ public abstract class ModbusSerialTransport extends AbstractModbusTransport { static final int FRAME_END = 2000; /** - * The number of nanoseconds there is in a millisecond + * The number of nanoseconds in a millisecond */ - private static final int NS_IN_A_MS = 1_000_000; + private static final double NS_IN_A_MS = 1_000_000.0; /** - * The number of nanoseconds there is in a second + * The number of microseconds in a second. */ - private static final int NS_IN_A_SEC = 1_000_000_000; - - private static final String CANNOT_READ_FROM_SERIAL_PORT = "Cannot read from serial port"; - private static final String COMM_PORT_IS_NOT_VALID_OR_NOT_OPEN = "Comm port is not valid or not open"; + private static final double MICROS_IN_A_SEC = 1_000_000.0; /** - * Historical calibration factors, for Transmission wait timing. + * The number of nanoseconds in a second */ - private static final double LONG_DELAY_FUDGE_FACTOR = 1.7; - private static final double SHORT_DELAY_FUDGE_FACTOR = 1.3; + private static final double NS_IN_A_SEC = 1_000_000_000.0; + + private static final String CANNOT_READ_FROM_SERIAL_PORT = "Cannot read from serial port"; + private static final String COMM_PORT_IS_NOT_VALID_OR_NOT_OPEN = "Comm port is not valid or not open"; private AbstractSerialConnection commPort; boolean echo = false; // require RS-485 echo processing @@ -108,47 +107,6 @@ public void writeRequest(ModbusRequest msg) throws ModbusIOException { writeMessage(msg); } - /** - * Waits for transmissionTimeNanos time period to elapse beginning from the startTime. - * This wait measurement is approximate, based on the OS clock and will depend on whether - * the OS uses a real-time clock (RTC). For sub-millisecond periods this method uses a - * tight loop to check against the OS clock which in of itself can be an expensive CPU call. - * - * @param startTime Time to start the period from - * @param transmissionTimeNanos Number of milliseconds to wait - */ - private void waitForTransmission(long startTime, double transmissionTimeNanos) { - if (transmissionTimeNanos >= NS_IN_A_MS) { - try { - final long adjustedDelay = (long) (transmissionTimeNanos * LONG_DELAY_FUDGE_FACTOR); - final long sleepMillis = adjustedDelay / NS_IN_A_MS; - final int sleepNanos = (int) (adjustedDelay % NS_IN_A_MS); - - Thread.sleep(sleepMillis, sleepNanos); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.debug("nothing to do. Sleep interrupted.", e); - } - } - else if (transmissionTimeNanos > 0) { - // For delays less than a millisecond, we need to chew CPU cycles unfortunately - // There are some fiddle factors here to allow for some oddities in the hardware - final int priority = Thread.currentThread().getPriority(); - try { - Thread.currentThread().setPriority(Thread.MIN_PRIORITY); - final long adjustedDelay = (long) (transmissionTimeNanos * SHORT_DELAY_FUDGE_FACTOR); - final long end = startTime + adjustedDelay; - while (System.nanoTime() < end) { - // noop - } - } - finally { - Thread.currentThread().setPriority(priority); - } - } - } - /** * Writes the request/response message to the port * @@ -160,12 +118,11 @@ private void writeMessage(ModbusMessage msg) throws ModbusIOException { notifyListenersBeforeWrite(msg); try { writeMessageOut(msg); - final long startTime = System.nanoTime(); // Wait here for the message to have been sent final double charactersPerSecond = commPort.getBaudRate() / commPort.getBitsPerCharacter(); - final double transmissionTimeNanos = NS_IN_A_SEC * msg.getOutputLength() / charactersPerSecond; - waitForTransmission(startTime, transmissionTimeNanos); + final double transmissionTimeNanos = (msg.getOutputLength() / charactersPerSecond) * NS_IN_A_SEC; + SerialTransmissionWaitUtils.waitForTransmission(transmissionTimeNanos); } finally { notifyListenersAfterWrite(msg); @@ -637,7 +594,7 @@ void waitBetweenFrames(int transDelayMS, long lastTransactionTimestamp) { int delay = getInterFrameDelay() / 1000; // How long since the last message we received - long gapSinceLastMessage = (System.nanoTime() - lastTransactionTimestamp) / NS_IN_A_MS; + final long gapSinceLastMessage = (long) ((System.nanoTime() - lastTransactionTimestamp) / NS_IN_A_MS); if (delay > gapSinceLastMessage) { long sleepTime = delay - gapSinceLastMessage; diff --git a/src/main/java/com/ghgande/j2mod/modbus/io/SerialTransmissionWaitUtils.java b/src/main/java/com/ghgande/j2mod/modbus/io/SerialTransmissionWaitUtils.java new file mode 100644 index 00000000..e336a2ba --- /dev/null +++ b/src/main/java/com/ghgande/j2mod/modbus/io/SerialTransmissionWaitUtils.java @@ -0,0 +1,162 @@ +/* + * Copyright 2002-2016 jamod & j2mod development teams + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ghgande.j2mod.modbus.io; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; + +/** + * Utility class for waiting for serial transmission to complete. + */ +class SerialTransmissionWaitUtils { + + private static final Logger logger = LoggerFactory.getLogger(SerialTransmissionWaitUtils.class); + + private static final String PROP_EXTRA_US = "j2mod.serial.tx.fudge.extra.us"; + private static final String ENV_EXTRA_US = "J2MOD_SERIAL_TX_FUDGE_EXTRA_US"; + + /** + * The number of nanoseconds in a millisecond + */ + private static final double NS_IN_A_MS = 1_000_000.0; + /** + * Minimum sleep duration in nanoseconds. + * Below this, only busy-waiting is accurate. + */ + private static final long SLEEP_MIN_NS = 3_000_000L; + + /** + * Safety buffer subtracted from sleep time + * so the thread wakes up early and finishes precision timing via LockSupport.parkNanos() or busy-waiting. + */ + private static final long SLEEP_MARGIN_NS = 800_000L; + + /** + * Threshold for using LockSupport.parkNanos() instead of busy-waiting. + * Below this, busy-waiting is more accurate. + */ + private static final long PARK_THRESHOLD_NS = 50_000L; + + private static final long LONG_SHORT_WAIT_THRESHOLD_NS = 5_000_000L; + + private static final double WAIT_FOR_TRANSMISSION_FUDGE_FACTOR = 0.22; + private static final double WAIT_FOR_TRANSMISSION_FUDGE_EXPONENT = 0.96; + private static final long WAIT_FOR_TRANSMISSION_FUDGE_MARGIN_NS; + + public static final long WAIT_FOR_TRANSMISSION_MIN_FUDGE_NS; + public static final long WAIT_FOR_TRANSMISSION_MAX_FUDGE_NS; + + /** + * Calculates a realistic wait time threshold (in nanoseconds) by adding an empirical + * overhead ("fudge factor") to the theoretical transmission duration. + *
+ * Standard baud-rate calculations account mainly for wire time and can miss OS scheduling, + * USB/driver latency, and adapter switching delays. This non-linear power-law model was + * empirically tuned from logic-analyzer measurements on constrained Linux hardware + * (BeagleBone Black, USB-to-RS485) across many runs and baud rates. + * + * @param theoreticalTransmissionTimeNs The baseline calculated transmission time in nanoseconds. + * @return recommended total wait time in nanoseconds; conservative for tested edge-device conditions + * @throws IllegalArgumentException if the theoretical transmission time is negative + */ + static long calcFudgedWaitTimeNs(double theoreticalTransmissionTimeNs) { + if (theoreticalTransmissionTimeNs < 0) { + throw new IllegalArgumentException("Theoretical transmission time must be non-negative."); + } + final double fudgeCalc = WAIT_FOR_TRANSMISSION_FUDGE_MARGIN_NS // + + WAIT_FOR_TRANSMISSION_FUDGE_FACTOR // + * Math.pow(theoreticalTransmissionTimeNs, WAIT_FOR_TRANSMISSION_FUDGE_EXPONENT); + final double fudgeValue = Math.max(WAIT_FOR_TRANSMISSION_MIN_FUDGE_NS, Math.min(fudgeCalc, WAIT_FOR_TRANSMISSION_MAX_FUDGE_NS)); + return Math.round(theoreticalTransmissionTimeNs + fudgeValue); + } + + static { + final long EXTRA_OFFSET_US = resolveExtraOffsetUs(); + long extraOffsetNs = TimeUnit.MICROSECONDS.toNanos(EXTRA_OFFSET_US); + + WAIT_FOR_TRANSMISSION_FUDGE_MARGIN_NS = 700_000L + extraOffsetNs; + WAIT_FOR_TRANSMISSION_MIN_FUDGE_NS = 800_000L + extraOffsetNs; + WAIT_FOR_TRANSMISSION_MAX_FUDGE_NS = 3_000_000L + extraOffsetNs; + } + + /** + * Resolves the extra offset. + */ + private static long resolveExtraOffsetUs() { + String rawValue = System.getProperty(PROP_EXTRA_US); + String source = "JVM property (" + PROP_EXTRA_US + ")"; + + if (rawValue == null || rawValue.trim().isEmpty()) { + rawValue = System.getenv(ENV_EXTRA_US); + source = "Env Var (" + ENV_EXTRA_US + ")"; + } + + if (rawValue == null || rawValue.trim().isEmpty()) { + return 0L; + } + + try { + return Long.parseLong(rawValue.trim()); + } catch (NumberFormatException e) { + logger.warn("Failed to parse long from {} value '{}'. Defaulting to 0 us.", source, rawValue); + return 0L; + } + } + + /** + * Waits for the calculated transmission time, using a combination of sleep, park, and busy-waiting. + * + * @param transmissionTimeNanos The theoretical transmission time in nanoseconds. + */ + @SuppressWarnings("StatementWithEmptyBody") + public static void waitForTransmission(double transmissionTimeNanos) { + if (transmissionTimeNanos <= 0) { + return; + } + + final long fudgedWaitTimeNs = calcFudgedWaitTimeNs(transmissionTimeNanos); + final long targetEndNanos = System.nanoTime() + fudgedWaitTimeNs; + + try { + long remainingNanos = targetEndNanos - System.nanoTime(); + if (remainingNanos >= (SLEEP_MIN_NS + SLEEP_MARGIN_NS)) { + final long sleepMargin = fudgedWaitTimeNs > LONG_SHORT_WAIT_THRESHOLD_NS ? 0 : SLEEP_MARGIN_NS; + long sleepMillis = (long) ((remainingNanos - sleepMargin) / NS_IN_A_MS); + Thread.sleep(sleepMillis); + } + remainingNanos = targetEndNanos - System.nanoTime(); + if (remainingNanos > PARK_THRESHOLD_NS * 2) { + LockSupport.parkNanos(remainingNanos - PARK_THRESHOLD_NS); + } + while (System.nanoTime() < targetEndNanos) { + // Pure busy wait, for precision. + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("waitForTransmission interrupted.", e); + } catch (RuntimeException ex) { + logger.debug("waitForTransmission failed with exception.", ex); + } + } + + private SerialTransmissionWaitUtils() { + // Private constructor to prevent instantiation + } + +} diff --git a/src/test/java/com/ghgande/j2mod/modbus/io/SerialTransmissionWaitUtilsTest.java b/src/test/java/com/ghgande/j2mod/modbus/io/SerialTransmissionWaitUtilsTest.java new file mode 100644 index 00000000..b229465c --- /dev/null +++ b/src/test/java/com/ghgande/j2mod/modbus/io/SerialTransmissionWaitUtilsTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2016 jamod & j2mod development teams + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ghgande.j2mod.modbus.io; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class SerialTransmissionWaitUtilsTest { + + private static final long MIN_EXPECTED_FUDGE_NS = 800_000L; + private static final long MAX_EXPECTED_FUDGE_NS = 3_000_000L; + + @Test + public void testCalcFudgedWaitTimeNs() { + long previousFudge = 0; + for (long i = 0; i <= 10_000_000; i += 10_000) { + long waitTimeNs = SerialTransmissionWaitUtils.calcFudgedWaitTimeNs(i); + assertFudgedWaitTimeInRange(i, waitTimeNs, previousFudge); + previousFudge = waitTimeNs - i; + } + } + + private void assertFudgedWaitTimeInRange(long theoreticalWaitTimeNs, long fudgedWaitTimeNs, long previousFudge) { + final long fudge = fudgedWaitTimeNs - theoreticalWaitTimeNs; + + assertTrue("Fudged has to scale with waitTime", fudge >= previousFudge); + assertTrue("Fudged has to never undershoot", fudgedWaitTimeNs >= theoreticalWaitTimeNs); + assertTrue("Fudge should add at least " + MIN_EXPECTED_FUDGE_NS + " ns", fudge >= MIN_EXPECTED_FUDGE_NS); + assertTrue("Fudge should add at most " + MAX_EXPECTED_FUDGE_NS + " ns", fudge <= MAX_EXPECTED_FUDGE_NS); + } + + @Test + public void testCalcFudgedWaitTimeNsWithNegativeInput() { + assertThrows(IllegalArgumentException.class, () -> SerialTransmissionWaitUtils.calcFudgedWaitTimeNs(-1)); + } + +}