package com.ruoyi.device.bts;
|
|
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.ruoyi.device.model.DeviceWorkStatus;
|
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
|
{
|
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<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
|
{
|
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<DeviceState> 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<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(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);
|
}
|
|
/** 删除指定开发设备;后续发现响应不再返回它。 */
|
public synchronized void deleteDevice(int deviceId)
|
{
|
DeviceState removed = requireDevice(deviceId);
|
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));
|
}
|
|
/** 修改指定开发设备的单个通道状态。 */
|
public synchronized void updateChannel(int deviceId, int channelId, boolean online,
|
DeviceWorkStatus workStatus)
|
{
|
requireControllableStatus(workStatus);
|
DeviceState device = requireDevice(deviceId);
|
if (channelId < 1 || channelId > device.channels().size())
|
throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
|
List<ChannelState> channels = new ArrayList<>(device.channels());
|
channels.set(channelId - 1, new ChannelState(channelId, online, workStatus));
|
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<CollectorState> 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<CollectorState> 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<CollectorState> 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<CollectorMetricState> metrics)
|
{
|
CollectorState current = requireCollector(collectorId);
|
replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
|
}
|
|
/**
|
* 模拟现场人工统一开关机:关机停止采样但保留历史,开机后检测柜通道保持待机。
|
* 该操作只改变模拟设备事实,不代表系统具备真实设备控制能力。
|
*/
|
public synchronized void setAllDevicesPoweredOn(boolean poweredOn)
|
{
|
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();
|
Instant changedAt = Instant.now();
|
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()));
|
}
|
|
/** 按每台采集器自己的频率推进实时和历史采样。 */
|
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);
|
btsHistory.remove(key);
|
stateStore.deleteBtsHistory(key);
|
stateStore.deleteBtsPlaybackState(key);
|
savePlayback(key, defaultPlayback(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)));
|
}
|
|
/** 按实际出现的循环和工步汇总 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();
|
grouped.computeIfAbsent(key, ignored -> new BtsStepAccumulator(item)).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)
|
{
|
DeviceState device = requireDevice(deviceId);
|
requireChannel(device, channelId);
|
BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
|
java.util.Map<Integer, BtsExcelFixture.FixtureRecord> firstByStep = new java.util.LinkedHashMap<>();
|
for (BtsExcelFixture.FixtureRecord item : BtsExcelFixture.records())
|
firstByStep.putIfAbsent(item.stepNo(), item);
|
|
List<BtsTestPlanRow> rows = new ArrayList<>();
|
for (BtsExcelFixture.FixtureRecord first : firstByStep.values())
|
{
|
List<BtsExcelFixture.FixtureRecord> sameCycle = BtsExcelFixture.records().stream()
|
.filter(item -> item.cycleNo() == first.cycleNo() && item.stepNo() == first.stepNo()).toList();
|
BtsExcelFixture.FixtureRecord last = sameCycle.get(sameCycle.size() - 1);
|
String status = playback.naturallyFinished() || playback.cycleNo() > first.cycleNo()
|
|| (playback.cycleNo() == first.cycleNo() && playback.stepNo() > first.stepNo())
|
? "已完成"
|
: playback.cycleNo() == first.cycleNo() && playback.stepNo() == first.stepNo()
|
? "进行中" : "待执行";
|
rows.add(new BtsTestPlanRow("STEP", first.stepNo(), first.stepName(),
|
Math.max(0d, last.offsetSeconds() - first.offsetSeconds()), first.voltageV(), status,
|
null, null));
|
}
|
int firstStepNo = firstByStep.values().stream().findFirst().map(BtsExcelFixture.FixtureRecord::stepNo).orElse(0);
|
rows.add(new BtsTestPlanRow("CYCLE", firstStepNo, "循环", 0d, null,
|
playback.naturallyFinished() ? "已完成" : "待执行", BtsExcelFixture.cycleCount(),
|
(long) BtsExcelFixture.recordCount()));
|
rows.add(new BtsTestPlanRow("END", 0, "结束", 0d, null,
|
playback.naturallyFinished() ? "已完成" : "待执行", null, (long) BtsExcelFixture.recordCount()));
|
return new BtsTestSnapshot(playback.cycleNo(), 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(), device.profile(),
|
device.channels().stream()
|
.map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(),
|
channel.workStatus()))
|
.toList()))
|
.toList();
|
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.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<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, 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<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);
|
recordRequest(command, request);
|
return switch (command)
|
{
|
case "getdevinfo" -> deviceInfoResponse();
|
case "inquire" -> observationResponse(request);
|
case "inquiredf" -> historyAvailabilityResponse(request);
|
case "download" -> historyResponse(request);
|
default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
|
};
|
}
|
|
private synchronized void recordRequest(String command, String request)
|
{
|
List<RecentRequest> 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("<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>")
|
.append("<serverip count=\"0\"/><middle count=\"").append(channelCount).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<ChannelTombstone> 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("</middle></bts>").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("<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(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()
|
&& (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();
|
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=\"").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, 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, 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 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) { cycle = first.cycleNo(); step = first.stepNo(); 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, 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)
|
{
|
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<DeviceState> replaceDevice(DeviceState replacement)
|
{
|
List<DeviceState> devices = new ArrayList<>(state.devices());
|
devices.replaceAll(item -> item.deviceId() == replacement.deviceId() ? replacement : item);
|
return devices;
|
}
|
|
private void upsertTombstone(List<ChannelTombstone> 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<CollectorHistoryAppend> 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<Integer> deviceIds = new java.util.HashSet<>();
|
java.util.Set<Integer> collectorIds = new java.util.HashSet<>();
|
java.util.Set<String> 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<String> 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() throws Exception
|
{
|
running = false;
|
simulator.close();
|
}
|
|
private record ObservedChannel(int deviceId, ChannelState channel) { }
|
|
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, 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, int sampleIntervalMs,
|
List<CollectorChannelState> channels, CollectorHistoryFaults historyFaults, long historyRecordCount,
|
List<CollectorRealtimeMeasurement> latestMeasurements)
|
{
|
public CollectorSnapshot
|
{
|
channels = List.copyOf(channels);
|
latestMeasurements = List.copyOf(latestMeasurements);
|
}
|
}
|
|
}
|