yeg
2026-09-02 c2dd019c1fddaeae412e2b6f44fb86ff0dc635d4
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
@@ -2,7 +2,10 @@
import java.io.IOException;
import java.io.StringReader;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import javax.xml.stream.XMLInputFactory;
@@ -12,11 +15,24 @@
import com.ruoyi.device.simulator.BtsSimulatorStateStore;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
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.BtsHistoryRecord;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsPlaybackState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorAdvance;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
public final class BtsDevelopmentSimulator implements AutoCloseable
@@ -26,9 +42,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;
@@ -37,6 +53,11 @@
    private final BtsSimulatorStateStore stateStore;
    private SimulatorState state;
    private boolean running = true;
    private long observationSequence;
    private final java.util.Map<String, java.util.List<BtsHistoryRecord>> btsHistory = new java.util.LinkedHashMap<>();
    private static final long SIMULATED_RECORD_INTERVAL_SECONDS = 60L;
    private static final DateTimeFormatter BTS_HISTORY_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss")
            .withZone(ZoneId.of("Asia/Hong_Kong"));
    /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */
    public BtsDevelopmentSimulator(int port) throws IOException
@@ -49,7 +70,11 @@
    {
        this.stateStore = stateStore;
        state = stateStore.loadOrInitialize(defaultState());
        // 仅清理 Ticket13 之前嵌在主状态中的旧演示历史;独立历史由模拟器存储保留。
        state = stateStore.resetHistoryForDemoStartup(state);
        restoreBtsHistory();
        validateRestoredState();
        stateStore.completeValidatedRestore(state);
        simulator = ScriptedBtsTcpSimulator.responding(port, this::respond);
    }
@@ -64,7 +89,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;
@@ -82,13 +107,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);
    }
    /** 删除指定开发设备;后续发现响应不再返回它。 */
@@ -98,8 +145,16 @@
        List<DeviceState> devices = new ArrayList<>(state.devices());
        devices.removeIf(item -> item.deviceId() == deviceId);
        List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
        BtsDeviceProfile removedProfile = profileFor(removed);
        for (ChannelState channel : removed.channels())
        {
            upsertTombstone(tombstones, removed.deviceId(), removed.ip(), channel.channelId());
            String key = historyKey(removedProfile.devtype(), removed.deviceId(), removedProfile.subDeviceId(),
                    channel.channelId());
            stateStore.deleteBtsHistory(key);
            stateStore.deleteBtsPlaybackState(key);
            btsHistory.remove(key);
        }
        persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
                state.nextCollectorId(), tombstones));
    }
@@ -115,7 +170,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()));
    }
@@ -138,8 +193,7 @@
        CollectorState current = requireCollector(collectorId);
        List<CollectorState> collectors = new ArrayList<>(state.collectors());
        collectors.replaceAll(item -> item.collectorId() == collectorId
                ? new CollectorState(collectorId, current.deviceCode(), ip.trim(), current.mac(), current.name(),
                        current.manufacturer(), current.model(), channelCount, online)
                ? DevelopmentCollectorSimulation.resize(current, ip, channelCount, online, Instant.now())
                : item);
        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones()));
@@ -148,18 +202,327 @@
    /** 删除指定开发采集器,不影响检测柜 TCP 发现结果。 */
    public synchronized void deleteCollector(int collectorId)
    {
        requireCollector(collectorId);
        CollectorState collector = requireCollector(collectorId);
        List<CollectorState> collectors = new ArrayList<>(state.collectors());
        collectors.removeIf(item -> item.collectorId() == collectorId);
        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
        SimulatorState next = new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones());
        stateStore.saveAndDeleteCollectorHistory(next, collector.deviceCode());
        this.state = next;
    }
    /** 配置采集器独立采样频率和历史下载故障场景。 */
    public synchronized void configureCollectorSampling(int collectorId, int sampleIntervalMs,
            CollectorHistoryFaults historyFaults)
    {
        CollectorState current = requireCollector(collectorId);
        replaceCollector(DevelopmentCollectorSimulation.configureSampling(current, sampleIntervalMs, historyFaults));
    }
    /** 配置一个稳定 S 通道的固定或动态演示指标。 */
    public synchronized void configureCollectorChannel(int collectorId, String channelCode,
            List<CollectorMetricState> metrics)
    {
        CollectorState current = requireCollector(collectorId);
        replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
    }
    /**
     * 模拟现场人工统一开关机:关机停止采样,开机重启检测柜夹具生命周期并保持通道待机。
     * 采集器历史继续保留,检测柜夹具历史在关机后的开机上升沿清理。
     * 该操作只改变模拟设备事实,不代表系统具备真实设备控制能力。
     */
    public synchronized void setAllDevicesPoweredOn(boolean poweredOn)
    {
        boolean wasPoweredOff = (!state.devices().isEmpty() || !state.collectors().isEmpty())
                && state.devices().stream().noneMatch(DeviceState::online)
                && state.collectors().stream().noneMatch(CollectorState::online);
        Instant changedAt = Instant.now();
        if (poweredOn && wasPoweredOff)
        {
            // 一键开机代表新一轮演示;清理检测柜夹具,避免序号从 1 重放并与旧生命周期冲突。
            for (DeviceState device : state.devices())
                for (ChannelState channel : device.channels())
                    resetBtsFixtureLifecycle(btsKey(device, channel.channelId()), changedAt);
        }
        List<DeviceState> devices = state.devices().stream()
                .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn,
                        device.channels().stream()
                                .map(channel -> new ChannelState(channel.channelId(), poweredOn,
                                        DeviceWorkStatus.STOPPED))
                                .toList(), device.profile()))
                .toList();
        List<CollectorState> collectors = state.collectors().stream()
                .map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(),
                        collector.channelCount(), poweredOn, changedAt))
                .toList();
        persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones()));
    }
    /** 清理检测柜夹具的历史和播放指针;采集器历史由总电源开关独立保留。 */
    private void resetBtsFixtureLifecycle(String key, Instant now)
    {
        btsHistory.remove(key);
        stateStore.deleteBtsHistory(key);
        stateStore.deleteBtsPlaybackState(key);
        savePlayback(key, defaultPlayback(now));
    }
    /** 按每台采集器自己的频率推进实时和历史采样。 */
    public synchronized void advanceCollectors(Instant now)
    {
        boolean hasOnlineCollector = false;
        for (CollectorState collector : state.collectors())
        {
            if (!collector.online()) continue;
            hasOnlineCollector = true;
            break;
        }
        if (!hasOnlineCollector) return;
        List<CollectorState> collectors = new ArrayList<>(state.collectors().size());
        List<CollectorHistoryAppend> historyAppends = new ArrayList<>();
        for (CollectorState collector : state.collectors())
        {
            CollectorAdvance advance = DevelopmentCollectorSimulation.advance(collector, now);
            collectors.add(advance.state());
            if (!advance.appendedRecords().isEmpty())
                historyAppends.add(new CollectorHistoryAppend(collector.deviceCode(), advance.appendedRecords()));
        }
        if (!historyAppends.isEmpty())
            persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                    state.nextCollectorId(), state.tombstones()), historyAppends);
    }
    /** 按墙上时间推进检测柜模拟播放;设备状态和测试接收状态互不影响。 */
    public synchronized void advanceBts(Instant now)
    {
        for (DeviceState device : state.devices())
        {
            if (!device.online()) continue;
            BtsDeviceProfile profile = profileFor(device);
            for (ChannelState channel : device.channels())
            {
                if (!channel.online() || channel.workStatus() != DeviceWorkStatus.WORKING) continue;
                String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId());
                BtsPlaybackState playback = playbackState(key, now);
                advanceBtsPlayback(key, profile, playback,
                        profile.currentMaxA().doubleValue() * 0.2d, 3.7d, now);
            }
        }
    }
    /** 设置检测柜通道演示倍速,只接受 1x、60x 和 600x。 */
    public synchronized void setBtsSpeed(int deviceId, int channelId, int speedMultiplier)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState current = playbackState(key, Instant.now());
        stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(current.playing(), speedMultiplier,
                current.cycleNo(), current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(),
                Instant.now(), current.nextSequence(), current.naturallyFinished()));
    }
    /** 开始播放指定检测柜通道的演示时间线。 */
    public synchronized void playBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); }
    /** 暂停模拟器时间线,不改变检测柜通道工作事实。 */
    public synchronized void pauseBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, false); }
    /** 从暂停状态恢复模拟器时间线。 */
    public synchronized void resumeBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); }
    private void setBtsPlaying(int deviceId, int channelId, boolean playing)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        Instant now = Instant.now();
        BtsPlaybackState current = playbackState(key, now);
        if (current.naturallyFinished() && playing) return;
        stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(playing, current.speedMultiplier(), current.cycleNo(),
                current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(), now,
                current.nextSequence(), current.naturallyFinished()));
    }
    /** 跳转到下一实际工步;不向设备发送控制报文。 */
    public synchronized void nextBtsStep(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState current = playbackState(key, Instant.now());
        BtsExcelFixture.FixtureRecord next = nextFixtureStep(current.cycleNo(), current.stepNo());
        if (next == null)
        {
            BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount());
            savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(),
                    last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(),
                    BtsExcelFixture.recordCount() + 1L, true));
            return;
        }
        savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(),
                next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false));
    }
    /** 跳转到下一循环的首工步;不补齐当前循环缺失的首尾工步。 */
    public synchronized void nextBtsCycle(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState current = playbackState(key, Instant.now());
        BtsExcelFixture.FixtureRecord next = BtsExcelFixture.records().stream()
                .filter(item -> item.cycleNo() > current.cycleNo()).findFirst().orElse(null);
        if (next == null)
        {
            BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount());
            savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(),
                    last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(),
                    BtsExcelFixture.recordCount() + 1L, true));
            return;
        }
        savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(),
                next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false));
    }
    /** 清空单通道播放和原始数据,恢复到首循环首工步。 */
    public synchronized void resetBts(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        resetBtsFixtureLifecycle(key, Instant.now());
    }
    /** 返回播放状态及当前通道的 record、step、cycle 数据。 */
    public synchronized PlaybackSnapshot btsPlayback(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        String key = btsKey(device, channelId);
        BtsPlaybackState state = playbackState(key, Instant.now());
        return new PlaybackSnapshot(deviceId, channelId, state, btsRecords(deviceId, channelId),
                btsSteps(deviceId, channelId), btsCycles(deviceId, channelId));
    }
    /** 返回只追加的检测柜原始 record。 */
    public synchronized List<BtsHistoryRecord> btsRecords(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        return List.copyOf(btsHistoryFor(btsKey(device, channelId)));
    }
    /** 返回开发模拟器当前通道的持久化槽位间隔;仅供开发调度层使用。 */
    public synchronized long persistenceIntervalMillis(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
        return Math.max(100L, 60000L / Math.max(1, playback.speedMultiplier()));
    }
    /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */
    public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId)
    {
        java.util.Map<String, BtsStepAccumulator> grouped = new java.util.LinkedHashMap<>();
        for (BtsHistoryRecord item : btsRecords(deviceId, channelId))
        {
            String key = item.cycleNo() + ":" + item.stepNo();
            int position = BtsExcelFixture.records().stream()
                    .filter(record -> record.cycleNo() == item.cycleNo())
                    .map(BtsExcelFixture.FixtureRecord::stepNo).distinct().toList().indexOf(item.stepNo()) + 1;
            grouped.computeIfAbsent(key, ignored -> new BtsStepAccumulator(item, position)).add(item);
        }
        return grouped.values().stream().map(BtsStepAccumulator::finish).toList();
    }
    /** 按实际出现的循环汇总 record,不增加测试结束统计行。 */
    public synchronized List<BtsCycleRecord> btsCycles(int deviceId, int channelId)
    {
        java.util.Map<Integer, BtsCycleAccumulator> grouped = new java.util.LinkedHashMap<>();
        for (BtsHistoryRecord item : btsRecords(deviceId, channelId))
            grouped.computeIfAbsent(item.cycleNo(), ignored -> new BtsCycleAccumulator(item)).add(item);
        return grouped.entrySet().stream().map(entry -> {
            BtsCycleRecord calculated = entry.getValue().finish();
            BtsExcelFixture.CycleSummary expected = BtsExcelFixture.cycle(entry.getKey());
            String recordNo = "CYCLE-" + deviceId + "-" + channelId + "-" + entry.getKey();
            if (expected == null || entry.getValue().count < expected.recordCount())
                return new BtsCycleRecord(recordNo, calculated.cycleNo(), calculated.recordCount(), calculated.startedAt(), calculated.endedAt(),
                        calculated.chargeCapacityAh(), calculated.dischargeCapacityAh(), calculated.chargeEnergyWh(), calculated.dischargeEnergyWh(), calculated.efficiency());
            return new BtsCycleRecord(recordNo, calculated.cycleNo(), expected.recordCount(), calculated.startedAt(),
                    calculated.endedAt(), expected.chargeCapacityAh(), expected.dischargeCapacityAh(),
                    expected.chargeEnergyWh(), expected.dischargeEnergyWh(),
                    efficiency(expected.chargeCapacityAh(), expected.dischargeCapacityAh()));
        }).toList();
    }
    /** 返回检测柜测试方案事实;方案行来自 Excel 夹具,当前状态来自该通道播放状态。 */
    public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId)
    { return btsTest(deviceId, channelId, null); }
    /** 返回指定循环的检测柜测试方案;未开始接收的测试固定使用第一个循环作为核对上下文。 */
    public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId, Integer forcedCycleNo)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
        int requestedCycleNo = forcedCycleNo == null ? playback.cycleNo() : forcedCycleNo;
        int currentCycleNo = requestedCycleNo;
        List<BtsExcelFixture.FixtureRecord> currentCycleRecords = BtsExcelFixture.records().stream()
                .filter(item -> item.cycleNo() == requestedCycleNo).toList();
        if (currentCycleRecords.isEmpty())
        {
            int fallbackCycleNo = BtsExcelFixture.records().get(0).cycleNo();
            currentCycleNo = fallbackCycleNo;
            currentCycleRecords = BtsExcelFixture.records().stream()
                    .filter(item -> item.cycleNo() == fallbackCycleNo).toList();
        }
        java.util.Map<Integer, BtsExcelFixture.FixtureRecord> firstByStep = new java.util.LinkedHashMap<>();
        for (BtsExcelFixture.FixtureRecord item : currentCycleRecords)
            firstByStep.putIfAbsent(item.stepNo(), item);
        List<BtsTestPlanRow> rows = new ArrayList<>();
        int cyclePosition = 1;
        for (BtsExcelFixture.FixtureRecord first : firstByStep.values())
        {
            List<BtsExcelFixture.FixtureRecord> sameCycle = currentCycleRecords.stream()
                    .filter(item -> item.stepNo() == first.stepNo()).toList();
            BtsExcelFixture.FixtureRecord last = sameCycle.get(sameCycle.size() - 1);
            // step 号在每个循环内重复,跨循环时必须按当前循环的工步位置判断,不能把上一循环的同号工步当成当前状态。
            String status = forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成"
                    : playback.stepNo() > first.stepNo() ? "已完成"
                            : playback.stepNo() == first.stepNo() ? "进行中" : "待执行";
            rows.add(new BtsTestPlanRow("STEP", first.stepNo(), cyclePosition++, first.stepName(),
                    Math.max(0d, last.offsetSeconds() - first.offsetSeconds()), first.voltageV(), status,
                    null, null));
        }
        rows.add(new BtsTestPlanRow("CYCLE", BtsExcelFixture.startStepNo(), null, "循环", 0d, null,
                forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "进行中", currentCycleNo,
                (long) currentCycleRecords.size()));
        rows.add(new BtsTestPlanRow("END", 0, null, "结束", 0d, null,
                forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "待执行", null, (long) BtsExcelFixture.recordCount()));
        return new BtsTestSnapshot(currentCycleNo, playback.stepNo(), BtsExcelFixture.cycleCount(), rows);
    }
    /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */
    public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId)
    {
        return DevelopmentCollectorSimulation.realtime(requireCollector(collectorId));
    }
    /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */
    public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit)
    {
        CollectorState collector = requireCollector(collectorId);
        return DevelopmentCollectorSimulation.history(collector, stateStore, offset, limit);
    }
    /** 返回控制页需要的运行状态快照。 */
    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()))
@@ -168,7 +531,9 @@
        List<CollectorSnapshot> collectorSnapshots = state.collectors().stream()
                .map(collector -> new CollectorSnapshot(collector.collectorId(), collector.deviceCode(),
                        collector.ip(), collector.mac(), collector.name(), collector.manufacturer(),
                        collector.model(), collector.channelCount(), collector.online()))
                        collector.model(), collector.channelCount(), collector.online(), collector.sampleIntervalMs(),
                        collector.channels(), collector.historyFaults(), collector.historyRecordCount(),
                        DevelopmentCollectorSimulation.realtime(collector).measurements()))
                .toList();
        return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
                stateStore.loadRecentRequests());
@@ -179,6 +544,7 @@
    {
        SimulatorState defaultState = defaultState();
        stateStore.reset(defaultState);
        btsHistory.clear();
        state = defaultState;
    }
@@ -191,10 +557,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)
@@ -204,6 +575,14 @@
                "开发模拟器", "Virtual Collector", channelCount, true);
    }
    private void replaceCollector(CollectorState replacement)
    {
        List<CollectorState> collectors = new ArrayList<>(state.collectors());
        collectors.replaceAll(item -> item.collectorId() == replacement.collectorId() ? replacement : item);
        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones()));
    }
    private String respond(String request)
    {
        String command = readCommand(request);
@@ -211,7 +590,9 @@
        return switch (command)
        {
            case "getdevinfo" -> deviceInfoResponse();
            case "inquire" -> observationResponse();
            case "inquire" -> observationResponse(request);
            case "inquiredf" -> historyAvailabilityResponse(request);
            case "download" -> historyResponse(request);
            default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
        };
    }
@@ -278,34 +659,391 @@
            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())
                    .append("-0\" cycle_id=\"1\" step_id=\"1\" step_type=\"cc\" workstatus=\"")
            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());
            String key = historyKey(profile.devtype(), observed.deviceId(), profile.subDeviceId(), channel.channelId());
            BtsPlaybackState playback = playbackState(key, Instant.now());
            double capacity = working ? latestCapacity(key) : 0d;
            double energy = capacity * voltage;
            if (working)
            {
                playback = advanceBtsPlayback(key, profile, playback, current, voltage);
                BtsHistoryRecord latest = latestRecord(key);
                if (latest != null)
                {
                    capacity = latest.capacityAh();
                    energy = latest.energyWh();
                }
            }
            int cycleNo = playback.cycleNo();
            int stepNo = playback.stepNo();
            String stepType = playback.stepType();
            response.append("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId())
                    .append('-').append(profile.subDeviceId()).append('-').append(channel.channelId())
                    .append("-0\" cycle_id=\"").append(cycleNo).append("\" step_id=\"").append(stepNo)
                    .append("\" step_type=\"").append(stepType).append("\" 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 synchronized String historyAvailabilityResponse(String request)
    {
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("<chl\\s+([^>]*)/?>").matcher(request);
        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquiredf_resp</cmd><list>");
        while (matcher.find())
        {
            String attrs = matcher.group(1);
            int devtype = Integer.parseInt(attribute(attrs, "devtype"));
            int devid = Integer.parseInt(attribute(attrs, "devid"));
            int subdevid = Integer.parseInt(attribute(attrs, "subdevid"));
            int chlid = Integer.parseInt(attribute(attrs, "chlid"));
            int count = btsHistoryFor(historyKey(devtype, devid, subdevid, chlid)).size();
            response.append("<chl devtype=\"").append(devtype).append("\" devid=\"").append(devid)
                    .append("\" subdevid=\"").append(subdevid).append("\" chlid=\"").append(chlid)
                    .append("\" testid=\"SIM-").append(devid).append('-').append(chlid).append("\" count=\"")
                    .append(count).append("\">true</chl>");
        }
        return response.append("</list></bts>").toString();
    }
    private synchronized String historyResponse(String request)
    {
        int devtype = Integer.parseInt(attribute(request, "devtype"));
        int devid = Integer.parseInt(attribute(request, "devid"));
        int subdevid = Integer.parseInt(attribute(request, "subdevid"));
        int chlid = Integer.parseInt(attribute(request, "chlid"));
        String testId = attribute(request, "testid");
        long start = Long.parseLong(attribute(request, "startpos"));
        int count = Integer.parseInt(attribute(request, "count"));
        List<BtsHistoryRecord> history = btsHistoryFor(historyKey(devtype, devid, subdevid, chlid));
        int from = (int) Math.min(Math.max(0L, start - 1L), history.size());
        int to = Math.min(history.size(), from + Math.max(1, count));
        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>download_resp</cmd><download devtype=\"")
                .append(devtype).append("\" devid=\"").append(devid).append("\" subdevid=\"").append(subdevid)
                .append("\" chlid=\"").append(chlid).append("\" testid=\"").append(testId)
                .append("\"><list>");
        for (BtsHistoryRecord item : history.subList(from, to))
            response.append("<data seqid=\"").append(item.sequenceId()).append("\" stepid=\"").append(item.stepNo())
                    .append("\" cycleid=\"").append(item.cycleNo()).append("\" steptype=\"").append(item.stepType()).append("\" atime=\"")
                    .append(BTS_HISTORY_TIME.format(item.deviceTime())).append("\" testtime=\"60000\" volt=\"")
                    .append(decimal(item.voltageV())).append("\" curr=\"").append(decimal(item.currentA()))
                    .append("\" cap=\"").append(decimal(item.capacityAh())).append("\" eng=\"").append(decimal(item.energyWh())).append("\"/>");
        return response.append("</list></download></bts>").toString();
    }
    private String attribute(String xml, String name)
    {
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile(name + "=\\\"([^\\\"]+)\\\"").matcher(xml);
        if (!matcher.find()) throw new IllegalArgumentException("Missing simulator attribute: " + name);
        return matcher.group(1);
    }
    private String historyKey(int devtype, int devid, int subdevid, int chlid)
    {
        return devtype + ":" + devid + ":" + subdevid + ":" + chlid;
    }
    private void restoreBtsHistory()
    {
        for (DeviceState device : state.devices())
        {
            BtsDeviceProfile profile = profileFor(device);
            for (ChannelState channel : device.channels())
            {
                String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId());
                ArrayList<BtsHistoryRecord> restored = new ArrayList<>();
                long offset = 0;
                while (true)
                {
                    var page = stateStore.loadBtsHistoryPage(key, offset, BtsSimulatorStateStore.MAX_HISTORY_BATCH_SIZE);
                    restored.addAll(page.records().stream().map(this::normalizeHistoryRecord).toList());
                    if (!page.hasMore()) break;
                    if (page.nextOffset() <= offset) throw new IllegalStateException("Invalid BTS history page");
                    offset = page.nextOffset();
                }
                if (!restored.isEmpty()) btsHistory.put(key, restored);
            }
        }
    }
    private List<BtsHistoryRecord> btsHistoryFor(String key)
    {
        return btsHistory.getOrDefault(key, List.of());
    }
    private BtsHistoryRecord normalizeHistoryRecord(BtsHistoryRecord item)
    {
        return item.stepName() == null || item.stepName().isBlank()
                ? new BtsHistoryRecord(item.sequenceId(), item.deviceTime(), item.currentA(), item.voltageV(),
                        item.capacityAh(), item.energyWh()) : item;
    }
    private String btsKey(DeviceState device, int channelId)
    {
        BtsDeviceProfile profile = profileFor(device);
        return historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channelId);
    }
    private void requireChannel(DeviceState device, int channelId)
    {
        if (channelId < 1 || channelId > device.channels().size())
            throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
    }
    private BtsPlaybackState playbackState(String key, Instant now)
    {
        BtsPlaybackState stored = stateStore.loadBtsPlaybackState(key);
        if (stored != null) return stored;
        List<BtsHistoryRecord> records = btsHistoryFor(key);
        long next = records.stream().mapToLong(BtsHistoryRecord::sequenceId).max().orElse(0L) + 1L;
        BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1);
        BtsPlaybackState created = new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(),
                now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, next, false);
        stateStore.saveBtsPlaybackState(key, created);
        return created;
    }
    private BtsHistoryRecord latestRecord(String key)
    {
        List<BtsHistoryRecord> records = btsHistoryFor(key);
        return records.isEmpty() ? null : records.get(records.size() - 1);
    }
    private double latestCapacity(String key)
    {
        BtsHistoryRecord latest = latestRecord(key);
        return latest == null ? 0d : latest.capacityAh();
    }
    private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current,
            double currentA, double voltageV)
    {
        return advanceBtsPlayback(key, profile, current, currentA, voltageV, Instant.now());
    }
    private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current,
            double currentA, double voltageV, Instant now)
    {
        if (!current.playing() || current.naturallyFinished()) return current;
        long elapsedMillis = Math.max(0L, java.time.Duration.between(current.wallClockAt(), now).toMillis())
                * current.speedMultiplier();
        List<BtsHistoryRecord> history = new ArrayList<>(btsHistoryFor(key));
        List<BtsHistoryRecord> appended = new ArrayList<>();
        Instant simulated = current.simulatedAt();
        long sequence = current.nextSequence();
        if (sequence < 1) sequence = 1;
        if (sequence > BtsExcelFixture.recordCount() + 1L)
            sequence = BtsExcelFixture.recordCount() + 1L;
        long lastOffset = sequence == 1 ? -SIMULATED_RECORD_INTERVAL_SECONDS
                : BtsExcelFixture.record(sequence - 1).offsetSeconds();
        long budgetMillis = elapsedMillis;
        while (sequence <= BtsExcelFixture.recordCount())
        {
            BtsExcelFixture.FixtureRecord fixture = BtsExcelFixture.record(sequence);
            long deltaSeconds = fixture.offsetSeconds() - lastOffset;
            long deltaMillis = Math.max(1L, deltaSeconds * 1000L);
            boolean firstRecord = history.isEmpty() && sequence == 1L;
            if (!firstRecord && budgetMillis < deltaMillis) break;
            budgetMillis = Math.max(0L, budgetMillis - deltaMillis);
            simulated = simulated.plusSeconds(deltaSeconds);
            BtsHistoryRecord record = new BtsHistoryRecord(fixture.sequenceId(), simulated, fixture.currentA(), fixture.voltageV(),
                    fixture.capacityAh(), fixture.energyWh(), fixture.cycleNo(), fixture.stepNo(), fixture.stepName(), fixture.stepType());
            history.add(record);
            appended.add(record);
            lastOffset = fixture.offsetSeconds();
            sequence++;
        }
        if (appended.isEmpty()) return current;
        if (history.size() > BtsSimulatorStateStore.MAX_HISTORY_RECORDS)
            history = new ArrayList<>(history.subList(history.size() - (int) BtsSimulatorStateStore.MAX_HISTORY_RECORDS,
                    history.size()));
        BtsExcelFixture.FixtureRecord latest = BtsExcelFixture.record(sequence - 1L);
        boolean finished = sequence > BtsExcelFixture.recordCount();
        BtsPlaybackState next = new BtsPlaybackState(!finished && current.playing(), current.speedMultiplier(),
                latest.cycleNo(), latest.stepNo(), latest.stepName(), latest.stepType(), simulated, now, sequence, finished);
        stateStore.saveBtsPlaybackAndAppendHistory(key, next, appended);
        btsHistory.put(key, history);
        return next;
    }
    private BtsPlaybackState defaultPlayback(Instant now)
    {
        BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1);
        return new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(),
                now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, 1, false);
    }
    private BtsExcelFixture.FixtureRecord nextFixtureStep(int cycleNo, int stepNo)
    {
        return BtsExcelFixture.records().stream()
                .filter(item -> item.cycleNo() > cycleNo || (item.cycleNo() == cycleNo && item.stepNo() > stepNo))
                .findFirst().orElse(null);
    }
    private void savePlayback(String key, BtsPlaybackState playback)
    {
        stateStore.saveBtsPlaybackState(key, playback);
    }
    public record BtsStepRecord(int cycleNo, int stepNo, Integer cyclePosition, String stepName, String stepType, long recordCount,
            Instant startedAt, Instant endedAt, double durationSeconds, double capacityAh, double energyWh,
            double startVoltageV, double endVoltageV, double chargePeakVoltageV, double dischargeValleyVoltageV) { }
    public record BtsCycleRecord(String recordNo, int cycleNo, long recordCount, Instant startedAt, Instant endedAt,
            double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, double dischargeEnergyWh,
            double efficiency) { }
    public record BtsTestPlanRow(String rowType, int stepNo, Integer cyclePosition, String stepName, double durationSeconds,
            Double voltageV, String executionStatus, Integer cycleCount, Long recordCount) { }
    public record BtsTestSnapshot(int currentCycleNo, int currentStepNo, int plannedCycleCount,
            List<BtsTestPlanRow> rows) { }
    private static double efficiency(double chargeCapacityAh, double dischargeCapacityAh)
    {
        return chargeCapacityAh > 0d ? dischargeCapacityAh / chargeCapacityAh * 100d : 0d;
    }
    public record PlaybackSnapshot(int deviceId, int channelId, BtsPlaybackState playback,
            List<BtsHistoryRecord> records, List<BtsStepRecord> steps, List<BtsCycleRecord> cycles) { }
    private static final class BtsStepAccumulator
    {
        private final int cycle; private final int step; private final int position; private final String name; private final String type;
        private long count; private Instant first; private Instant last; private double duration; private double capacity; private double energy;
        private double startVoltage; private double endVoltage; private double chargePeak = Double.NEGATIVE_INFINITY; private double dischargeValley = Double.POSITIVE_INFINITY;
        BtsStepAccumulator(BtsHistoryRecord first, int position) { cycle = first.cycleNo(); step = first.stepNo(); this.position = position; name = first.stepName(); type = first.stepType(); }
        void add(BtsHistoryRecord item) { count++; if (first == null) { first = item.deviceTime(); startVoltage = item.voltageV(); } last = item.deviceTime(); endVoltage = item.voltageV(); duration = first == null ? 0 : Math.max(0, java.time.Duration.between(first, last).toMillis() / 1000d); capacity = item.capacityAh(); energy = item.energyWh(); if (item.currentA() >= 0) chargePeak = Math.max(chargePeak, item.voltageV()); else dischargeValley = Math.min(dischargeValley, item.voltageV()); }
        BtsStepRecord finish() { return new BtsStepRecord(cycle, step, position, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley); }
    }
    private static final class BtsCycleAccumulator
    {
        private final int cycle; private long count; private Instant first; private Instant last;
        private double maximumCapacity = Double.NEGATIVE_INFINITY; private double minimumCapacity = Double.POSITIVE_INFINITY;
        private double chargeEnergy; private double dischargeEnergy;
        BtsCycleAccumulator(BtsHistoryRecord first) { cycle = first.cycleNo(); }
        void add(BtsHistoryRecord item) { count++; if (first == null) first = item.deviceTime(); last = item.deviceTime(); maximumCapacity = Math.max(maximumCapacity, item.capacityAh()); minimumCapacity = Math.min(minimumCapacity, item.capacityAh()); if (item.currentA() >= 0) chargeEnergy = Math.max(chargeEnergy, item.energyWh()); else dischargeEnergy = Math.max(dischargeEnergy, Math.abs(item.energyWh())); }
        BtsCycleRecord finish() { double charge = Math.max(0d, maximumCapacity); double discharge = Math.max(0d, maximumCapacity - minimumCapacity); return new BtsCycleRecord("", cycle, count, first, last, charge, discharge, chargeEnergy, dischargeEnergy, efficiency(charge, discharge)); }
    }
    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)
@@ -396,6 +1134,13 @@
        state = nextState;
    }
    private void persist(SimulatorState nextState, List<CollectorHistoryAppend> historyAppends)
    {
        // 主状态与原始历史批次必须一起成功,内存序号才能向前推进。
        stateStore.save(nextState, historyAppends);
        state = nextState;
    }
    private String simulatorMac(int devtype, int devid)
    {
        return String.format("02:44:%02X:%02X:%02X:%02X",
@@ -426,6 +1171,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++)
            {
@@ -442,6 +1189,7 @@
                    || !isIpv4(collector.ip()) || !ips.add(collector.ip())
                    || collector.channelCount() < 1 || collector.channelCount() > MAX_CHANNEL_COUNT)
                throw new IllegalStateException("Invalid BTS simulator collector state in Redis");
            DevelopmentCollectorSimulation.validate(collector, stateStore);
            maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId());
        }
        java.util.Set<String> tombstoneIds = new java.util.HashSet<>();
@@ -466,11 +1214,21 @@
    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) { }
    public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
            String manufacturer, String model, int channelCount, boolean online) { }
            String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
            List<CollectorChannelState> channels, CollectorHistoryFaults historyFaults, long historyRecordCount,
            List<CollectorRealtimeMeasurement> latestMeasurements)
    {
        public CollectorSnapshot
        {
            channels = List.copyOf(channels);
            latestMeasurements = List.copyOf(latestMeasurements);
        }
    }
}