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