From db2eb9412ddc4ff687eb4c9b862053f49f9aec7b Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 26 八月 2026 13:16:42 +0800
Subject: [PATCH] feat(device): 完善 BTS 只读协议与模拟器

---
 ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java                |   10 
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java        |   34 ++
 ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryAvailability.java        |   21 +
 ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsDeviceAdapterContractTest.java |   91 +++++++
 ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java      |   79 +++++-
 ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java              |   86 ++++++
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java              |   97 +++++++
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java                  |   96 ++++++-
 ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java          |  112 +++++++++
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java             |   10 
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java         |   31 ++
 11 files changed, 626 insertions(+), 41 deletions(-)

diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java b/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java
index ec41e4b..9c9b140 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java
@@ -7,21 +7,31 @@
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
 import com.ruoyi.device.model.DeviceControlCommand;
+import com.ruoyi.device.model.HistoryAvailability;
 import com.ruoyi.device.model.HistoryPage;
 import com.ruoyi.device.model.RealtimeSnapshot;
 
 public interface DeviceAdapter extends AutoCloseable
 {
+    /** 返回适配器实例的稳定代码。 */
     String adapterCode();
 
+    /** 返回运行时已启用的设备能力。 */
     AdapterCapabilities capabilities();
 
+    /** 发现当前端点可见的检测柜通道。 */
     List<DetectedChannel> discoverChannels();
 
+    /** 批量查询通道实时快照,不形成最终原始采集数据。 */
     Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels);
 
+    /** 查询通道当前 BTS 测试的历史数据上传进度。 */
+    List<HistoryAvailability> inquireHistory(Set<ChannelIdentity> channels);
+
+    /** 分页下载单个通道的最终原始历史数据,每页最多 1000 条。 */
     HistoryPage downloadHistory(ChannelIdentity channel, String btsTestId, long startPosition, int limit);
 
+    /** 执行设备控制;只读适配器始终拒绝该调用。 */
     default void control(ChannelIdentity channel, DeviceControlCommand command)
     {
         throw new UnsupportedOperationException("Device control is not enabled for this adapter");
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java
index beaaabb..92b3554 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java
@@ -8,6 +8,7 @@
 import com.ruoyi.device.model.AdapterCapabilities;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.HistoryAvailability;
 import com.ruoyi.device.model.HistoryPage;
 import com.ruoyi.device.model.RealtimeSnapshot;
 
@@ -50,10 +51,17 @@
     }
 
     @Override
+    public List<HistoryAvailability> inquireHistory(Set<ChannelIdentity> channels)
+    {
+        if (channels == null || channels.isEmpty()) return List.of();
+        return codec.parseHistoryAvailability(transport.exchange(codec.inquireHistoryRequest(channels)));
+    }
+
+    @Override
     public HistoryPage downloadHistory(ChannelIdentity channel, String btsTestId, long startPosition, int limit)
     {
         return codec.parseHistory(transport.exchange(codec.downloadRequest(channel, btsTestId, startPosition, limit)),
-                channel, startPosition);
+                channel, btsTestId, startPosition);
     }
 
     @Override
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java
index 3c3ffaa..e619424 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java
@@ -6,13 +6,42 @@
 {
     private static final long serialVersionUID = 1L;
 
+    public enum Reason
+    {
+        UNEXPECTED_COMMAND,
+        MALFORMED_XML,
+        MISSING_FIELD,
+        INVALID_VALUE,
+        IDENTITY_MISMATCH,
+        INVALID_RESPONSE
+    }
+
+    private final Reason reason;
+
     public BtsProtocolException(String message)
     {
-        super(message);
+        this(Reason.INVALID_RESPONSE, message);
     }
 
     public BtsProtocolException(String message, Throwable cause)
     {
+        this(Reason.INVALID_RESPONSE, message, cause);
+    }
+
+    public BtsProtocolException(Reason reason, String message)
+    {
+        super(message);
+        this.reason = reason;
+    }
+
+    public BtsProtocolException(Reason reason, String message, Throwable cause)
+    {
         super(message, cause);
+        this.reason = reason;
+    }
+
+    public Reason reason()
+    {
+        return reason;
     }
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java
index 8c02695..fa9eb5e 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java
@@ -1,21 +1,27 @@
 package com.ruoyi.device.bts;
 
 import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.net.ConnectException;
 import java.net.InetSocketAddress;
 import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.Objects;
 import java.util.concurrent.locks.ReentrantLock;
-import com.ruoyi.device.api.DeviceAdapterException;
 
 public final class BtsTcpTransport implements BtsTransport
 {
     private static final byte[] FRAME_END = "#\r\n".getBytes(StandardCharsets.UTF_8);
     private static final int DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
+    private static final int MAX_RESPONSE_TIMEOUT_MS = 30_000;
 
     private final String host;
     private final int port;
@@ -39,6 +45,9 @@
         this.maxResponseBytes = maxResponseBytes;
         if (host.isBlank() || port < 1 || port > 65535) throw new IllegalArgumentException("Invalid BTS TCP endpoint");
         if (connectTimeoutMs < 1 || responseTimeoutMs < 1) throw new IllegalArgumentException("Timeouts must be positive");
+        // 厂商协议声明单条命令最长不超过约 30 秒,避免串行请求队列被错误配置长期阻塞。
+        if (responseTimeoutMs > MAX_RESPONSE_TIMEOUT_MS)
+            throw new IllegalArgumentException("BTS response timeout must not exceed 30 seconds");
         if (maxResponseBytes < 1) throw new IllegalArgumentException("maxResponseBytes must be positive");
     }
 
@@ -56,10 +65,41 @@
             output.flush();
             return readFrame(socket.getInputStream());
         }
+        catch (ConnectException ex)
+        {
+            closeSocket();
+            throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED,
+                    "BTS TCP connection failed for " + host + ":" + port, ex);
+        }
+        catch (SocketTimeoutException ex)
+        {
+            closeSocket();
+            throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT,
+                    "BTS TCP exchange timed out for " + host + ":" + port, ex);
+        }
+        catch (EOFException ex)
+        {
+            closeSocket();
+            throw new BtsTransportException(BtsTransportException.Reason.DISCONNECTED,
+                    "BTS disconnected before completing a response for " + host + ":" + port, ex);
+        }
+        catch (ResponseTooLargeException ex)
+        {
+            closeSocket();
+            throw new BtsTransportException(BtsTransportException.Reason.RESPONSE_TOO_LARGE,
+                    "BTS response exceeded " + maxResponseBytes + " bytes for " + host + ":" + port, ex);
+        }
+        catch (InvalidUtf8Exception ex)
+        {
+            closeSocket();
+            throw new BtsTransportException(BtsTransportException.Reason.INVALID_UTF8,
+                    "BTS response is not valid UTF-8 for " + host + ":" + port, ex);
+        }
         catch (IOException ex)
         {
             closeSocket();
-            throw new DeviceAdapterException("BTS TCP exchange failed for " + host + ":" + port, ex);
+            throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE,
+                    "BTS TCP exchange failed for " + host + ":" + port, ex);
         }
         finally
         {
@@ -80,24 +120,50 @@
     {
         ByteArrayOutputStream buffer = new ByteArrayOutputStream();
         int matched = 0;
-        while (buffer.size() < maxResponseBytes)
+        while (true)
         {
             int value = input.read();
-            if (value < 0) throw new IOException("BTS closed the connection before the response frame ended");
+            if (value < 0) throw new EOFException("BTS closed the connection before the response frame ended");
             if ((byte) value == FRAME_END[matched])
             {
                 matched++;
-                if (matched == FRAME_END.length) return buffer.toString(StandardCharsets.UTF_8);
+                if (matched == FRAME_END.length) return decodeUtf8(buffer.toByteArray());
                 continue;
             }
             if (matched > 0)
             {
-                buffer.write(FRAME_END, 0, matched);
+                for (int i = 0; i < matched; i++) appendPayload(buffer, FRAME_END[i]);
                 matched = 0;
+                if ((byte) value == FRAME_END[0])
+                {
+                    matched = 1;
+                    continue;
+                }
             }
-            buffer.write(value);
+            appendPayload(buffer, value);
         }
-        throw new IOException("BTS response exceeded " + maxResponseBytes + " bytes");
+    }
+
+    private void appendPayload(ByteArrayOutputStream buffer, int value) throws ResponseTooLargeException
+    {
+        if (buffer.size() >= maxResponseBytes) throw new ResponseTooLargeException();
+        buffer.write(value);
+    }
+
+    private static String decodeUtf8(byte[] bytes) throws InvalidUtf8Exception
+    {
+        try
+        {
+            return StandardCharsets.UTF_8.newDecoder()
+                    .onMalformedInput(CodingErrorAction.REPORT)
+                    .onUnmappableCharacter(CodingErrorAction.REPORT)
+                    .decode(ByteBuffer.wrap(bytes))
+                    .toString();
+        }
+        catch (CharacterCodingException ex)
+        {
+            throw new InvalidUtf8Exception(ex);
+        }
     }
 
     @Override
@@ -129,4 +195,19 @@
             socket = null;
         }
     }
+
+    private static final class ResponseTooLargeException extends IOException
+    {
+        private static final long serialVersionUID = 1L;
+    }
+
+    private static final class InvalidUtf8Exception extends IOException
+    {
+        private static final long serialVersionUID = 1L;
+
+        private InvalidUtf8Exception(Throwable cause)
+        {
+            super(cause);
+        }
+    }
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java
new file mode 100644
index 0000000..f6fe0f8
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java
@@ -0,0 +1,34 @@
+package com.ruoyi.device.bts;
+
+import com.ruoyi.device.api.DeviceAdapterException;
+
+/**
+ * 表示可供调度与恢复逻辑分类处理的 BTS TCP 传输失败。
+ */
+public class BtsTransportException extends DeviceAdapterException
+{
+    private static final long serialVersionUID = 1L;
+
+    public enum Reason
+    {
+        CONNECTION_FAILED,
+        TIMEOUT,
+        DISCONNECTED,
+        RESPONSE_TOO_LARGE,
+        INVALID_UTF8,
+        IO_FAILURE
+    }
+
+    private final Reason reason;
+
+    public BtsTransportException(Reason reason, String message, Throwable cause)
+    {
+        super(message, cause);
+        this.reason = reason;
+    }
+
+    public Reason reason()
+    {
+        return reason;
+    }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java
index 058135b..d0be888 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java
@@ -14,6 +14,7 @@
 import java.util.Map;
 import java.util.Set;
 import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
@@ -21,9 +22,11 @@
 import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 import org.xml.sax.InputSource;
+import org.xml.sax.helpers.DefaultHandler;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
 import com.ruoyi.device.model.DeviceWorkStatus;
+import com.ruoyi.device.model.HistoryAvailability;
 import com.ruoyi.device.model.HistoryPage;
 import com.ruoyi.device.model.HistorySample;
 import com.ruoyi.device.model.Measurement;
@@ -68,6 +71,24 @@
         return envelope(list.append("</list>").toString());
     }
 
+    /**
+     * 查询各通道当前 BTS 测试的历史数据上传进度。
+     */
+    public String inquireHistoryRequest(Set<ChannelIdentity> channels)
+    {
+        StringBuilder list = new StringBuilder("<cmd>inquiredf</cmd><list count=\"").append(channels.size()).append("\">");
+        for (ChannelIdentity channel : channels)
+        {
+            requireOwned(channel);
+            list.append("<chl devtype=\"").append(channel.devtype())
+                    .append("\" devid=\"").append(channel.devid())
+                    .append("\" subdevid=\"").append(channel.subdevid())
+                    .append("\" chlid=\"").append(channel.chlid())
+                    .append("\" testid=\"0\"/>");
+        }
+        return envelope(list.append("</list>").toString());
+    }
+
     public String downloadRequest(ChannelIdentity channel, String btsTestId, long startPosition, int limit)
     {
         requireOwned(channel);
@@ -92,7 +113,7 @@
             ChannelIdentity identity = new ChannelIdentity(adapterCode, integerAttribute(element, "devtype"),
                     integerAttribute(element, "devid"), integerAttribute(element, "subdevid"), chlid);
             channels.add(new DetectedChannel(identity, requiredAttribute(element, "ip"),
-                    Boolean.parseBoolean(element.getTextContent().trim())));
+                    booleanText(element.getTextContent(), "available")));
         }
         return List.copyOf(channels);
     }
@@ -106,7 +127,9 @@
         {
             Element element = (Element) nodes.item(i);
             String[] device = requiredAttribute(element, "dev").split("-");
-            if (device.length < 4) throw new BtsProtocolException("Invalid BTS dev identity: " + element.getAttribute("dev"));
+            if (device.length < 4)
+                throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+                        "Invalid BTS dev identity: " + element.getAttribute("dev"));
             ChannelIdentity identity = new ChannelIdentity(adapterCode, integer(device[0], "devtype"), integer(device[1], "devid"),
                     integer(device[2], "subdevid"), integer(device[3], "chlid"));
             snapshots.put(identity, new RealtimeSnapshot(identity,
@@ -119,11 +142,38 @@
         return Map.copyOf(snapshots);
     }
 
-    public HistoryPage parseHistory(String xml, ChannelIdentity channel, long requestedStart)
+    /**
+     * 将 BTS 历史清单映射为厂商无关的通道历史可用性。
+     */
+    public List<HistoryAvailability> parseHistoryAvailability(String xml)
+    {
+        Document document = parse(xml, "inquiredf_resp");
+        NodeList nodes = document.getElementsByTagName("chl");
+        List<HistoryAvailability> availability = new ArrayList<>(nodes.getLength());
+        for (int i = 0; i < nodes.getLength(); i++)
+        {
+            Element element = (Element) nodes.item(i);
+            ChannelIdentity channel = new ChannelIdentity(adapterCode, integerAttribute(element, "devtype"),
+                    integerAttribute(element, "devid"), integerAttribute(element, "subdevid"),
+                    integerAttribute(element, "chlid"));
+            availability.add(new HistoryAvailability(channel, requiredAttribute(element, "testid"),
+                    longAttribute(element, "count"), booleanText(element.getTextContent(), "uploadComplete")));
+        }
+        return List.copyOf(availability);
+    }
+
+    public HistoryPage parseHistory(String xml, ChannelIdentity channel, String requestedTestId, long requestedStart)
     {
         Document document = parse(xml, "download_resp");
         Element download = firstElement(document, "download");
         String btsTestId = requiredAttribute(download, "testid");
+        if (integerAttribute(download, "devtype") != channel.devtype()
+                || integerAttribute(download, "devid") != channel.devid()
+                || integerAttribute(download, "subdevid") != channel.subdevid()
+                || integerAttribute(download, "chlid") != channel.chlid()
+                || !btsTestId.equals(requestedTestId))
+            throw new BtsProtocolException(BtsProtocolException.Reason.IDENTITY_MISMATCH,
+                    "BTS history response identity does not match the request");
         NodeList nodes = document.getElementsByTagName("data");
         List<HistorySample> samples = new ArrayList<>(nodes.getLength());
         long next = requestedStart;
@@ -153,10 +203,13 @@
             factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
             factory.setXIncludeAware(false);
             factory.setExpandEntityReferences(false);
-            Document document = factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
+            DocumentBuilder builder = factory.newDocumentBuilder();
+            builder.setErrorHandler(new DefaultHandler());
+            Document document = builder.parse(new InputSource(new StringReader(xml)));
             String command = firstElement(document, "cmd").getTextContent().trim();
             if (!expectedCommand.equals(command))
-                throw new BtsProtocolException("Expected BTS command " + expectedCommand + " but received " + command);
+                throw new BtsProtocolException(BtsProtocolException.Reason.UNEXPECTED_COMMAND,
+                        "Expected BTS command " + expectedCommand + " but received " + command);
             return document;
         }
         catch (BtsProtocolException ex)
@@ -165,7 +218,8 @@
         }
         catch (Exception ex)
         {
-            throw new BtsProtocolException("Invalid BTS XML response", ex);
+            throw new BtsProtocolException(BtsProtocolException.Reason.MALFORMED_XML,
+                    "Invalid BTS XML response", ex);
         }
     }
 
@@ -201,7 +255,8 @@
         }
         catch (DateTimeParseException ex)
         {
-            throw new BtsProtocolException("Invalid BTS device time: " + value, ex);
+            throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+                    "Invalid BTS device time: " + value, ex);
         }
     }
 
@@ -223,14 +278,16 @@
     private static Element firstElement(Document document, String tag)
     {
         NodeList elements = document.getElementsByTagName(tag);
-        if (elements.getLength() == 0) throw new BtsProtocolException("Missing BTS element: " + tag);
+        if (elements.getLength() == 0)
+            throw new BtsProtocolException(BtsProtocolException.Reason.MISSING_FIELD, "Missing BTS element: " + tag);
         return (Element) elements.item(0);
     }
 
     private static String requiredAttribute(Element element, String name)
     {
         String value = element.getAttribute(name);
-        if (value == null || value.isBlank()) throw new BtsProtocolException("Missing BTS attribute: " + name);
+        if (value == null || value.isBlank())
+            throw new BtsProtocolException(BtsProtocolException.Reason.MISSING_FIELD, "Missing BTS attribute: " + name);
         return value.trim();
     }
 
@@ -253,7 +310,8 @@
         }
         catch (NumberFormatException ex)
         {
-            throw new BtsProtocolException("Invalid integer BTS attribute: " + name, ex);
+            throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+                    "Invalid integer BTS attribute: " + name, ex);
         }
     }
 
@@ -273,7 +331,8 @@
         }
         catch (NumberFormatException ex)
         {
-            throw new BtsProtocolException("Invalid integer BTS attribute: " + name, ex);
+            throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+                    "Invalid integer BTS attribute: " + name, ex);
         }
     }
 
@@ -285,7 +344,8 @@
         }
         catch (NumberFormatException ex)
         {
-            throw new BtsProtocolException("Invalid integer BTS attribute: " + name, ex);
+            throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+                    "Invalid integer BTS attribute: " + name, ex);
         }
     }
 
@@ -303,7 +363,17 @@
         }
         catch (NumberFormatException ex)
         {
-            throw new BtsProtocolException("Invalid decimal BTS attribute: " + name, ex);
+            throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+                    "Invalid decimal BTS attribute: " + name, ex);
         }
     }
+
+    private static boolean booleanText(String value, String name)
+    {
+        String normalized = value == null ? "" : value.trim();
+        if ("true".equalsIgnoreCase(normalized)) return true;
+        if ("false".equalsIgnoreCase(normalized)) return false;
+        throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+                "Invalid boolean BTS value: " + name);
+    }
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryAvailability.java b/ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryAvailability.java
new file mode 100644
index 0000000..d67d227
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryAvailability.java
@@ -0,0 +1,21 @@
+package com.ruoyi.device.model;
+
+import java.util.Objects;
+
+/**
+ * 表示检测柜通道上一项 BTS 测试当前可下载的历史数据范围。
+ */
+public record HistoryAvailability(
+        ChannelIdentity channel,
+        String btsTestId,
+        long availableRecordCount,
+        boolean uploadComplete)
+{
+    public HistoryAvailability
+    {
+        channel = Objects.requireNonNull(channel, "channel");
+        btsTestId = Objects.requireNonNull(btsTestId, "btsTestId").trim();
+        if (btsTestId.isEmpty()) throw new IllegalArgumentException("btsTestId must not be blank");
+        if (availableRecordCount < 0) throw new IllegalArgumentException("availableRecordCount must be non-negative");
+    }
+}
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsDeviceAdapterContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsDeviceAdapterContractTest.java
new file mode 100644
index 0000000..5137147
--- /dev/null
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsDeviceAdapterContractTest.java
@@ -0,0 +1,91 @@
+package com.ruoyi.device.bts;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.time.Duration;
+import java.time.ZoneId;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import com.ruoyi.device.api.DeviceAdapter;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DeviceControlCommand;
+import com.ruoyi.device.model.DeviceWorkStatus;
+
+class BtsDeviceAdapterContractTest
+{
+    @Test
+    void observesCompleteReadOnlyContractWithoutSendingControlCommands() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(2));
+             DeviceAdapter adapter = new BtsDeviceAdapter("lab-bts", transport,
+                     new BtsXmlCodec("lab-bts", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            ChannelIdentity first = new ChannelIdentity("lab-bts", 22, 3, 2, 8);
+            ChannelIdentity second = new ChannelIdentity("lab-bts", 22, 3, 1, 6);
+            simulator.enqueue("<bts><cmd>getdevinfo_resp</cmd><middle count=\"2\">"
+                    + "<channel ip=\"192.168.1.35\" devtype=\"22\" devid=\"3\" subdevid=\"2\" Channelid=\"8\">true</channel>"
+                    + "<channel ip=\"192.168.1.36\" devtype=\"22\" devid=\"3\" subdevid=\"1\" Channelid=\"6\">true</channel>"
+                    + "</middle></bts>");
+            simulator.enqueue("<bts><cmd>inquire_resp</cmd><list count=\"2\">"
+                    + "<inquire dev=\"22-3-2-8-0\" workstatus=\"working\" voltage=\"3.5\"/>"
+                    + "<inquire dev=\"22-3-1-6-0\" workstatus=\"futureState\" voltage=\"3.6\"/>"
+                    + "</list></bts>");
+            simulator.enqueue("<bts><cmd>inquiredf_resp</cmd><list count=\"2\">"
+                    + "<chl devtype=\"22\" devid=\"3\" subdevid=\"2\" chlid=\"8\" testid=\"11\" count=\"3\">true</chl>"
+                    + "<chl devtype=\"22\" devid=\"3\" subdevid=\"1\" chlid=\"6\" testid=\"10\" count=\"11\">false</chl>"
+                    + "</list></bts>");
+            simulator.enqueue("<bts><cmd>download_resp</cmd>"
+                    + "<download devtype=\"22\" devid=\"3\" subdevid=\"2\" chlid=\"8\" testid=\"11\"/>"
+                    + "<list count=\"3\">"
+                    + historyData(3) + historyData(1) + historyData(3) + "</list></bts>");
+
+            assertEquals(2, adapter.discoverChannels().size());
+            var snapshots = adapter.inquire(Set.of(first, second));
+            var history = adapter.inquireHistory(Set.of(first, second));
+            var page = adapter.downloadHistory(first, "11", 1, 1000);
+
+            assertTrue(adapter.capabilities().discovery());
+            assertTrue(adapter.capabilities().realtimeObservation());
+            assertTrue(adapter.capabilities().historyDownload());
+            assertFalse(adapter.capabilities().control());
+            assertEquals(DeviceWorkStatus.UNKNOWN, snapshots.get(second).workStatus());
+            assertFalse(history.get(1).uploadComplete());
+            assertEquals(4, page.nextPosition());
+            assertEquals(java.util.List.of(3L, 1L, 3L),
+                    page.samples().stream().map(sample -> sample.sequenceId()).toList());
+
+            int observedRequests = simulator.requests().size();
+            for (DeviceControlCommand command : DeviceControlCommand.values())
+                assertThrows(UnsupportedOperationException.class, () -> adapter.control(first, command));
+            assertEquals(observedRequests, simulator.requests().size());
+            assertEquals(4, observedRequests);
+        }
+    }
+
+    @Test
+    void surfacesMalformedResponseScript() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("lab-bts", transport,
+                     new BtsXmlCodec("lab-bts", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            simulator.enqueue("<bts><cmd>getdevinfo_resp</cmd>");
+
+            BtsProtocolException exception = assertThrows(BtsProtocolException.class, adapter::discoverChannels);
+
+            assertEquals(BtsProtocolException.Reason.MALFORMED_XML, exception.reason());
+        }
+    }
+
+    private static String historyData(long sequenceId)
+    {
+        return "<data seqid=\"" + sequenceId + "\" stepid=\"1\" cycleid=\"1\" steptype=\"cc\" "
+                + "atime=\"2026-08-25 18.00.00\" volt=\"3.5\" curr=\"1.2\" cap=\"0.1\" eng=\"0.35\"/>";
+    }
+}
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java
index 595af12..511a6b8 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java
@@ -1,6 +1,7 @@
 package com.ruoyi.device.bts;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import java.time.Duration;
 import java.util.Set;
@@ -23,7 +24,7 @@
             {
                 simulator.enqueue("<bts><cmd>first_resp</cmd></bts>");
                 simulator.enqueue("<bts><cmd>second_resp</cmd></bts>");
-                Callable<String> first = () -> transport.exchange("<bts><cmd>first</cmd></bts>");
+                Callable<String> first = () -> transport.exchange("<bts><cmd>first</cmd><note>检测</note></bts>");
                 Callable<String> second = () -> transport.exchange("<bts><cmd>second</cmd></bts>");
 
                 Future<String> firstResult = callers.submit(first);
@@ -33,6 +34,7 @@
                 assertEquals(Set.of("<bts><cmd>first_resp</cmd></bts>", "<bts><cmd>second_resp</cmd></bts>"), responses);
                 assertEquals(2, simulator.requests().size());
                 assertTrue(simulator.requests().stream().allMatch(request -> request.startsWith("<bts>")));
+                assertTrue(simulator.requests().stream().anyMatch(request -> request.contains("<note>检测</note>")));
             }
             finally
             {
@@ -40,4 +42,112 @@
             }
         }
     }
+
+    @Test
+    void reportsDelayedResponseAsTimeout() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofMillis(50)))
+        {
+            simulator.enqueueDelayed(Duration.ofMillis(200), "<bts><cmd>late_resp</cmd></bts>");
+
+            BtsTransportException exception = assertThrows(BtsTransportException.class,
+                    () -> transport.exchange("<bts><cmd>late</cmd></bts>"));
+
+            assertEquals(BtsTransportException.Reason.TIMEOUT, exception.reason());
+        }
+    }
+
+    @Test
+    void reportsActiveDisconnectAndReconnectsOnNextExchange() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1)))
+        {
+            simulator.enqueueDisconnect();
+
+            BtsTransportException exception = assertThrows(BtsTransportException.class,
+                    () -> transport.exchange("<bts><cmd>first</cmd></bts>"));
+
+            assertEquals(BtsTransportException.Reason.DISCONNECTED, exception.reason());
+
+            simulator.enqueue("<bts><cmd>second_resp</cmd></bts>");
+            assertEquals("<bts><cmd>second_resp</cmd></bts>",
+                    transport.exchange("<bts><cmd>second</cmd></bts>"));
+            assertEquals(2, simulator.requests().size());
+        }
+    }
+
+    @Test
+    void rejectsResponseLargerThanConfiguredLimit() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1), 32))
+        {
+            simulator.enqueue("x".repeat(33));
+
+            BtsTransportException exception = assertThrows(BtsTransportException.class,
+                    () -> transport.exchange("<bts><cmd>large</cmd></bts>"));
+
+            assertEquals(BtsTransportException.Reason.RESPONSE_TOO_LARGE, exception.reason());
+        }
+    }
+
+    @Test
+    void reportsRefusedConnection() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = ScriptedBtsTcpSimulator.refusingConnections();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1)))
+        {
+            BtsTransportException exception = assertThrows(BtsTransportException.class,
+                    () -> transport.exchange("<bts><cmd>getdevinfo</cmd></bts>"));
+
+            assertEquals(BtsTransportException.Reason.CONNECTION_FAILED, exception.reason());
+            assertTrue(simulator.requests().isEmpty());
+        }
+    }
+
+    @Test
+    void rejectsResponseTimeoutAboveProtocolMaximum()
+    {
+        assertThrows(IllegalArgumentException.class,
+                () -> new BtsTcpTransport("127.0.0.1", 502,
+                        Duration.ofSeconds(1), Duration.ofSeconds(31)));
+    }
+
+    @Test
+    void rejectsResponseThatIsNotValidUtf8() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1)))
+        {
+            simulator.enqueueRaw(new byte[] {(byte) 0xC3, (byte) 0x28});
+
+            BtsTransportException exception = assertThrows(BtsTransportException.class,
+                    () -> transport.exchange("<bts><cmd>getdevinfo</cmd></bts>"));
+
+            assertEquals(BtsTransportException.Reason.INVALID_UTF8, exception.reason());
+        }
+    }
+
+    @Test
+    void acceptsResponseAtSizeLimitAndDelimiterPrefixInPayload() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1), 32))
+        {
+            String atLimit = "x".repeat(32);
+            simulator.enqueue(atLimit);
+            simulator.enqueue("#");
+
+            assertEquals(atLimit, transport.exchange("first"));
+            assertEquals("#", transport.exchange("second"));
+        }
+    }
 }
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java
index 8313e7e..2699acd 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java
@@ -3,14 +3,18 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import java.math.BigDecimal;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import org.junit.jupiter.api.Test;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DeviceWorkStatus;
 import com.ruoyi.device.model.HistoryPage;
+import com.ruoyi.device.model.HistoryAvailability;
 import com.ruoyi.device.model.RealtimeSnapshot;
 
 class BtsXmlCodecTest
@@ -29,6 +33,19 @@
         assertEquals(1, channels.size());
         assertEquals(new ChannelIdentity("lab-bts", 22, 1, 2, 3), channels.get(0).identity());
         assertEquals("192.168.1.35", channels.get(0).serverIp());
+    }
+
+    @Test
+    void rejectsInvalidDiscoveryAvailability()
+    {
+        String xml = "<bts><cmd>getdevinfo_resp</cmd><middle count=\"1\">"
+                + "<channel ip=\"192.168.1.35\" devtype=\"22\" devid=\"1\" subdevid=\"2\" Channelid=\"3\">maybe</channel>"
+                + "</middle></bts>";
+
+        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
+                () -> codec.parseDeviceInfo(xml));
+
+        assertEquals(BtsProtocolException.Reason.INVALID_VALUE, exception.reason());
     }
 
     @Test
@@ -63,12 +80,14 @@
     void parsesHistoryPageAndHongKongDeviceTime()
     {
         ChannelIdentity channel = new ChannelIdentity("lab-bts", 24, 1, 1, 1);
-        String xml = "<bts><cmd>download_resp</cmd><download testid=\"42\"/><list count=\"1\">"
+        String xml = "<bts><cmd>download_resp</cmd>"
+                + "<download devtype=\"24\" devid=\"1\" subdevid=\"1\" chlid=\"1\" testid=\"42\"/>"
+                + "<list count=\"1\">"
                 + "<data seqid=\"7\" stepid=\"1\" cycleid=\"2\" steptype=\"cc\" testtime=\"1000\" "
                 + "atime=\"2026-08-25 18.00.00\" volt=\"3.5\" curr=\"1.2\" cap=\"0.1\" eng=\"0.35\" T1=\"25\"/>"
                 + "</list></bts>";
 
-        HistoryPage page = codec.parseHistory(xml, channel, 7);
+        HistoryPage page = codec.parseHistory(xml, channel, "42", 7);
 
         assertEquals("42", page.btsTestId());
         assertEquals(8, page.nextPosition());
@@ -76,11 +95,72 @@
     }
 
     @Test
+    void rejectsHistoryResponseForDifferentChannelOrTest()
+    {
+        ChannelIdentity requested = new ChannelIdentity("lab-bts", 24, 1, 1, 1);
+        String xml = "<bts><cmd>download_resp</cmd>"
+                + "<download devtype=\"24\" devid=\"1\" subdevid=\"1\" chlid=\"2\" testid=\"99\"/>"
+                + "<list count=\"0\"/></bts>";
+
+        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
+                () -> codec.parseHistory(xml, requested, "42", 1));
+
+        assertEquals(BtsProtocolException.Reason.IDENTITY_MISMATCH, exception.reason());
+    }
+
+    @Test
+    void buildsAndParsesHistoryAvailabilityForMultipleChannels()
+    {
+        ChannelIdentity first = new ChannelIdentity("lab-bts", 22, 3, 2, 8);
+        ChannelIdentity second = new ChannelIdentity("lab-bts", 22, 3, 1, 6);
+
+        String request = codec.inquireHistoryRequest(Set.of(first, second));
+        String response = "<bts><cmd>inquiredf_resp</cmd><list count=\"2\">"
+                + "<chl devtype=\"22\" devid=\"3\" subdevid=\"2\" chlid=\"8\" testid=\"11\" count=\"10\">true</chl>"
+                + "<chl devtype=\"22\" devid=\"3\" subdevid=\"1\" chlid=\"6\" testid=\"10\" count=\"11\">false</chl>"
+                + "</list></bts>";
+
+        List<HistoryAvailability> result = codec.parseHistoryAvailability(response);
+
+        assertTrue(request.contains("<cmd>inquiredf</cmd>"));
+        assertTrue(request.contains("devtype=\"22\" devid=\"3\" subdevid=\"2\" chlid=\"8\" testid=\"0\""));
+        assertTrue(request.contains("devtype=\"22\" devid=\"3\" subdevid=\"1\" chlid=\"6\" testid=\"0\""));
+        assertEquals(new HistoryAvailability(first, "11", 10, true), result.get(0));
+        assertEquals(new HistoryAvailability(second, "10", 11, false), result.get(1));
+    }
+
+    @Test
     void rejectsIllegalNumericData()
     {
         String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"22-1-1-1-0\" current=\"bad\"/></list></bts>";
 
-        assertThrows(BtsProtocolException.class, () -> codec.parseSnapshots(xml, Instant.EPOCH));
+        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
+                () -> codec.parseSnapshots(xml, Instant.EPOCH));
+
+        assertEquals(BtsProtocolException.Reason.INVALID_VALUE, exception.reason());
+    }
+
+    @Test
+    void classifiesWrongCommandAndMalformedXml()
+    {
+        BtsProtocolException wrongCommand = assertThrows(BtsProtocolException.class,
+                () -> codec.parseSnapshots("<bts><cmd>download_resp</cmd></bts>", Instant.EPOCH));
+        BtsProtocolException malformed = assertThrows(BtsProtocolException.class,
+                () -> codec.parseSnapshots("<bts><cmd>inquire_resp</cmd>", Instant.EPOCH));
+
+        assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, wrongCommand.reason());
+        assertEquals(BtsProtocolException.Reason.MALFORMED_XML, malformed.reason());
+    }
+
+    @Test
+    void reportsMissingRequiredField()
+    {
+        String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire workstatus=\"working\"/></list></bts>";
+
+        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
+                () -> codec.parseSnapshots(xml, Instant.EPOCH));
+
+        assertEquals(BtsProtocolException.Reason.MISSING_FIELD, exception.reason());
     }
 
     @Test
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java b/ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java
index 2e5b46b..5a61ea5 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java
@@ -5,7 +5,9 @@
 import java.io.InputStream;
 import java.net.ServerSocket;
 import java.net.Socket;
+import java.net.SocketException;
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
 import java.util.List;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.CopyOnWriteArrayList;
@@ -19,24 +21,55 @@
     private static final byte[] FRAME_END = "#\r\n".getBytes(StandardCharsets.UTF_8);
 
     private final ServerSocket serverSocket;
+    private final int port;
     private final ExecutorService executor = Executors.newSingleThreadExecutor();
-    private final BlockingQueue<String> responses = new LinkedBlockingQueue<>();
+    private final BlockingQueue<ScriptedAction> actions = new LinkedBlockingQueue<>();
     private final List<String> requests = new CopyOnWriteArrayList<>();
 
     ScriptedBtsTcpSimulator() throws IOException
     {
+        this(true);
+    }
+
+    private ScriptedBtsTcpSimulator(boolean acceptConnections) throws IOException
+    {
         serverSocket = new ServerSocket(0);
-        executor.submit(this::serve);
+        port = serverSocket.getLocalPort();
+        if (acceptConnections)
+            executor.submit(this::serve);
+        else
+            serverSocket.close();
+    }
+
+    static ScriptedBtsTcpSimulator refusingConnections() throws IOException
+    {
+        return new ScriptedBtsTcpSimulator(false);
     }
 
     int port()
     {
-        return serverSocket.getLocalPort();
+        return port;
     }
 
     void enqueue(String response)
     {
-        responses.add(response);
+        actions.add(new ScriptedAction(Duration.ZERO, response.getBytes(StandardCharsets.UTF_8), false));
+    }
+
+    void enqueueDelayed(Duration delay, String response)
+    {
+        if (delay.isNegative()) throw new IllegalArgumentException("delay must not be negative");
+        actions.add(new ScriptedAction(delay, response.getBytes(StandardCharsets.UTF_8), false));
+    }
+
+    void enqueueRaw(byte[] response)
+    {
+        actions.add(new ScriptedAction(Duration.ZERO, response.clone(), false));
+    }
+
+    void enqueueDisconnect()
+    {
+        actions.add(new ScriptedAction(Duration.ZERO, null, true));
     }
 
     List<String> requests()
@@ -46,21 +79,35 @@
 
     private void serve()
     {
-        try (Socket socket = serverSocket.accept())
+        while (!serverSocket.isClosed())
         {
-            while (!socket.isClosed())
+            try (Socket socket = serverSocket.accept())
             {
-                String request = readFrame(socket.getInputStream());
-                requests.add(request);
-                String response = responses.poll(5, TimeUnit.SECONDS);
-                if (response == null) throw new IOException("No scripted BTS response available");
-                socket.getOutputStream().write(response.getBytes(StandardCharsets.UTF_8));
-                socket.getOutputStream().write(FRAME_END);
-                socket.getOutputStream().flush();
+                serveConnection(socket);
+            }
+            catch (SocketException ex)
+            {
+                if (!serverSocket.isClosed()) throw new IllegalStateException(ex);
+            }
+            catch (Exception ignored)
+            {
             }
         }
-        catch (Exception ignored)
+    }
+
+    private void serveConnection(Socket socket) throws Exception
+    {
+        while (!socket.isClosed())
         {
+            String request = readFrame(socket.getInputStream());
+            requests.add(request);
+            ScriptedAction action = actions.poll(5, TimeUnit.SECONDS);
+            if (action == null) throw new IOException("No scripted BTS action available");
+            TimeUnit.MILLISECONDS.sleep(action.delay().toMillis());
+            if (action.disconnect()) return;
+            socket.getOutputStream().write(action.payload());
+            socket.getOutputStream().write(FRAME_END);
+            socket.getOutputStream().flush();
         }
     }
 
@@ -94,4 +141,8 @@
         executor.shutdownNow();
         executor.awaitTermination(2, TimeUnit.SECONDS);
     }
+
+    private record ScriptedAction(Duration delay, byte[] payload, boolean disconnect)
+    {
+    }
 }

--
Gitblit v1.7.1