package com.ruoyi.device.bts; import java.io.IOException; import java.io.StringReader; import java.time.Instant; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamReader; import com.ruoyi.device.model.DeviceWorkStatus; /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */ public final class BtsDevelopmentSimulator implements AutoCloseable { public static final String HOST = "127.0.0.1"; public static final int DEFAULT_PORT = 15020; private static final int DEVICE_TYPE = 22; private static final int SUB_DEVICE_ID = 1; private static final int MAX_CHANNEL_COUNT = 99; private static final int MAX_RECENT_REQUESTS = 20; private static final int TOMBSTONE_DISCOVERY_COUNT = 3; private final ScriptedBtsTcpSimulator simulator; private final Map devices = new LinkedHashMap<>(); private final Map tombstones = new LinkedHashMap<>(); private final Deque recentRequests = new ArrayDeque<>(); private int nextDeviceId = 1; private boolean running = true; /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */ public BtsDevelopmentSimulator(int port) throws IOException { addDeviceInternal("127.0.0.2", 4); simulator = ScriptedBtsTcpSimulator.responding(port, this::respond); } public int port() { return simulator.port(); } /** 新增一台具有独立虚拟 IP 和通道数的开发设备。 */ public synchronized int addDevice(String ip, int channelCount) { validateDeviceConfiguration(ip, channelCount, null); return addDeviceInternal(ip, channelCount); } /** 修改开发设备的虚拟地址、通道数和在线状态。 */ public synchronized void updateDevice(int deviceId, String ip, int channelCount, boolean online) { DeviceState current = requireDevice(deviceId); validateDeviceConfiguration(ip, channelCount, deviceId); List channels = new ArrayList<>(channelCount); for (int channelId = 1; channelId <= channelCount; channelId++) { channels.add(channelId <= current.channels().size() ? current.channels().get(channelId - 1) : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); } for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++) addTombstone(current.deviceId(), current.ip(), channelId); for (int channelId = 1; channelId <= channelCount; channelId++) tombstones.remove(tombstoneKey(deviceId, channelId)); devices.put(deviceId, new DeviceState(deviceId, ip.trim(), online, List.copyOf(channels))); } /** 删除指定开发设备;后续发现响应不再返回它。 */ public synchronized void deleteDevice(int deviceId) { DeviceState removed = devices.remove(deviceId); if (removed == null) throw new IllegalArgumentException("Unknown simulator device: " + deviceId); for (ChannelState channel : removed.channels()) addTombstone(removed.deviceId(), removed.ip(), channel.channelId()); } /** 修改指定开发设备的单个通道状态。 */ public synchronized void updateChannel(int deviceId, int channelId, boolean online, DeviceWorkStatus workStatus) { requireControllableStatus(workStatus); DeviceState device = requireDevice(deviceId); if (channelId < 1 || channelId > device.channels().size()) throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size()); List channels = new ArrayList<>(device.channels()); channels.set(channelId - 1, new ChannelState(channelId, online, workStatus)); devices.put(deviceId, new DeviceState(deviceId, device.ip(), device.online(), List.copyOf(channels))); } /** 返回控制页需要的运行状态快照。 */ public synchronized StateSnapshot snapshot() { List deviceSnapshots = devices.values().stream() .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), device.channels().stream() .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(), channel.workStatus())) .toList())) .toList(); return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, List.copyOf(recentRequests)); } private int addDeviceInternal(String ip, int channelCount) { int deviceId = nextDeviceId++; List channels = new ArrayList<>(channelCount); for (int channelId = 1; channelId <= channelCount; channelId++) channels.add(new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); devices.put(deviceId, new DeviceState(deviceId, ip.trim(), true, List.copyOf(channels))); return deviceId; } private String respond(String request) { String command = readCommand(request); recordRequest(command, request); return switch (command) { case "getdevinfo" -> deviceInfoResponse(); case "inquire" -> observationResponse(); default -> "error"; }; } private synchronized void recordRequest(String command, String request) { recentRequests.addFirst(new RecentRequest(Instant.now(), command, request)); while (recentRequests.size() > MAX_RECENT_REQUESTS) recentRequests.removeLast(); } private String readCommand(String request) { try { XMLInputFactory factory = XMLInputFactory.newFactory(); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request)); while (reader.hasNext()) { if (reader.next() == XMLStreamConstants.START_ELEMENT && "cmd".equals(reader.getLocalName())) return reader.getElementText().trim(); } } catch (Exception exception) { throw new IllegalArgumentException("Invalid BTS request XML", exception); } throw new IllegalArgumentException("BTS request does not contain cmd"); } private synchronized String deviceInfoResponse() { int channelCount = devices.values().stream().mapToInt(device -> device.channels().size()).sum() + tombstones.size(); StringBuilder response = new StringBuilder("getdevinfo_resp") .append(""); for (DeviceState device : devices.values()) { for (ChannelState channel : device.channels()) { boolean available = device.online() && channel.online(); appendDiscoveredChannel(response, device.deviceId(), device.ip(), channel.channelId(), available); } } for (Map.Entry entry : new ArrayList<>(tombstones.entrySet())) { ChannelTombstone tombstone = entry.getValue(); appendDiscoveredChannel(response, tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), false); if (tombstone.remainingDiscoveries() <= 1) tombstones.remove(entry.getKey()); else tombstones.put(entry.getKey(), new ChannelTombstone(tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), tombstone.remainingDiscoveries() - 1)); } return response.append("").toString(); } private void appendDiscoveredChannel(StringBuilder response, int deviceId, String ip, int channelId, boolean available) { // 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。 response.append("") .append(available).append(""); } private synchronized String observationResponse() { List available = new ArrayList<>(); for (DeviceState device : devices.values()) for (ChannelState channel : device.channels()) if (device.online() && channel.online()) available.add(new ObservedChannel(device.deviceId(), channel)); StringBuilder response = new StringBuilder("inquire_resp"); for (ObservedChannel observed : available) { ChannelState channel = observed.channel(); response.append(""); } return response.append("").toString(); } private String toBtsWorkStatus(DeviceWorkStatus status) { return switch (status) { case STOPPED -> "stop"; case WORKING -> "working"; case PAUSED -> "pause"; case FINISHED -> "finish"; default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status); }; } private void validateDeviceConfiguration(String ip, int channelCount, Integer currentDeviceId) { if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator device IP must be a valid IPv4 address"); if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT) throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT); boolean duplicate = devices.values().stream() .anyMatch(device -> device.ip().equals(ip.trim()) && device.deviceId() != (currentDeviceId == null ? -1 : currentDeviceId)); if (duplicate) throw new IllegalArgumentException("Simulator device IP must be unique"); } private boolean isIpv4(String ip) { if (ip == null) return false; String[] parts = ip.trim().split("\\.", -1); if (parts.length != 4) return false; for (String part : parts) { try { int value = Integer.parseInt(part); if (part.isEmpty() || value < 0 || value > 255) return false; } catch (NumberFormatException exception) { return false; } } return true; } private DeviceState requireDevice(int deviceId) { DeviceState device = devices.get(deviceId); if (device == null) throw new IllegalArgumentException("Unknown simulator device: " + deviceId); return device; } private void addTombstone(int deviceId, String ip, int channelId) { tombstones.put(tombstoneKey(deviceId, channelId), new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT)); } private String tombstoneKey(int deviceId, int channelId) { return deviceId + ":" + channelId; } private String simulatorMac(int devtype, int devid) { return String.format("02:44:%02X:%02X:%02X:%02X", (devtype >>> 8) & 0xFF, devtype & 0xFF, (devid >>> 8) & 0xFF, devid & 0xFF); } private void requireControllableStatus(DeviceWorkStatus status) { if (status == null || (status != DeviceWorkStatus.STOPPED && status != DeviceWorkStatus.WORKING && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED)) throw new IllegalArgumentException("Simulator work status must be STOPPED, WORKING, PAUSED or FINISHED"); } @Override public synchronized void close() throws Exception { running = false; simulator.close(); } private record DeviceState(int deviceId, String ip, boolean online, List channels) { } private record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { } private record ObservedChannel(int deviceId, ChannelState channel) { } private record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { } public record StateSnapshot(boolean running, String listenAddress, List devices, List recentRequests) { } public record DeviceSnapshot(int deviceId, String ip, boolean online, List channels) { } public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { } public record RecentRequest(Instant receivedAt, String command, String request) { } }