yeg
2026-08-26 db2eb9412ddc4ff687eb4c9b862053f49f9aec7b
feat(device): 完善 BTS 只读协议与模拟器
3个文件已添加
8个文件已修改
667 ■■■■■ 已修改文件
ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryAvailability.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsDeviceAdapterContractTest.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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");
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
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;
    }
}
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);
        }
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java
New file
@@ -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;
    }
}
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);
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryAvailability.java
New file
@@ -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");
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsDeviceAdapterContractTest.java
New file
@@ -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\"/>";
    }
}
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"));
        }
    }
}
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
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)
    {
    }
}