feat(device): 完善 BTS 只读协议与模拟器
| | |
| | | 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"); |
| | |
| | | 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; |
| | | |
| | |
| | | } |
| | | |
| | | @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 |
| | |
| | | { |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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"); |
| | | } |
| | | |
| | |
| | | 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 |
| | | { |
| | |
| | | { |
| | | 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; |
| | | } |
| | | } |
| | | appendPayload(buffer, value); |
| | | } |
| | | } |
| | | |
| | | private void appendPayload(ByteArrayOutputStream buffer, int value) throws ResponseTooLargeException |
| | | { |
| | | if (buffer.size() >= maxResponseBytes) throw new ResponseTooLargeException(); |
| | | buffer.write(value); |
| | | } |
| | | throw new IOException("BTS response exceeded " + maxResponseBytes + " bytes"); |
| | | |
| | | 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 |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | |
| | | { |
| | | 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, |
| | |
| | | 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; |
| | |
| | | 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) |
| | |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | throw new BtsProtocolException("Invalid BTS XML response", ex); |
| | | throw new BtsProtocolException(BtsProtocolException.Reason.MALFORMED_XML, |
| | | "Invalid BTS XML response", ex); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | 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(); |
| | | } |
| | | |
| | |
| | | } |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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"); |
| | | } |
| | | } |
| New file |
| | |
| | | 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\"/>"; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | { |
| | | 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); |
| | |
| | | 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 |
| | | { |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | @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")); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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()); |
| | |
| | | } |
| | | |
| | | @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 |
| | |
| | | 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; |
| | |
| | | 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); |
| | | 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() |
| | |
| | | |
| | | private void serve() |
| | | { |
| | | while (!serverSocket.isClosed()) |
| | | { |
| | | try (Socket socket = serverSocket.accept()) |
| | | { |
| | | serveConnection(socket); |
| | | } |
| | | catch (SocketException ex) |
| | | { |
| | | if (!serverSocket.isClosed()) throw new IllegalStateException(ex); |
| | | } |
| | | catch (Exception ignored) |
| | | { |
| | | } |
| | | } |
| | | } |
| | | |
| | | private void serveConnection(Socket socket) throws Exception |
| | | { |
| | | while (!socket.isClosed()) |
| | | { |
| | | 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)); |
| | | 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(); |
| | | } |
| | | } |
| | | catch (Exception ignored) |
| | | { |
| | | } |
| | | } |
| | | |
| | |
| | | executor.shutdownNow(); |
| | | executor.awaitTermination(2, TimeUnit.SECONDS); |
| | | } |
| | | |
| | | private record ScriptedAction(Duration delay, byte[] payload, boolean disconnect) |
| | | { |
| | | } |
| | | } |