From 1c26cdb81070af7f29df4462d3bfc1ae588ff8f3 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 30 八月 2026 14:11:34 +0800
Subject: [PATCH] 模拟器优化

---
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java                         |  179 ++++++++++++++++++++++---
 ruoyi-admin/src/main/resources/static/dev/bts-simulator.html                                         |   15 +
 ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java                          |   22 +++
 ruoyi-system/pom.xml                                                                                 |    4 
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java |   19 ++
 ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java                    |   41 +++++
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java     |   23 ++
 ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java                |   62 ++++++++
 8 files changed, 332 insertions(+), 33 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
index 1733bf7..f151ea6 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
+++ b/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);
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
index 855f830..3938123 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
@@ -79,7 +79,10 @@
     {
         try
         {
-            simulator.addDevice(command.ip(), command.channelCount());
+            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) { }
 
diff --git a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
index 23fca3a..47f4a13 100644
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ b/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); }
     });
 
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
index 7cec568..e2b2284 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
+++ b/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))
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
index 5c4a71b..1af43b7 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
+++ b/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) { }
 
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
index 58a90fe..a950af0 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
+++ b/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) { }
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
index 83c40aa..df11a42 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
+++ b/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);
diff --git a/ruoyi-system/pom.xml b/ruoyi-system/pom.xml
index fc5bc9b..64855f9 100644
--- a/ruoyi-system/pom.xml
+++ b/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>
 

--
Gitblit v1.7.1