ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
@@ -14,6 +14,7 @@ import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.device.simulator.BtsSimulatorStateStore; import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile; import com.ruoyi.device.model.DeviceWorkStatus; /** 将开发模拟器完整状态写入 Redis,主状态不设置普通 TTL。 */ @@ -266,8 +267,20 @@ JSONObject object = jsonObject(value); List<ChannelState> channels = new ArrayList<>(); requiredArray(object, "channels").forEach(item -> channels.add(channelState(item))); BtsDeviceProfile profile = object.containsKey("profile") ? deviceProfile(object.getJSONObject("profile")) : null; return new DeviceState(requiredInteger(object, "deviceId"), object.getString("ip"), object.getBooleanValue("online"), channels); object.getBooleanValue("online"), channels, profile); } private BtsDeviceProfile deviceProfile(JSONObject object) { if (object == null) throw new IllegalStateException("Invalid BTS simulator device profile"); return new BtsDeviceProfile(requiredInteger(object, "devtype"), requiredInteger(object, "subDeviceId"), object.getString("name"), object.getString("manufacturer"), object.getString("model"), object.getString("serialNumber"), object.getString("firmwareVersion"), requiredInteger(object, "channelCount"), requiredDecimal(object, "currentMinA"), requiredDecimal(object, "currentMaxA"), requiredDecimal(object, "voltageMinV"), requiredDecimal(object, "voltageMaxV"), requiredInteger(object, "sampleIntervalMs")); } private List<CollectorMeasurementRecord> embeddedCollectorHistory(JSONObject object) @@ -405,6 +418,13 @@ return value; } private BigDecimal requiredDecimal(JSONObject object, String field) { BigDecimal value = object.getBigDecimal(field); if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field); return value; } private Instant requiredInstant(JSONObject object, String field) { Instant value = optionalInstant(object, field); ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
@@ -79,7 +79,10 @@ { try { if (command.model() == null || command.model().isBlank()) simulator.addDevice(command.ip(), command.channelCount()); else simulator.addNewareDevice(command.ip(), command.model()); return simulator.snapshot(); } catch (IllegalArgumentException exception) @@ -95,6 +98,8 @@ try { simulator.updateDevice(deviceId, command.ip(), command.channelCount(), command.online()); if (command.model() != null && !command.model().isBlank()) simulator.configureNewareProfile(deviceId, command.model()); return simulator.snapshot(); } catch (IllegalArgumentException exception) @@ -251,11 +256,23 @@ return new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception); } public record CreateDeviceCommand(String ip, int channelCount) { } public record CreateDeviceCommand(String ip, int channelCount, String model) { public CreateDeviceCommand(String ip, int channelCount) { this(ip, channelCount, null); } } public record SimulatorPowerCommand(Boolean poweredOn) { } public record DeviceStateCommand(String ip, int channelCount, boolean online) { } public record DeviceStateCommand(String ip, int channelCount, boolean online, String model) { public DeviceStateCommand(String ip, int channelCount, boolean online) { this(ip, channelCount, online, null); } } public record ChannelStateCommand(boolean online, DeviceWorkStatus workStatus) { } ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -96,6 +96,7 @@ <form id="add-device" class="add-form"> <label><span>虚拟 IP</span><input id="new-ip" type="text" value="127.0.0.3" required></label> <label><span>通道数</span><input id="new-channel-count" type="number" min="1" max="99" value="4" required></label> <label><span>设备 profile</span><select id="new-device-model"><option value="">开发虚拟柜</option><option value="CT-4008T-5V6A-S1-F">新威 CT-4008T-5V6A</option><option value="CT-4008Tn-5V12A-S1-F">新威 CT-4008Tn-5V12A</option></select></label> <button type="submit">添加检测柜</button> </form> </section> @@ -186,7 +187,7 @@ heading.textContent = `检测柜 ${device.deviceId}`; const identity = document.createElement('span'); identity.className = 'label'; identity.textContent = `devtype 22 / devid ${device.deviceId}`; identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / ${device.profile.model}`; title.append(heading, identity); const settings = document.createElement('div'); @@ -195,6 +196,12 @@ const channelCount = field('通道数', 'number', device.channels.length); channelCount.input.min = '1'; channelCount.input.max = '99'; const model = document.createElement('label'); model.innerHTML = '<span>设备 profile</span>'; const modelInput = document.createElement('select'); modelInput.innerHTML = '<option value="">开发虚拟柜</option><option value="CT-4008T-5V6A-S1-F">新威 CT-4008T-5V6A</option><option value="CT-4008Tn-5V12A-S1-F">新威 CT-4008Tn-5V12A</option>'; modelInput.value = device.profile.model.startsWith('CT-4008T') ? device.profile.model : ''; model.appendChild(modelInput); const onlineLabel = document.createElement('label'); onlineLabel.className = 'switch-label'; const online = document.createElement('input'); @@ -208,7 +215,7 @@ save.type = 'button'; save.textContent = '保存配置'; save.addEventListener('click', async () => { try { render(await request(`/devices/${device.deviceId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) })); } try { render(await request(`/devices/${device.deviceId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked, model: modelInput.value || null }) })); } catch (error) { showError(error); } }); const remove = document.createElement('button'); @@ -219,7 +226,7 @@ try { render(await request(`/devices/${device.deviceId}`, { method: 'DELETE' })); } catch (error) { showError(error); } }); settings.append(ip.label, channelCount.label, onlineLabel, save, remove); settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove); const table = document.createElement('table'); table.innerHTML = '<thead><tr><th style="width:25%">通道</th><th style="width:30%">在线状态</th><th>工作状态</th></tr></thead>'; @@ -484,7 +491,7 @@ document.querySelector('#add-device').addEventListener('submit', async event => { event.preventDefault(); try { render(await request('/devices', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-ip').value, channelCount: Number(document.querySelector('#new-channel-count').value) }) })); render(await request('/devices', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-ip').value, channelCount: Number(document.querySelector('#new-channel-count').value), model: document.querySelector('#new-device-model').value || null }) })); } catch (error) { showError(error); } }); ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
@@ -70,6 +70,25 @@ } @Test void controllerCreatesNewareDeviceFromExplicitModelProfile() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) { BtsDevelopmentSimulatorController controller = controller(simulator); var created = controller.createDevice(new BtsDevelopmentSimulatorController.CreateDeviceCommand( "127.0.0.8", 0, "CT-4008T-5V6A-S1-F")); var device = created.devices().stream().filter(item -> item.ip().equals("127.0.0.8")) .findFirst().orElseThrow(); assertEquals(8, device.channels().size()); assertEquals("新威", device.profile().manufacturer()); assertEquals("CT-4008T-5V6A-S1-F", device.profile().model()); assertEquals(100, device.profile().sampleIntervalMs()); } } @Test void controllerConfiguresCollectorsSeparatelyFromCabinets() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
@@ -2,6 +2,7 @@ import java.io.IOException; import java.io.StringReader; import java.math.BigDecimal; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -20,6 +21,7 @@ import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile; import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest; import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState; import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore; @@ -36,9 +38,9 @@ public static final String ADAPTER_CODE = "dev-sim"; public static final String ADAPTER_NAME = "dev-sim BTS 模拟器"; public static final int POLL_INTERVAL_MS = 5000; public static final String NEWARE_CT_4008T_5V6A_S1_F = "CT-4008T-5V6A-S1-F"; public static final String NEWARE_CT_4008TN_5V12A_S1_F = "CT-4008Tn-5V12A-S1-F"; 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; @@ -47,6 +49,7 @@ private final BtsSimulatorStateStore stateStore; private SimulatorState state; private boolean running = true; private long observationSequence; /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */ public BtsDevelopmentSimulator(int port) throws IOException @@ -75,7 +78,7 @@ validateDeviceConfiguration(ip, channelCount, null); int deviceId = state.nextDeviceId(); List<DeviceState> devices = new ArrayList<>(state.devices()); devices.add(newDevice(deviceId, ip, channelCount)); devices.add(newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount))); persist(new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, devices, state.collectors(), deviceId + 1, state.nextCollectorId(), state.tombstones())); return deviceId; @@ -93,13 +96,35 @@ ? current.channels().get(channelId - 1) : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); } List<DeviceState> devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels)); List<DeviceState> devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels, resizeProfile(current.profile(), channelCount))); List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones()); for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++) upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId); tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() <= channelCount); persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(), state.nextCollectorId(), tombstones)); } /** 使用现场确认的新威检测柜 profile 新增设备,型号之外的控制语义仍保持只读。 */ public synchronized int addNewareDevice(String ip, String model) { BtsDeviceProfile profile = newareProfile(model); validateDeviceConfiguration(ip, profile.channelCount(), null); int deviceId = state.nextDeviceId(); List<DeviceState> devices = new ArrayList<>(state.devices()); devices.add(newDevice(deviceId, ip, profile)); persist(new SimulatorState(state.version(), devices, state.collectors(), deviceId + 1, state.nextCollectorId(), state.tombstones())); return deviceId; } /** 将已有模拟设备切换为新威 profile,并按 profile 通道数调整拓扑。 */ public synchronized void configureNewareProfile(int deviceId, String model) { DeviceState current = requireDevice(deviceId); BtsDeviceProfile profile = newareProfile(model); updateDeviceWithProfile(current, profile); } /** 删除指定开发设备;后续发现响应不再返回它。 */ @@ -126,7 +151,7 @@ List<ChannelState> channels = new ArrayList<>(device.channels()); channels.set(channelId - 1, new ChannelState(channelId, online, workStatus)); persist(new SimulatorState(state.version(), replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels)), state.collectors(), replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(), state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); } @@ -194,7 +219,7 @@ device.channels().stream() .map(channel -> new ChannelState(channel.channelId(), poweredOn, DeviceWorkStatus.STOPPED)) .toList())) .toList(), device.profile())) .toList(); Instant changedAt = Instant.now(); List<CollectorState> collectors = state.collectors().stream() @@ -247,7 +272,7 @@ public synchronized StateSnapshot snapshot() { List<DeviceSnapshot> deviceSnapshots = state.devices().stream() .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), device.profile(), device.channels().stream() .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(), channel.workStatus())) @@ -281,10 +306,15 @@ private DeviceState newDevice(int deviceId, String ip, int channelCount) { List<ChannelState> channels = new ArrayList<>(channelCount); for (int channelId = 1; channelId <= channelCount; channelId++) return newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount)); } private DeviceState newDevice(int deviceId, String ip, BtsDeviceProfile profile) { List<ChannelState> channels = new ArrayList<>(profile.channelCount()); for (int channelId = 1; channelId <= profile.channelCount(); channelId++) channels.add(new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); return new DeviceState(deviceId, ip.trim(), true, channels); return new DeviceState(deviceId, ip.trim(), true, channels, profile); } private CollectorState newCollector(int collectorId, String ip, int channelCount) @@ -309,7 +339,7 @@ return switch (command) { case "getdevinfo" -> deviceInfoResponse(); case "inquire" -> observationResponse(); case "inquire" -> observationResponse(request); default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>"; }; } @@ -376,34 +406,134 @@ 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("\">") DeviceState device = state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst().orElse(null); BtsDeviceProfile profile = device == null ? BtsDeviceProfile.virtual(channelId) : profileFor(device); response.append("<channel ip=\"").append(ip).append("\" devtype=\"").append(profile.devtype()) .append("\" devid=\"").append(deviceId).append("\" subdevid=\"").append(profile.subDeviceId()) .append("\" Channelid=\"").append(channelId).append("\" name=\"").append(profile.name()) .append("\" manufacturer=\"").append(profile.manufacturer()).append("\" model=\"") .append(profile.model()).append("\" serialNumber=\"").append(profile.serialNumber()) .append("\" firmwareVersion=\"").append(profile.firmwareVersion()).append("\" mac=\"") .append(simulatorMac(profile.devtype(), deviceId)).append("\">") .append(available).append("</channel>"); } private synchronized String observationResponse() private synchronized String observationResponse(String request) { long tick = ++observationSequence; java.util.Set<String> requested = requestedChannels(request); List<ObservedChannel> available = new ArrayList<>(); for (DeviceState device : state.devices()) for (ChannelState channel : device.channels()) if (device.online() && channel.online()) available.add(new ObservedChannel(device.deviceId(), channel)); if (device.online() && channel.online() && (requested.isEmpty() || requested.contains(device.deviceId() + ":" + channel.channelId()))) 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()) DeviceState device = requireDevice(observed.deviceId()); BtsDeviceProfile profile = profileFor(device); boolean working = channel.workStatus() == DeviceWorkStatus.WORKING; double wave = Math.sin((tick + channel.channelId()) * 0.7d); double current = working ? clamp(profile.currentMaxA().doubleValue() * (0.18d + wave * 0.02d), profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d; double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(), profile.voltageMaxV().doubleValue()); double capacity = working ? Math.max(0d, tick * 0.001d * current) : 0d; double energy = capacity * voltage; response.append("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId()) .append('-').append(profile.subDeviceId()).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\"/>"); .append("\" current=\"").append(decimal(current)).append("\" voltage=\"") .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity)) .append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"") .append(working ? tick : 0).append("\" relativetime=\"").append(working ? tick : 0) .append("\" open_or_close=\"0\" log_code=\"0\"/>"); } return response.append("</list></bts>").toString(); } private java.util.Set<String> requestedChannels(String request) { java.util.Set<String> channels = new java.util.HashSet<>(); 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 && "inquire".equals(reader.getLocalName())) { String devid = reader.getAttributeValue(null, "devid"); String chlid = reader.getAttributeValue(null, "chlid"); if (devid != null && chlid != null) channels.add(devid + ":" + chlid); } } return channels; } catch (Exception exception) { throw new IllegalArgumentException("Invalid BTS inquire XML", exception); } } private BtsDeviceProfile profileFor(DeviceState device) { return device.profile() == null ? BtsDeviceProfile.virtual(device.channels().size()) : device.profile(); } private BtsDeviceProfile resizeProfile(BtsDeviceProfile profile, int channelCount) { return new BtsDeviceProfile(profile.devtype(), profile.subDeviceId(), profile.name(), profile.manufacturer(), profile.model(), profile.serialNumber(), profile.firmwareVersion(), channelCount, profile.currentMinA(), profile.currentMaxA(), profile.voltageMinV(), profile.voltageMaxV(), profile.sampleIntervalMs()); } private void updateDeviceWithProfile(DeviceState current, BtsDeviceProfile profile) { int channelCount = profile.channelCount(); 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)); List<DeviceState> devices = replaceDevice(new DeviceState(current.deviceId(), current.ip(), current.online(), channels, profile)); List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones()); for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++) upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId); tombstones.removeIf(item -> item.deviceId() == current.deviceId() && item.channelId() <= channelCount); persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(), state.nextCollectorId(), tombstones)); } private BtsDeviceProfile newareProfile(String model) { if (NEWARE_CT_4008T_5V6A_S1_F.equals(model)) return new BtsDeviceProfile(22, 1, "新威 CT-4008T", "新威", model, "SIM-CT4008T-5V6A", "BTS7.5.4", 8, new BigDecimal("-6.000"), new BigDecimal("6.000"), BigDecimal.ZERO, new BigDecimal("5.000"), 100); if (NEWARE_CT_4008TN_5V12A_S1_F.equals(model)) return new BtsDeviceProfile(22, 1, "新威 CT-4008Tn", "新威", model, "SIM-CT4008TN-5V12A", "BTS8.0", 8, new BigDecimal("-12.000"), new BigDecimal("12.000"), BigDecimal.ZERO, new BigDecimal("5.000"), 100); throw new IllegalArgumentException("Unsupported Neware simulator model: " + model); } private double clamp(double value, double minimum, double maximum) { return Math.max(minimum, Math.min(maximum, value)); } private String decimal(double value) { return String.format(java.util.Locale.ROOT, "%.3f", value); } private String toBtsWorkStatus(DeviceWorkStatus status) @@ -531,6 +661,8 @@ || !ips.add(device.ip()) || device.channels().isEmpty() || device.channels().size() > MAX_CHANNEL_COUNT) throw new IllegalStateException("Invalid BTS simulator device state in Redis"); if (profileFor(device).channelCount() != device.channels().size()) throw new IllegalStateException("BTS simulator device profile channel count does not match topology"); maximumDeviceId = Math.max(maximumDeviceId, device.deviceId()); for (int index = 0; index < device.channels().size(); index++) { @@ -572,7 +704,8 @@ public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices, List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { } public record DeviceSnapshot(int deviceId, String ip, boolean online, List<ChannelSnapshot> channels) { } public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile, List<ChannelSnapshot> channels) { } public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { } ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
@@ -66,9 +66,20 @@ } } record DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels) record DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels, BtsDeviceProfile profile) { public DeviceState { channels = List.copyOf(channels); } public DeviceState { channels = List.copyOf(channels); profile = profile == null ? BtsDeviceProfile.virtual(channels.size()) : profile; } /** 兼容旧版只保存拓扑和通道状态的模拟设备。 */ public DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels) { this(deviceId, ip, online, channels, BtsDeviceProfile.virtual(channels.size())); } } record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name, @@ -137,6 +148,32 @@ record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { } /** BTS 检测柜的可配置厂商 profile;参数用于发现展示和边界内的演示数据生成。 */ record BtsDeviceProfile(int devtype, int subDeviceId, String name, String manufacturer, String model, String serialNumber, String firmwareVersion, int channelCount, BigDecimal currentMinA, BigDecimal currentMaxA, BigDecimal voltageMinV, BigDecimal voltageMaxV, int sampleIntervalMs) { public BtsDeviceProfile { if (devtype < 0 || subDeviceId < 0 || channelCount < 1 || sampleIntervalMs < 1) throw new IllegalArgumentException("Invalid BTS device profile limits"); if (name == null || name.isBlank() || manufacturer == null || manufacturer.isBlank() || model == null || model.isBlank() || serialNumber == null || serialNumber.isBlank() || firmwareVersion == null || firmwareVersion.isBlank()) throw new IllegalArgumentException("BTS device profile metadata must not be blank"); if (currentMinA == null || currentMaxA == null || voltageMinV == null || voltageMaxV == null || currentMinA.compareTo(currentMaxA) > 0 || voltageMinV.compareTo(voltageMaxV) > 0) throw new IllegalArgumentException("Invalid BTS device profile ranges"); } public static BtsDeviceProfile virtual(int channelCount) { return new BtsDeviceProfile(22, 1, "dev-sim 设备", "开发模拟器", "BTS Virtual Cabinet", "SIM-VIRTUAL", "dev-sim", channelCount, new BigDecimal("-6.000"), new BigDecimal("6.000"), BigDecimal.ZERO, new BigDecimal("5.000"), 100); } } record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { } record RecentRequest(Instant receivedAt, String command, String request) { } ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
@@ -1,6 +1,7 @@ package com.ruoyi.device.contract; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -303,6 +304,67 @@ } @Test void developmentSimulatorSupportsNewareProfilesAndDynamicObservation() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0); BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), Duration.ofSeconds(1), Duration.ofSeconds(1)); DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport, new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong")))) { int tDevice = simulator.addNewareDevice("127.0.0.8", "CT-4008T-5V6A-S1-F"); int tnDevice = simulator.addNewareDevice("127.0.0.9", "CT-4008Tn-5V12A-S1-F"); simulator.updateChannel(tDevice, 1, true, DeviceWorkStatus.WORKING); var discovered = adapter.discoverChannels(); var tChannels = discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.8")).toList(); var tnChannels = discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.9")).toList(); assertEquals(4, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.2")).count()); assertEquals(8, tChannels.size()); assertEquals(8, tnChannels.size()); assertEquals("新威", tChannels.get(0).deviceMetadata().manufacturer()); assertEquals("CT-4008T-5V6A-S1-F", tChannels.get(0).deviceMetadata().model()); assertEquals("CT-4008Tn-5V12A-S1-F", tnChannels.get(0).deviceMetadata().model()); assertEquals(8, simulator.snapshot().devices().stream() .filter(device -> device.deviceId() == tDevice || device.deviceId() == tnDevice) .mapToInt(device -> device.channels().size()).sum() / 2); Set<com.ruoyi.device.model.ChannelIdentity> channel = tChannels.stream() .map(DetectedChannel::identity).filter(identity -> identity.chlid() == 1) .collect(java.util.stream.Collectors.toSet()); var first = adapter.inquire(channel).values().iterator().next(); var second = adapter.inquire(channel).values().iterator().next(); assertEquals(DeviceWorkStatus.WORKING, first.workStatus()); assertNotEquals(first.currentA(), second.currentA()); assertTrue(first.voltageV().doubleValue() >= 0.0 && first.voltageV().doubleValue() <= 5.0); } } @Test void developmentSimulatorPersistsNewareProfileMetadata() throws Exception { InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) { simulator.addNewareDevice("127.0.0.8", "CT-4008Tn-5V12A-S1-F"); } try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store); BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", restored.port(), Duration.ofSeconds(1), Duration.ofSeconds(1)); DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport, new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong")))) { var profileChannels = adapter.discoverChannels().stream() .filter(channel -> channel.serverIp().equals("127.0.0.8")).toList(); assertEquals(8, profileChannels.size()); assertEquals("CT-4008Tn-5V12A-S1-F", profileChannels.get(0).deviceMetadata().model()); assertEquals("新威", profileChannels.get(0).deviceMetadata().manufacturer()); } } @Test void developmentSimulatorPublishesOperatorControlledChannelFacts() throws Exception { try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0); ruoyi-system/pom.xml
@@ -28,6 +28,10 @@ <groupId>com.ruoyi</groupId> <artifactId>ruoyi-common</artifactId> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> </dependency> </dependencies>