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<Integer, DeviceState> devices = new LinkedHashMap<>();
|
private final Map<String, ChannelTombstone> tombstones = new LinkedHashMap<>();
|
private final Deque<RecentRequest> 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<ChannelState> 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<ChannelState> 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<DeviceSnapshot> 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<ChannelState> 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 -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
|
};
|
}
|
|
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("<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>")
|
.append("<serverip count=\"0\"/><middle count=\"").append(channelCount).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<String, ChannelTombstone> 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("</middle></bts>").toString();
|
}
|
|
private void appendDiscoveredChannel(StringBuilder response, int deviceId, String ip, int channelId,
|
boolean available)
|
{
|
// 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。
|
response.append("<channel ip=\"").append(ip).append("\" devtype=\"").append(DEVICE_TYPE)
|
.append("\" devid=\"").append(deviceId).append("\" subdevid=\"").append(SUB_DEVICE_ID)
|
.append("\" Channelid=\"").append(channelId).append("\" name=\"dev-sim 设备 ")
|
.append(deviceId).append("\" manufacturer=\"开发模拟器\" model=\"BTS Virtual Cabinet\" mac=\"")
|
.append(simulatorMac(DEVICE_TYPE, deviceId)).append("\">")
|
.append(available).append("</channel>");
|
}
|
|
private synchronized String observationResponse()
|
{
|
List<ObservedChannel> 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("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"")
|
.append(available.size()).append("\">");
|
for (ObservedChannel observed : available)
|
{
|
ChannelState channel = observed.channel();
|
response.append("<inquire dev=\"").append(DEVICE_TYPE).append('-').append(observed.deviceId())
|
.append('-').append(SUB_DEVICE_ID).append('-').append(channel.channelId())
|
.append("-0\" cycle_id=\"1\" step_id=\"1\" step_type=\"cc\" workstatus=\"")
|
.append(toBtsWorkStatus(channel.workStatus()))
|
.append("\" current=\"0\" voltage=\"3.700\" capacity=\"0\" energy=\"0\" ")
|
.append("totaltime=\"0\" relativetime=\"0\" open_or_close=\"0\" log_code=\"0\"/>");
|
}
|
return response.append("</list></bts>").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<ChannelState> 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<DeviceSnapshot> devices,
|
List<RecentRequest> recentRequests) { }
|
|
public record DeviceSnapshot(int deviceId, String ip, boolean online, List<ChannelSnapshot> channels) { }
|
|
public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { }
|
|
public record RecentRequest(Instant receivedAt, String command, String request) { }
|
}
|