package com.battery.device.simulator; 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; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamReader; import com.battery.device.model.DeviceWorkStatus; import com.battery.device.model.DeviceConnectionStatus; import com.battery.device.model.DeviceIdentity; import com.battery.device.model.DeviceObservation; import com.battery.device.model.DetectedChannel; import com.battery.device.model.DetectedDeviceMetadata; import com.battery.device.model.ChannelIdentity; import com.battery.device.model.AdapterCapabilities; import com.battery.device.api.DeviceObservationAdapter; import com.battery.device.simulator.BtsSimulatorStateStore; import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState; import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement; import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState; import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState; import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile; import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord; import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState; import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest; import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState; import com.battery.device.simulator.InMemoryBtsSimulatorStateStore; import com.battery.device.simulator.DevelopmentCollectorSimulation; import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorAdvance; import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage; import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot; /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */ public final class BtsDevelopmentSimulator implements DeviceObservationAdapter { public static final String HOST = "127.0.0.1"; public static final int DEFAULT_PORT = 15020; 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 MAX_CHANNEL_COUNT = 99; private static final int MAX_RECENT_REQUESTS = 20; private static final int TOMBSTONE_DISCOVERY_COUNT = 3; private final ScriptedBtsTcpSimulator simulator; private final BtsSimulatorStateStore stateStore; private SimulatorState state; private boolean running = true; private long observationSequence; private final java.util.Map> 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")); @Override public String adapterCode() { return ADAPTER_CODE; } @Override public AdapterCapabilities capabilities() { return AdapterCapabilities.observeOnly(); } /** 直接提供模拟器当前拓扑,供不需要 TCP/XML 的契约测试使用。 */ @Override public synchronized List discoverChannels() { List result = new ArrayList<>(); for (DeviceState device : state.devices()) { BtsDeviceProfile profile = profileFor(device); for (ChannelState channel : device.channels()) { ChannelIdentity identity = new ChannelIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId()); result.add(new DetectedChannel(identity, device.ip(), device.online() && channel.online(), new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(), simulatorMac(profile.devtype(), device.deviceId())))); } } return List.copyOf(result); } /** 直接提供模拟器观测;离线通道不保留上一次测量或工步事实。 */ @Override public synchronized java.util.Map observe( java.util.Set channels) { if (channels == null || channels.isEmpty()) return java.util.Map.of(); java.util.Map result = new java.util.LinkedHashMap<>(); for (ChannelIdentity requested : channels) { DeviceState device = state.devices().stream().filter(item -> item.deviceId() == requested.devid()) .findFirst().orElse(null); ChannelState channel = device == null ? null : device.channels().stream() .filter(item -> item.channelId() == requested.chlid()).findFirst().orElse(null); if (device == null || channel == null || !ADAPTER_CODE.equals(requested.adapterCode()) || !device.online() || !channel.online()) { result.put(requested, DeviceObservation.offline(requested, "OFFLINE")); continue; } BtsDeviceProfile profile = profileFor(device); if (requested.devtype() != profile.devtype() || requested.subdevid() != profile.subDeviceId()) { result.put(requested, DeviceObservation.offline(requested, "OFFLINE")); continue; } result.put(requested, new DeviceObservation( new DeviceIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(), profile.subDeviceId()), requested, DeviceConnectionStatus.ONLINE, channel.workStatus(), List.of(), null, java.util.Map.of(), null)); } return java.util.Map.copyOf(result); } /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */ public BtsDevelopmentSimulator(int port) throws IOException { this(port, new InMemoryBtsSimulatorStateStore()); } /** 从长期状态恢复后再启动 TCP 服务,避免服务先暴露默认拓扑。 */ public BtsDevelopmentSimulator(int port, BtsSimulatorStateStore stateStore) throws IOException { this.stateStore = stateStore; state = stateStore.loadOrInitialize(defaultState()); // 仅清理 Ticket13 之前嵌在主状态中的旧演示历史;独立历史由模拟器存储保留。 state = stateStore.resetHistoryForDemoStartup(state); restoreBtsHistory(); validateRestoredState(); stateStore.completeValidatedRestore(state); simulator = ScriptedBtsTcpSimulator.responding(port, this::respond); } public int port() { return simulator.port(); } /** 新增一台具有独立虚拟 IP 和通道数的开发设备。 */ public synchronized int addDevice(String ip, int channelCount) { validateDeviceConfiguration(ip, channelCount, null); int deviceId = state.nextDeviceId(); List devices = new ArrayList<>(state.devices()); 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; } /** 修改开发设备的虚拟地址、通道数和在线状态。 */ public synchronized void updateDevice(int deviceId, String ip, int channelCount, boolean online) { DeviceState current = requireDevice(deviceId); validateDeviceConfiguration(ip, channelCount, deviceId); List channels = new ArrayList<>(channelCount); for (int channelId = 1; channelId <= channelCount; channelId++) { channels.add(channelId <= current.channels().size() ? current.channels().get(channelId - 1) : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); } List devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels, resizeProfile(current.profile(), channelCount))); List 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 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); } /** 删除指定开发设备;后续发现响应不再返回它。 */ public synchronized void deleteDevice(int deviceId) { DeviceState removed = requireDevice(deviceId); List devices = new ArrayList<>(state.devices()); devices.removeIf(item -> item.deviceId() == deviceId); List 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)); } /** 修改指定开发设备的单个通道状态。 */ public synchronized void updateChannel(int deviceId, int channelId, boolean online, DeviceWorkStatus workStatus) { requireControllableStatus(workStatus); DeviceState device = requireDevice(deviceId); if (channelId < 1 || channelId > device.channels().size()) throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size()); List channels = new ArrayList<>(device.channels()); channels.set(channelId - 1, new ChannelState(channelId, online, workStatus)); persist(new SimulatorState(state.version(), replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(), state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); } /** 新增一台与 BTS 检测柜拓扑隔离的开发采集器。 */ public synchronized int addCollector(String ip, int channelCount) { validateCollectorConfiguration(ip, channelCount, null); int collectorId = state.nextCollectorId(); List collectors = new ArrayList<>(state.collectors()); collectors.add(newCollector(collectorId, ip, channelCount)); persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), collectorId + 1, state.tombstones())); return collectorId; } /** 修改开发采集器的虚拟地址、通道数和在线状态。 */ public synchronized void updateCollector(int collectorId, String ip, int channelCount, boolean online) { validateCollectorConfiguration(ip, channelCount, collectorId); CollectorState current = requireCollector(collectorId); List collectors = new ArrayList<>(state.collectors()); collectors.replaceAll(item -> item.collectorId() == collectorId ? DevelopmentCollectorSimulation.resize(current, ip, channelCount, online, Instant.now()) : item); persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); } /** 删除指定开发采集器,不影响检测柜 TCP 发现结果。 */ public synchronized void deleteCollector(int collectorId) { CollectorState collector = requireCollector(collectorId); List collectors = new ArrayList<>(state.collectors()); collectors.removeIf(item -> item.collectorId() == collectorId); 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 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 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 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 collectors = new ArrayList<>(state.collectors().size()); List 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 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 btsSteps(int deviceId, int channelId) { java.util.Map 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 btsCycles(int deviceId, int channelId) { java.util.Map 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 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 firstByStep = new java.util.LinkedHashMap<>(); for (BtsExcelFixture.FixtureRecord item : currentCycleRecords) firstByStep.putIfAbsent(item.stepNo(), item); List rows = new ArrayList<>(); int cyclePosition = 1; for (BtsExcelFixture.FixtureRecord first : firstByStep.values()) { List 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 deviceSnapshots = state.devices().stream() .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())) .toList())) .toList(); List 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.sampleIntervalMs(), collector.channels(), collector.historyFaults(), collector.historyRecordCount(), DevelopmentCollectorSimulation.realtime(collector).measurements())) .toList(); return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots, stateStore.loadRecentRequests()); } /** 人工恢复默认拓扑;这是正常运行中唯一清空长期模拟状态的入口。 */ public synchronized void reset() { SimulatorState defaultState = defaultState(); stateStore.reset(defaultState); btsHistory.clear(); state = defaultState; } private SimulatorState defaultState() { return new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, List.of(newDevice(1, "127.0.0.2", 4)), List.of(newCollector(1, "127.0.1.2", 4)), 2, 2, List.of()); } private DeviceState newDevice(int deviceId, String ip, int channelCount) { return newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount)); } private DeviceState newDevice(int deviceId, String ip, BtsDeviceProfile profile) { List 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, profile); } private CollectorState newCollector(int collectorId, String ip, int channelCount) { return new CollectorState(collectorId, "dev-sim-collector-" + collectorId, ip.trim(), simulatorCollectorMac(collectorId), "dev-sim 采集器 " + collectorId, "开发模拟器", "Virtual Collector", channelCount, true); } private void replaceCollector(CollectorState replacement) { List 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); recordRequest(command, request); return switch (command) { case "getdevinfo" -> deviceInfoResponse(); case "inquire" -> observationResponse(request); case "inquiredf" -> historyAvailabilityResponse(request); case "download" -> historyResponse(request); default -> "error"; }; } private synchronized void recordRequest(String command, String request) { List recentRequests = new ArrayList<>(stateStore.loadRecentRequests()); recentRequests.add(0, new RecentRequest(Instant.now(), command, request)); if (recentRequests.size() > MAX_RECENT_REQUESTS) recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS)); stateStore.saveRecentRequests(recentRequests); } private String readCommand(String request) { try { XMLInputFactory factory = XMLInputFactory.newFactory(); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request)); while (reader.hasNext()) { if (reader.next() == XMLStreamConstants.START_ELEMENT && "cmd".equals(reader.getLocalName())) return reader.getElementText().trim(); } } catch (Exception exception) { throw new IllegalArgumentException("Invalid BTS request XML", exception); } throw new IllegalArgumentException("BTS request does not contain cmd"); } private synchronized String deviceInfoResponse() { int channelCount = state.devices().stream().mapToInt(device -> device.channels().size()).sum() + state.tombstones().size(); StringBuilder response = new StringBuilder("getdevinfo_resp") .append(""); for (DeviceState device : state.devices()) { for (ChannelState channel : device.channels()) { boolean available = device.online() && channel.online(); appendDiscoveredChannel(response, device.deviceId(), device.ip(), channel.channelId(), available); } } List remaining = new ArrayList<>(); for (ChannelTombstone tombstone : state.tombstones()) { appendDiscoveredChannel(response, tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), false); if (tombstone.remainingDiscoveries() > 1) remaining.add(new ChannelTombstone(tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), tombstone.remainingDiscoveries() - 1)); } if (!remaining.equals(state.tombstones())) persist(new SimulatorState(state.version(), state.devices(), state.collectors(), state.nextDeviceId(), state.nextCollectorId(), remaining)); return response.append("").toString(); } private void appendDiscoveredChannel(StringBuilder response, int deviceId, String ip, int channelId, boolean available) { // 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。 DeviceState device = state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst().orElse(null); BtsDeviceProfile profile = device == null ? BtsDeviceProfile.virtual(channelId) : profileFor(device); response.append("") .append(available).append(""); } private synchronized String observationResponse(String request) { long tick = ++observationSequence; java.util.Set requested = requestedChannels(request); List available = new ArrayList<>(); for (DeviceState device : state.devices()) for (ChannelState channel : device.channels()) if (device.online() && channel.online() && (requested.isEmpty() || requested.contains(device.deviceId() + ":" + channel.channelId()))) available.add(new ObservedChannel(device.deviceId(), channel)); StringBuilder response = new StringBuilder("inquire_resp"); for (ObservedChannel observed : available) { ChannelState channel = observed.channel(); 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("") // 模拟器返回当前设备方案,供统一观测模型展示;不代表真实 BTS 的完整方案字段。 .append(""); } return response.append("").toString(); } private synchronized String historyAvailabilityResponse(String request) { java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("]*)/?>").matcher(request); StringBuilder response = new StringBuilder("inquiredf_resp"); 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("true"); } return response.append("").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 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("download_resp"); for (BtsHistoryRecord item : history.subList(from, to)) response.append(""); return response.append("").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 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 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.cycleNo(), item.stepNo(), item.stepName(), item.stepType(), item.serverReceivedAt()) : 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 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 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 history = new ArrayList<>(btsHistoryFor(key)); List 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(), now); 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 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 records, List steps, List 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 requestedChannels(String request) { java.util.Set 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 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 devices = replaceDevice(new DeviceState(current.deviceId(), current.ip(), current.online(), channels, profile)); List 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) { return switch (status) { case STOPPED -> "stop"; case WORKING -> "working"; case PAUSED -> "pause"; case FINISHED -> "finish"; default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status); }; } private void validateDeviceConfiguration(String ip, int channelCount, Integer currentDeviceId) { if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator device IP must be a valid IPv4 address"); if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT) throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT); boolean duplicate = state.devices().stream() .anyMatch(device -> device.ip().equals(ip.trim()) && device.deviceId() != (currentDeviceId == null ? -1 : currentDeviceId)); duplicate = duplicate || state.collectors().stream().anyMatch(collector -> collector.ip().equals(ip.trim())); if (duplicate) throw new IllegalArgumentException("Simulator device IP must be unique"); } private void validateCollectorConfiguration(String ip, int channelCount, Integer currentCollectorId) { if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator collector IP must be a valid IPv4 address"); if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT) throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT); boolean duplicate = state.collectors().stream() .anyMatch(collector -> collector.ip().equals(ip.trim()) && collector.collectorId() != (currentCollectorId == null ? -1 : currentCollectorId)); duplicate = duplicate || state.devices().stream().anyMatch(device -> device.ip().equals(ip.trim())); if (duplicate) throw new IllegalArgumentException("Simulator collector IP must be unique"); } private boolean isIpv4(String ip) { if (ip == null) return false; String[] parts = ip.trim().split("\\.", -1); if (parts.length != 4) return false; for (String part : parts) { try { int value = Integer.parseInt(part); if (part.isEmpty() || value < 0 || value > 255) return false; } catch (NumberFormatException exception) { return false; } } return true; } private DeviceState requireDevice(int deviceId) { return state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown simulator device: " + deviceId)); } private CollectorState requireCollector(int collectorId) { return state.collectors().stream().filter(item -> item.collectorId() == collectorId).findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown simulator collector: " + collectorId)); } private List replaceDevice(DeviceState replacement) { List devices = new ArrayList<>(state.devices()); devices.replaceAll(item -> item.deviceId() == replacement.deviceId() ? replacement : item); return devices; } private void upsertTombstone(List tombstones, int deviceId, String ip, int channelId) { tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() == channelId); tombstones.add(new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT)); } private void persist(SimulatorState nextState) { // 写穿失败时保留当前内存副本,避免控制页显示未持久化状态。 stateStore.save(nextState); state = nextState; } private void persist(SimulatorState nextState, List historyAppends) { // 主状态与原始历史批次必须一起成功,内存序号才能向前推进。 stateStore.save(nextState, historyAppends); state = nextState; } private String simulatorMac(int devtype, int devid) { return String.format("02:44:%02X:%02X:%02X:%02X", (devtype >>> 8) & 0xFF, devtype & 0xFF, (devid >>> 8) & 0xFF, devid & 0xFF); } private String simulatorCollectorMac(int collectorId) { return String.format("02:43:00:00:%02X:%02X", (collectorId >>> 8) & 0xFF, collectorId & 0xFF); } private void requireControllableStatus(DeviceWorkStatus status) { if (status == null || (status != DeviceWorkStatus.STOPPED && status != DeviceWorkStatus.WORKING && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED)) throw new IllegalArgumentException("Simulator work status must be STOPPED, WORKING, PAUSED or FINISHED"); } private void validateRestoredState() { java.util.Set deviceIds = new java.util.HashSet<>(); java.util.Set collectorIds = new java.util.HashSet<>(); java.util.Set ips = new java.util.HashSet<>(); int maximumDeviceId = 0; for (DeviceState device : state.devices()) { if (device.deviceId() < 1 || !deviceIds.add(device.deviceId()) || !isIpv4(device.ip()) || !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++) { ChannelState channel = device.channels().get(index); if (channel.channelId() != index + 1) throw new IllegalStateException("Invalid BTS simulator channel state in Redis"); requireControllableStatus(channel.workStatus()); } } int maximumCollectorId = 0; for (CollectorState collector : state.collectors()) { if (collector.collectorId() < 1 || !collectorIds.add(collector.collectorId()) || !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 tombstoneIds = new java.util.HashSet<>(); boolean invalidTombstone = state.tombstones().stream().anyMatch(item -> item.deviceId() < 1 || !isIpv4(item.ip()) || item.channelId() < 1 || item.channelId() > MAX_CHANNEL_COUNT || item.remainingDiscoveries() < 1 || item.remainingDiscoveries() > TOMBSTONE_DISCOVERY_COUNT || !tombstoneIds.add(item.deviceId() + ":" + item.channelId())); if (state.nextDeviceId() <= maximumDeviceId || state.nextCollectorId() <= maximumCollectorId || invalidTombstone) throw new IllegalStateException("Invalid BTS simulator numbering or tombstone state in Redis"); } @Override public synchronized void close() { running = false; try { simulator.close(); } catch (Exception exception) { throw new IllegalStateException("关闭开发模拟器失败", exception); } } private record ObservedChannel(int deviceId, ChannelState channel) { } public record StateSnapshot(boolean running, String listenAddress, List devices, List collectors, List recentRequests) { } public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile, List 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, int sampleIntervalMs, List channels, CollectorHistoryFaults historyFaults, long historyRecordCount, List latestMeasurements) { public CollectorSnapshot { channels = List.copyOf(channels); latestMeasurements = List.copyOf(latestMeasurements); } } }