| | |
| | | import java.time.ZoneId; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import javax.xml.stream.XMLInputFactory; |
| | | import javax.xml.stream.XMLStreamConstants; |
| | | import javax.xml.stream.XMLStreamReader; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.api.model.DeviceConnectionStatus; |
| | | import com.battery.device.api.model.DeviceIdentity; |
| | |
| | | import com.battery.device.api.model.DetectedDeviceMetadata; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | | import com.battery.device.api.DeviceObservationAdapter; |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration; |
| | | 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 TestDataObservationAdapter |
| | | /** |
| | | * 提供由开发后端托管的临时只读 BTS 模拟端点。 |
| | | * 同时实现发现与测试数据观测契约:模拟器直接暴露自身状态,因此发现与取数按同一实例解析, |
| | | * 不再需要单独注入一个扩展观测 Bean。TCP 端点仍保留给协议层测试使用。 |
| | | */ |
| | | public final class BtsDevelopmentSimulator implements DeviceAdapter, TestDataObservationAdapter |
| | | { |
| | | public static final String HOST = "127.0.0.1"; |
| | | public static final int DEFAULT_PORT = 15020; |
| | |
| | | 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")); |
| | | private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong"); |
| | | private static final DateTimeFormatter DEVICE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); |
| | | |
| | | /** 返回固定的适配器实例代码,用于把模拟观测归属到 dev-sim 这一实例。 */ |
| | | @Override |
| | | public String adapterCode() |
| | | { |
| | | return ADAPTER_CODE; |
| | | } |
| | | |
| | | /** 返回只观测能力:支持发现与实时观测,不支持历史下载,也不支持向设备下发控制命令。 */ |
| | | @Override |
| | | public AdapterCapabilities capabilities() |
| | | { |
| | |
| | | { |
| | | 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(), |
| | | result.add(new DetectedChannel(identity, device.ip(), state.softwareAccessible() && device.online() && channel.online(), |
| | | new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(), |
| | | simulatorMac(profile.devtype(), device.deviceId())))); |
| | | } |
| | | } |
| | | recordRequest("discover", ADAPTER_CODE, result.toString()); |
| | | return List.copyOf(result); |
| | | } |
| | | |
| | |
| | | { |
| | | if (channels == null || channels.isEmpty()) return java.util.Map.of(); |
| | | java.util.Map<ChannelIdentity, DeviceObservation> result = new java.util.LinkedHashMap<>(); |
| | | for (ChannelIdentity requested : channels) result.put(requested, observation(requested)); |
| | | recordRequest("observe", channels.toString(), result.toString()); |
| | | return java.util.Map.copyOf(result); |
| | | } |
| | | |
| | | /** 直接提供模拟器实时快照;不可观测通道不出现在结果里,与 BTS 协议解析后的语义一致。 */ |
| | | @Override |
| | | public synchronized Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels) |
| | | { |
| | | if (channels == null || channels.isEmpty()) return Map.of(); |
| | | Map<ChannelIdentity, RealtimeSnapshot> result = new 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)); |
| | | if (!canObserve(requested)) continue; |
| | | CabinetRealtime measurement = cabinetMeasurement(requested); |
| | | if (measurement == null) continue; |
| | | DeviceObservation observed = observation(requested); |
| | | result.put(requested, new RealtimeSnapshot(requested, observed.workStatus(), measurement.cycleNo(), |
| | | measurement.stepNo(), measurement.stepType(), null, BigDecimal.valueOf(measurement.currentA()), |
| | | BigDecimal.valueOf(measurement.voltageV()), BigDecimal.valueOf(measurement.capacityAh()), |
| | | BigDecimal.valueOf(measurement.energyWh()), totalTimeSeconds(requested), |
| | | measurement.relativeTimeSeconds(), null, measurement.serverReceivedAt(), Map.of(), |
| | | DeviceConnectionStatus.ONLINE, measurement.deviceTime(), List.of(), null)); |
| | | } |
| | | return java.util.Map.copyOf(result); |
| | | recordRequest("inquire", channels.toString(), result.toString()); |
| | | return Map.copyOf(result); |
| | | } |
| | | |
| | | private long totalTimeSeconds(ChannelIdentity identity) |
| | | { |
| | | return requireDevice(identity.devid()).channels().get(identity.chlid() - 1).configuration().totalTimeSeconds(); |
| | | } |
| | | |
| | | private DeviceObservation observation(ChannelIdentity requested) |
| | | { |
| | | 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 (!state.softwareAccessible() || device == null || channel == null |
| | | || !ADAPTER_CODE.equals(requested.adapterCode()) || !device.online() || !channel.online() |
| | | || requested.devtype() != profileFor(device).devtype() |
| | | || requested.subdevid() != profileFor(device).subDeviceId()) |
| | | return DeviceObservation.offline(requested, failureReason()); |
| | | return new DeviceObservation(new DeviceIdentity(ADAPTER_CODE, requested.devtype(), requested.devid(), |
| | | requested.subdevid()), requested, DeviceConnectionStatus.ONLINE, channel.workStatus(), |
| | | List.of(), null, java.util.Map.of(), null); |
| | | } |
| | | |
| | | /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */ |
| | |
| | | { |
| | | this.stateStore = stateStore; |
| | | state = stateStore.loadOrInitialize(defaultState()); |
| | | // 仅清理嵌在主状态中的旧演示历史;独立历史由模拟器存储保留。 |
| | | state = stateStore.resetHistoryForDemoStartup(state); |
| | | restoreBtsHistory(); |
| | | validateRestoredState(); |
| | | stateStore.completeValidatedRestore(state); |
| | | simulator = ScriptedBtsTcpSimulator.responding(port, this::respond); |
| | | } |
| | | |
| | | /** 返回模拟器 TCP 端点实际监听的端口。 */ |
| | | public int port() |
| | | { |
| | | return simulator.port(); |
| | |
| | | state.nextCollectorId(), tombstones)); |
| | | } |
| | | |
| | | /** 使用现场确认的新威检测柜 profile 新增设备,型号之外的控制语义仍保持只读。 */ |
| | | /** 按预设新威型号元数据新增模拟设备;真实协议和拓扑仍待现场验证。 */ |
| | | public synchronized int addNewareDevice(String ip, String model) |
| | | { |
| | | BtsDeviceProfile profile = newareProfile(model); |
| | |
| | | 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)); |
| | |
| | | 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)); |
| | | channels.set(channelId - 1, new ChannelState(channelId, online, workStatus, |
| | | channels.get(channelId - 1).configuration())); |
| | | persist(new SimulatorState(state.version(), |
| | | replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(), |
| | | state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); |
| | | } |
| | | |
| | | /** 保存单通道的当前测量和工步方案,不改写在线或工作状态,不产生观测。 */ |
| | | public synchronized void configureChannel(int deviceId, int channelId, ChannelConfiguration configuration) |
| | | { |
| | | if (configuration == null) throw new IllegalArgumentException("通道配置不能为空"); |
| | | DeviceState device = requireDevice(deviceId); |
| | | if (channelId < 1 || channelId > device.channels().size()) |
| | | throw new IllegalArgumentException("通道不存在"); |
| | | List<ChannelState> channels = new ArrayList<>(device.channels()); |
| | | ChannelState channel = channels.get(channelId - 1); |
| | | channels.set(channelId - 1, new ChannelState(channelId, channel.online(), channel.workStatus(), configuration)); |
| | | 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 检测柜拓扑隔离的开发采集器。 */ |
| | |
| | | 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; |
| | | persist(next); |
| | | } |
| | | |
| | | /** 配置采集器独立采样频率和历史下载故障场景。 */ |
| | | public synchronized void configureCollectorSampling(int collectorId, int sampleIntervalMs, |
| | | CollectorHistoryFaults historyFaults) |
| | | { |
| | | CollectorState current = requireCollector(collectorId); |
| | | replaceCollector(DevelopmentCollectorSimulation.configureSampling(current, sampleIntervalMs, historyFaults)); |
| | | } |
| | | |
| | | /** 配置一个稳定 S 通道的固定或动态演示指标。 */ |
| | | /** 保存稳定 S 通道的固定测量值及自定义指标,下一次请求时形成观测。 */ |
| | | public synchronized void configureCollectorChannel(int collectorId, String channelCode, |
| | | List<CollectorMetricState> metrics) |
| | | { |
| | |
| | | replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics)); |
| | | } |
| | | |
| | | /** |
| | | * 模拟现场人工统一开关机:关机停止采样,开机重启检测柜夹具生命周期并保持通道待机。 |
| | | * 采集器历史继续保留,检测柜夹具历史在关机后的开机上升沿清理。 |
| | | * 该操作只改变模拟设备事实,不代表系统具备真实设备控制能力。 |
| | | */ |
| | | /** 修改软件可访问性,不改变设备身份、在线配置或通道工作状态。 */ |
| | | public synchronized void setSoftwareAccessible(boolean accessible) |
| | | { |
| | | SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(), |
| | | state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), accessible, state.observationSequence()); |
| | | stateStore.save(next); |
| | | state = next; |
| | | } |
| | | |
| | | /** 批量修改设备在线事实,保留各通道工作状态。 */ |
| | | public synchronized void setAllDevicesPoweredOn(boolean poweredOn) |
| | | { |
| | | boolean wasPoweredOff = (!state.devices().isEmpty() || !state.collectors().isEmpty()) |
| | | && state.devices().stream().noneMatch(DeviceState::online) |
| | | && state.collectors().stream().noneMatch(CollectorState::online); |
| | | Instant changedAt = Instant.now(); |
| | | if (poweredOn && wasPoweredOff) |
| | | { |
| | | // 一键开机代表新一轮演示;清理检测柜夹具,避免序号从 1 重放并与旧生命周期冲突。 |
| | | for (DeviceState device : state.devices()) |
| | | for (ChannelState channel : device.channels()) |
| | | resetBtsFixtureLifecycle(btsKey(device, channel.channelId()), changedAt); |
| | | } |
| | | List<DeviceState> devices = state.devices().stream() |
| | | .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn, |
| | | device.channels().stream() |
| | | .map(channel -> new ChannelState(channel.channelId(), poweredOn, |
| | | DeviceWorkStatus.STOPPED)) |
| | | .toList(), device.profile())) |
| | | .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn, device.channels(), device.profile())) |
| | | .toList(); |
| | | List<CollectorState> collectors = state.collectors().stream() |
| | | .map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(), |
| | | collector.channelCount(), poweredOn, changedAt)) |
| | | .toList(); |
| | | collector.channelCount(), poweredOn, Instant.now())).toList(); |
| | | persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(), |
| | | state.nextCollectorId(), state.tombstones())); |
| | | } |
| | | |
| | | /** 清理检测柜夹具的历史和播放指针;采集器历史由总电源开关独立保留。 */ |
| | | private void resetBtsFixtureLifecycle(String key, Instant now) |
| | | /** 修改采集器单个通道工作状态;不代表真实采集器控制能力。 */ |
| | | public synchronized void updateCollectorChannelStatus(int collectorId, String channelCode, DeviceWorkStatus status) |
| | | { |
| | | btsHistory.remove(key); |
| | | stateStore.deleteBtsHistory(key); |
| | | stateStore.deleteBtsPlaybackState(key); |
| | | savePlayback(key, defaultPlayback(now)); |
| | | requireControllableStatus(status); |
| | | replaceCollector(DevelopmentCollectorSimulation.configureStatus(requireCollector(collectorId), channelCode, status)); |
| | | } |
| | | |
| | | /** 按每台采集器自己的频率推进实时和历史采样。 */ |
| | | public synchronized void advanceCollectors(Instant now) |
| | | { |
| | | boolean hasOnlineCollector = false; |
| | | for (CollectorState collector : state.collectors()) |
| | | { |
| | | if (!collector.online()) continue; |
| | | hasOnlineCollector = true; |
| | | break; |
| | | } |
| | | if (!hasOnlineCollector) return; |
| | | List<CollectorState> collectors = new ArrayList<>(state.collectors().size()); |
| | | List<CollectorHistoryAppend> historyAppends = new ArrayList<>(); |
| | | for (CollectorState collector : state.collectors()) |
| | | { |
| | | CollectorAdvance advance = DevelopmentCollectorSimulation.advance(collector, now); |
| | | collectors.add(advance.state()); |
| | | if (!advance.appendedRecords().isEmpty()) |
| | | historyAppends.add(new CollectorHistoryAppend(collector.deviceCode(), advance.appendedRecords())); |
| | | } |
| | | if (!historyAppends.isEmpty()) |
| | | persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), |
| | | state.nextCollectorId(), state.tombstones()), historyAppends); |
| | | } |
| | | |
| | | /** 按墙上时间推进检测柜模拟播放;设备状态和测试接收状态互不影响。 */ |
| | | public synchronized void advanceBts(Instant now) |
| | | { |
| | | for (DeviceState device : state.devices()) |
| | | { |
| | | if (!device.online()) continue; |
| | | BtsDeviceProfile profile = profileFor(device); |
| | | for (ChannelState channel : device.channels()) |
| | | { |
| | | if (!channel.online() || channel.workStatus() != DeviceWorkStatus.WORKING) continue; |
| | | String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId()); |
| | | BtsPlaybackState playback = playbackState(key, now); |
| | | advanceBtsPlayback(key, profile, playback, |
| | | profile.currentMaxA().doubleValue() * 0.2d, 3.7d, now); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** 设置检测柜通道演示倍速,只接受 1x、60x 和 600x。 */ |
| | | public synchronized void setBtsSpeed(int deviceId, int channelId, int speedMultiplier) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | String key = btsKey(device, channelId); |
| | | BtsPlaybackState current = playbackState(key, Instant.now()); |
| | | stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(current.playing(), speedMultiplier, |
| | | current.cycleNo(), current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(), |
| | | Instant.now(), current.nextSequence(), current.naturallyFinished())); |
| | | } |
| | | |
| | | /** 开始播放指定检测柜通道的演示时间线。 */ |
| | | public synchronized void playBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); } |
| | | /** 暂停模拟器时间线,不改变检测柜通道工作事实。 */ |
| | | public synchronized void pauseBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, false); } |
| | | /** 从暂停状态恢复模拟器时间线。 */ |
| | | public synchronized void resumeBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); } |
| | | |
| | | private void setBtsPlaying(int deviceId, int channelId, boolean playing) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | String key = btsKey(device, channelId); |
| | | Instant now = Instant.now(); |
| | | BtsPlaybackState current = playbackState(key, now); |
| | | if (current.naturallyFinished() && playing) return; |
| | | stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(playing, current.speedMultiplier(), current.cycleNo(), |
| | | current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(), now, |
| | | current.nextSequence(), current.naturallyFinished())); |
| | | } |
| | | |
| | | /** 跳转到下一实际工步;不向设备发送控制报文。 */ |
| | | public synchronized void nextBtsStep(int deviceId, int channelId) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | String key = btsKey(device, channelId); |
| | | BtsPlaybackState current = playbackState(key, Instant.now()); |
| | | BtsExcelFixture.FixtureRecord next = nextFixtureStep(current.cycleNo(), current.stepNo()); |
| | | if (next == null) |
| | | { |
| | | BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount()); |
| | | savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(), |
| | | last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(), |
| | | BtsExcelFixture.recordCount() + 1L, true)); |
| | | return; |
| | | } |
| | | savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(), |
| | | next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false)); |
| | | } |
| | | |
| | | /** 跳转到下一循环的首工步;不补齐当前循环缺失的首尾工步。 */ |
| | | public synchronized void nextBtsCycle(int deviceId, int channelId) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | String key = btsKey(device, channelId); |
| | | BtsPlaybackState current = playbackState(key, Instant.now()); |
| | | BtsExcelFixture.FixtureRecord next = BtsExcelFixture.records().stream() |
| | | .filter(item -> item.cycleNo() > current.cycleNo()).findFirst().orElse(null); |
| | | if (next == null) |
| | | { |
| | | BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount()); |
| | | savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(), |
| | | last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(), |
| | | BtsExcelFixture.recordCount() + 1L, true)); |
| | | return; |
| | | } |
| | | savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(), |
| | | next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false)); |
| | | } |
| | | |
| | | /** 清空单通道播放和原始数据,恢复到首循环首工步。 */ |
| | | public synchronized void resetBts(int deviceId, int channelId) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | String key = btsKey(device, channelId); |
| | | resetBtsFixtureLifecycle(key, Instant.now()); |
| | | } |
| | | |
| | | /** 返回播放状态及当前通道的 record、step、cycle 数据。 */ |
| | | public synchronized PlaybackSnapshot btsPlayback(int deviceId, int channelId) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | String key = btsKey(device, channelId); |
| | | BtsPlaybackState state = playbackState(key, Instant.now()); |
| | | return new PlaybackSnapshot(deviceId, channelId, state, btsRecords(deviceId, channelId), |
| | | btsSteps(deviceId, channelId), btsCycles(deviceId, channelId)); |
| | | } |
| | | |
| | | /** 返回只追加的检测柜原始 record。 */ |
| | | public synchronized List<BtsHistoryRecord> btsRecords(int deviceId, int channelId) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | return List.copyOf(btsHistoryFor(btsKey(device, channelId))); |
| | | } |
| | | |
| | | /** 返回开发模拟器当前通道的持久化槽位间隔;仅供开发调度层使用。 */ |
| | | public synchronized long persistenceIntervalMillis(int deviceId, int channelId) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now()); |
| | | return Math.max(100L, 60000L / Math.max(1, playback.speedMultiplier())); |
| | | } |
| | | |
| | | /** 返回通道已配置的开发方案及当前工步位置,不自动推进或生成历史。 */ |
| | | @Override |
| | | public synchronized TestPlan testPlan(ChannelIdentity channel) |
| | | { |
| | | if (!canObserve(channel)) return null; |
| | | var plan = btsTest(channel.devid(), channel.chlid()); |
| | | return new TestPlan(plan.currentCycleNo(), plan.currentStepNo(), plan.plannedCycleCount(), |
| | | plan.rows().stream().map(row -> new PlanRow(row.rowType(), row.stepNo(), row.cyclePosition(), |
| | | row.stepName(), row.durationSeconds(), row.voltageV(), row.executionStatus(), |
| | | row.cycleCount(), row.recordCount())).toList()); |
| | | ChannelState source = requireDevice(channel.devid()).channels().get(channel.chlid() - 1); |
| | | ChannelConfiguration configuration = source.configuration(); |
| | | List<PlanRow> rows = new ArrayList<>(); |
| | | for (int index = 0; index < configuration.steps().size(); index++) { |
| | | var step = configuration.steps().get(index); |
| | | String status = index + 1 < configuration.stepNo() ? "已完成" : "待执行"; |
| | | if (index + 1 == configuration.stepNo()) status = switch (source.workStatus()) { |
| | | case WORKING -> "进行中"; |
| | | case PAUSED -> "暂停"; |
| | | case FINISHED -> "已完成"; |
| | | case PROTECTED -> "异常"; |
| | | default -> "待执行"; |
| | | }; |
| | | // cyclePosition 表示循环内的工步位置,不能使用当前循环号,否则控制台会合并不同工步。 |
| | | rows.add(new PlanRow("STEP", index + 1, index + 1, step.name(), step.durationSeconds(), |
| | | step.voltageV(), status, null, null)); |
| | | } |
| | | return new TestPlan(configuration.cycleNo(), configuration.stepNo(), configuration.plannedCycleCount(), rows); |
| | | } |
| | | |
| | | /** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */ |
| | | @Override |
| | | public synchronized CabinetRealtime latestCabinet(ChannelIdentity channel) |
| | | { |
| | | if (!canObserve(channel)) return null; |
| | | BtsHistoryRecord record = btsRecords(channel.devid(), channel.chlid()).stream() |
| | | .max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).orElse(null); |
| | | return record == null ? null : new CabinetRealtime(record.sequenceId(), record.deviceTime(), record.currentA(), |
| | | record.voltageV(), record.capacityAh(), record.energyWh(), record.cycleNo(), record.stepNo(), |
| | | record.stepName(), record.stepType(), record.serverReceivedAt(), record.deviceTime().toString(), "UTC"); |
| | | CabinetRealtime result = canObserve(channel) ? cabinetMeasurement(channel) : null; |
| | | recordRequest("cabinet-realtime", String.valueOf(channel), |
| | | result == null ? failureReason() : result.toString()); |
| | | return result; |
| | | } |
| | | |
| | | @Override |
| | | public synchronized CollectorRealtime latestCollector(String collectorCode) |
| | | private CabinetRealtime cabinetMeasurement(ChannelIdentity identity) |
| | | { |
| | | var collector = snapshot().collectors().stream().filter(item -> item.deviceCode().equals(collectorCode)) |
| | | DeviceState device = requireDevice(identity.devid()); |
| | | ChannelState channel = device.channels().get(identity.chlid() - 1); |
| | | if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) return null; |
| | | Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); |
| | | long sequence = nextObservationSequence(); |
| | | ChannelConfiguration configuration = channel.configuration(); |
| | | var step = configuration.steps().get(configuration.stepNo() - 1); |
| | | // 保存值直接成为当前观测;工作状态和设备量程不反向覆盖操作员配置。 |
| | | CabinetRealtime result = new CabinetRealtime(sequence, now, configuration.currentA().doubleValue(), |
| | | configuration.voltageV().doubleValue(), configuration.capacityAh().doubleValue(), configuration.energyWh().doubleValue(), |
| | | configuration.cycleNo(), configuration.stepNo(), step.name(), step.stepType(), |
| | | now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId(), configuration.relativeTimeSeconds()); |
| | | return result; |
| | | } |
| | | |
| | | /** 请求采集器当前配置值;软件不可访问或设备离线时返回空测量。 */ |
| | | @Override |
| | | public synchronized CollectorRealtime latestCollector(String adapterCode, String collectorCode) |
| | | { |
| | | CollectorState collector = state.collectors().stream().filter(item -> item.deviceCode().equals(collectorCode)) |
| | | .findFirst().orElse(null); |
| | | if (collector == null) return null; |
| | | CollectorRealtimeSnapshot snapshot = collectorRealtime(collector.collectorId()); |
| | | var snapshot = collectorRealtime(collector.collectorId()); |
| | | return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(), |
| | | snapshot.measurements().stream().map(item -> new CollectorMeasurement(item.channelCode(), item.sequenceId(), |
| | | item.deviceTime(), item.deviceTimeRaw(), item.serverReceivedAt(), item.metricCode(), item.value(), |
| | | item.unit())).toList(), "Asia/Hong_Kong"); |
| | | item.unit())).toList(), DEVICE_ZONE.getId()); |
| | | } |
| | | |
| | | /** 模拟器不提供历史工步统计。 */ |
| | | @Override |
| | | public synchronized List<StepRecord> stepRecords(ChannelIdentity channel) |
| | | { |
| | | if (!canObserve(channel)) return List.of(); |
| | | var playback = playbackState(btsKey(requireDevice(channel.devid()), channel.chlid()), Instant.now()); |
| | | // 仅发布已离开的工步;手动跳转也可结束工步,不用记录数作为完整性门禁。 |
| | | return btsSteps(channel.devid(), channel.chlid()).stream().filter(item -> playback.naturallyFinished() |
| | | || item.cycleNo() != playback.cycleNo() || item.stepNo() != playback.stepNo()).map(item -> new StepRecord(item.recordNo(), |
| | | item.cycleNo(), item.stepNo(), item.stepName(), item.stepType(), item.startedAt(), item.endedAt(), |
| | | item.endVoltageV(), item.capacityAh(), item.energyWh(), item.endedAt().toString(), "UTC", receivedAt(channel, item.endedAt()), |
| | | java.util.Map.of("stepDuration", java.math.BigDecimal.valueOf(item.durationSeconds()), |
| | | "startVoltage", java.math.BigDecimal.valueOf(item.startVoltageV()), |
| | | "chargePeakVoltage", java.math.BigDecimal.valueOf(item.chargePeakVoltageV()), |
| | | "dischargeValleyVoltage", java.math.BigDecimal.valueOf(item.dischargeValleyVoltageV())))).toList(); |
| | | } |
| | | public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); } |
| | | |
| | | /** 模拟器不提供历史循环统计。 */ |
| | | @Override |
| | | public synchronized List<CycleRecord> cycleRecords(ChannelIdentity channel) |
| | | { |
| | | if (!canObserve(channel)) return List.of(); |
| | | var playback = playbackState(btsKey(requireDevice(channel.devid()), channel.chlid()), Instant.now()); |
| | | return btsCycles(channel.devid(), channel.chlid()).stream().filter(item -> playback.naturallyFinished() |
| | | || item.cycleNo() != playback.cycleNo()).map(item -> new CycleRecord(item.recordNo(), |
| | | item.cycleNo(), item.startedAt(), item.endedAt(), item.chargeCapacityAh(), item.dischargeCapacityAh(), |
| | | item.chargeEnergyWh(), item.dischargeEnergyWh(), item.efficiency(), item.endedAt().toString(), "UTC", receivedAt(channel, item.endedAt()))).toList(); |
| | | } |
| | | public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); } |
| | | |
| | | /** 使用后端正常持久化周期,不提供开发倍速。 */ |
| | | @Override |
| | | public synchronized long persistenceIntervalMillis(ChannelIdentity channel) |
| | | { return canObserve(channel) ? persistenceIntervalMillis(channel.devid(), channel.chlid()) : 60000L; } |
| | | public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; } |
| | | |
| | | private boolean canObserve(ChannelIdentity channel) |
| | | { |
| | | // 全身份与在线事实共同限定来源,避免同编号的其他适配器被写入模拟数据。 |
| | | return channel != null && observe(java.util.Set.of(channel)).get(channel).connectionStatus() == DeviceConnectionStatus.ONLINE; |
| | | // 完整来源身份限定数据归属;不可访问和离线只改变观测结果。 |
| | | return channel != null && observation(channel).connectionStatus() == DeviceConnectionStatus.ONLINE |
| | | && observation(channel).workStatus() != DeviceWorkStatus.UNOBSERVABLE; |
| | | } |
| | | |
| | | private Instant receivedAt(ChannelIdentity channel, Instant endedAt) |
| | | { |
| | | return btsRecords(channel.devid(), channel.chlid()).stream().filter(row -> row.deviceTime().equals(endedAt)) |
| | | .map(BtsHistoryRecord::serverReceivedAt).filter(java.util.Objects::nonNull).max(Instant::compareTo).orElse(null); |
| | | } |
| | | |
| | | /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */ |
| | | public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId) |
| | | { |
| | | java.util.Map<String, BtsStepAccumulator> grouped = new java.util.LinkedHashMap<>(); |
| | | for (BtsHistoryRecord item : btsRecords(deviceId, channelId)) |
| | | { |
| | | String key = item.cycleNo() + ":" + item.stepNo(); |
| | | int position = BtsExcelFixture.records().stream() |
| | | .filter(record -> record.cycleNo() == item.cycleNo()) |
| | | .map(BtsExcelFixture.FixtureRecord::stepNo).distinct().toList().indexOf(item.stepNo()) + 1; |
| | | grouped.computeIfAbsent(key, ignored -> new BtsStepAccumulator(item, position)).add(item); |
| | | } |
| | | return grouped.values().stream().map(BtsStepAccumulator::finish).toList(); |
| | | } |
| | | |
| | | /** 按实际出现的循环汇总 record,不增加测试结束统计行。 */ |
| | | public synchronized List<BtsCycleRecord> btsCycles(int deviceId, int channelId) |
| | | { |
| | | java.util.Map<Integer, BtsCycleAccumulator> grouped = new java.util.LinkedHashMap<>(); |
| | | for (BtsHistoryRecord item : btsRecords(deviceId, channelId)) |
| | | grouped.computeIfAbsent(item.cycleNo(), ignored -> new BtsCycleAccumulator(item)).add(item); |
| | | return grouped.entrySet().stream().map(entry -> { |
| | | BtsCycleRecord calculated = entry.getValue().finish(); |
| | | BtsExcelFixture.CycleSummary expected = BtsExcelFixture.cycle(entry.getKey()); |
| | | String recordNo = "CYCLE-" + deviceId + "-" + channelId + "-" + entry.getKey(); |
| | | if (expected == null || entry.getValue().count < expected.recordCount()) |
| | | return new BtsCycleRecord(recordNo, calculated.cycleNo(), calculated.recordCount(), calculated.startedAt(), calculated.endedAt(), |
| | | calculated.chargeCapacityAh(), calculated.dischargeCapacityAh(), calculated.chargeEnergyWh(), calculated.dischargeEnergyWh(), calculated.efficiency()); |
| | | return new BtsCycleRecord(recordNo, calculated.cycleNo(), expected.recordCount(), calculated.startedAt(), |
| | | calculated.endedAt(), expected.chargeCapacityAh(), expected.dischargeCapacityAh(), |
| | | expected.chargeEnergyWh(), expected.dischargeEnergyWh(), |
| | | efficiency(expected.chargeCapacityAh(), expected.dischargeCapacityAh())); |
| | | }).toList(); |
| | | } |
| | | |
| | | /** 返回检测柜测试方案事实;方案行来自 Excel 夹具,当前状态来自该通道播放状态。 */ |
| | | public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId) |
| | | { return btsTest(deviceId, channelId, null); } |
| | | |
| | | /** 返回指定循环的检测柜测试方案;未开始接收的测试固定使用第一个循环作为核对上下文。 */ |
| | | public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId, Integer forcedCycleNo) |
| | | { |
| | | DeviceState device = requireDevice(deviceId); |
| | | requireChannel(device, channelId); |
| | | BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now()); |
| | | int requestedCycleNo = forcedCycleNo == null ? playback.cycleNo() : forcedCycleNo; |
| | | int currentCycleNo = requestedCycleNo; |
| | | List<BtsExcelFixture.FixtureRecord> currentCycleRecords = BtsExcelFixture.records().stream() |
| | | .filter(item -> item.cycleNo() == requestedCycleNo).toList(); |
| | | if (currentCycleRecords.isEmpty()) |
| | | { |
| | | int fallbackCycleNo = BtsExcelFixture.records().get(0).cycleNo(); |
| | | currentCycleNo = fallbackCycleNo; |
| | | currentCycleRecords = BtsExcelFixture.records().stream() |
| | | .filter(item -> item.cycleNo() == fallbackCycleNo).toList(); |
| | | } |
| | | java.util.Map<Integer, BtsExcelFixture.FixtureRecord> firstByStep = new java.util.LinkedHashMap<>(); |
| | | for (BtsExcelFixture.FixtureRecord item : currentCycleRecords) |
| | | firstByStep.putIfAbsent(item.stepNo(), item); |
| | | |
| | | List<BtsTestPlanRow> rows = new ArrayList<>(); |
| | | int cyclePosition = 1; |
| | | for (BtsExcelFixture.FixtureRecord first : firstByStep.values()) |
| | | { |
| | | List<BtsExcelFixture.FixtureRecord> sameCycle = currentCycleRecords.stream() |
| | | .filter(item -> item.stepNo() == first.stepNo()).toList(); |
| | | BtsExcelFixture.FixtureRecord last = sameCycle.get(sameCycle.size() - 1); |
| | | // step 号在每个循环内重复,跨循环时必须按当前循环的工步位置判断,不能把上一循环的同号工步当成当前状态。 |
| | | String status = forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" |
| | | : playback.stepNo() > first.stepNo() ? "已完成" |
| | | : playback.stepNo() == first.stepNo() ? "进行中" : "待执行"; |
| | | rows.add(new BtsTestPlanRow("STEP", first.stepNo(), cyclePosition++, first.stepName(), |
| | | Math.max(0d, last.offsetSeconds() - first.offsetSeconds()), first.voltageV(), status, |
| | | null, null)); |
| | | } |
| | | rows.add(new BtsTestPlanRow("CYCLE", BtsExcelFixture.startStepNo(), null, "循环", 0d, null, |
| | | forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "进行中", currentCycleNo, |
| | | (long) currentCycleRecords.size())); |
| | | rows.add(new BtsTestPlanRow("END", 0, null, "结束", 0d, null, |
| | | forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "待执行", null, (long) BtsExcelFixture.recordCount())); |
| | | return new BtsTestSnapshot(currentCycleNo, playback.stepNo(), BtsExcelFixture.cycleCount(), rows); |
| | | } |
| | | |
| | | /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */ |
| | | /** 请求采集器当前观测,读取状态页面本身不会触发此请求。 */ |
| | | public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId) |
| | | { |
| | | return DevelopmentCollectorSimulation.realtime(requireCollector(collectorId)); |
| | | } |
| | | |
| | | /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */ |
| | | public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit) |
| | | { |
| | | CollectorState collector = requireCollector(collectorId); |
| | | return DevelopmentCollectorSimulation.history(collector, stateStore, offset, limit); |
| | | boolean available = state.softwareAccessible() && collector.online(); |
| | | if (available) |
| | | { |
| | | collector = DevelopmentCollectorSimulation.sample(collector, Instant.now(), nextObservationSequence()); |
| | | replaceCollector(collector); |
| | | } |
| | | var result = new CollectorRealtimeSnapshot(collector.deviceCode(), available, collector.sampleIntervalMs(), |
| | | available ? collector.latestMeasurements() : List.of()); |
| | | recordRequest("collector-realtime", collector.deviceCode(), available ? result.toString() : failureReason()); |
| | | return result; |
| | | } |
| | | |
| | | /** 返回控制页需要的运行状态快照。 */ |
| | | private String failureReason() |
| | | { |
| | | return state.softwareAccessible() ? "UNOBSERVABLE" : "SOFTWARE_UNAVAILABLE"; |
| | | } |
| | | |
| | | private long nextObservationSequence() |
| | | { |
| | | long sequence = Math.max(state.observationSequence() + 1, Instant.now().toEpochMilli()); |
| | | SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(), |
| | | state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), state.softwareAccessible(), sequence); |
| | | stateStore.save(next); |
| | | state = next; |
| | | return sequence; |
| | | } |
| | | |
| | | /** 返回模拟器配置、当前状态和最近请求,不触发新的设备观测。 */ |
| | | 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())) |
| | | channel.workStatus(), channel.configuration())) |
| | | .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())) |
| | | collector.channels(), state.softwareAccessible() && collector.online() ? collector.latestMeasurements() : List.of())) |
| | | .toList(); |
| | | return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots, |
| | | return new StateSnapshot(running, state.softwareAccessible(), HOST + ":" + port(), deviceSnapshots, collectorSnapshots, |
| | | stateStore.loadRecentRequests()); |
| | | } |
| | | |
| | |
| | | { |
| | | SimulatorState defaultState = defaultState(); |
| | | stateStore.reset(defaultState); |
| | | btsHistory.clear(); |
| | | state = defaultState; |
| | | } |
| | | |
| | |
| | | state.nextCollectorId(), state.tombstones())); |
| | | } |
| | | |
| | | private String respond(String request) |
| | | private synchronized String respond(String request) |
| | | { |
| | | String command = readCommand(request); |
| | | recordRequest(command, request); |
| | | return switch (command) |
| | | String response = state.softwareAccessible() ? 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>"; |
| | | }; |
| | | } : "<bts version=\"1.0\"><cmd>error</cmd></bts>"; |
| | | recordRequest(command, request, state.softwareAccessible() ? response : "SOFTWARE_UNAVAILABLE"); |
| | | return response; |
| | | } |
| | | |
| | | private synchronized void recordRequest(String command, String request) |
| | | private void recordRequest(String command, String request, String response) |
| | | { |
| | | List<RecentRequest> recentRequests = new ArrayList<>(stateStore.loadRecentRequests()); |
| | | recentRequests.add(0, new RecentRequest(Instant.now(), command, request)); |
| | | recentRequests.add(0, new RecentRequest(Instant.now(), command, request, response)); |
| | | if (recentRequests.size() > MAX_RECENT_REQUESTS) |
| | | recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS)); |
| | | stateStore.saveRecentRequests(recentRequests); |
| | |
| | | |
| | | 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()))) |
| | | if (device.online() && channel.online() && channel.workStatus() != DeviceWorkStatus.UNOBSERVABLE |
| | | && requested.contains(profileFor(device).devtype() + ":" + device.deviceId() |
| | | + ":" + profileFor(device).subDeviceId() + ":" + channel.channelId())) |
| | | available.add(new ObservedChannel(device.deviceId(), channel)); |
| | | |
| | | StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"") |
| | |
| | | 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(); |
| | | CabinetRealtime measurement = cabinetMeasurement(new ChannelIdentity(ADAPTER_CODE, profile.devtype(), |
| | | device.deviceId(), profile.subDeviceId(), channel.channelId())); |
| | | double current = measurement.currentA(), voltage = measurement.voltageV(); |
| | | double capacity = measurement.capacityAh(), energy = measurement.energyWh(); |
| | | int cycleNo = measurement.cycleNo(), stepNo = measurement.stepNo(); |
| | | String stepType = measurement.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("\" 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(channel.configuration().totalTimeSeconds()).append("\" relativetime=\"") |
| | | .append(channel.configuration().relativeTimeSeconds()) |
| | | .append("\" open_or_close=\"0\" log_code=\"0\">") |
| | | // 模拟器返回当前设备方案,供统一观测模型展示;不代表真实 BTS 的完整方案字段。 |
| | | .append("<plan><step step_no=\"").append(stepNo).append("\" step_type=\"") |
| | | .append(stepType).append("\" name=\"").append(stepType).append("\"/></plan></inquire>"); |
| | | } |
| | | 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.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<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(), 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, |
| | | String recordNo) |
| | | { |
| | | public 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) |
| | | { |
| | | this(cycleNo, stepNo, cyclePosition, stepName, stepType, recordCount, startedAt, endedAt, |
| | | durationSeconds, capacityAh, energyWh, startVoltageV, endVoltageV, chargePeakVoltageV, |
| | | dischargeValleyVoltageV, null); |
| | | } |
| | | } |
| | | |
| | | public record BtsCycleRecord(String recordNo, int cycleNo, long recordCount, Instant startedAt, Instant endedAt, |
| | | double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, double dischargeEnergyWh, |
| | | double efficiency) { } |
| | | |
| | | public record BtsTestPlanRow(String rowType, int stepNo, Integer cyclePosition, String stepName, double durationSeconds, |
| | | Double voltageV, String executionStatus, Integer cycleCount, Long recordCount) { } |
| | | |
| | | public record BtsTestSnapshot(int currentCycleNo, int currentStepNo, int plannedCycleCount, |
| | | List<BtsTestPlanRow> rows) { } |
| | | |
| | | private static double efficiency(double chargeCapacityAh, double dischargeCapacityAh) |
| | | { |
| | | return chargeCapacityAh > 0d ? dischargeCapacityAh / chargeCapacityAh * 100d : 0d; |
| | | } |
| | | |
| | | public record PlaybackSnapshot(int deviceId, int channelId, BtsPlaybackState playback, |
| | | List<BtsHistoryRecord> records, List<BtsStepRecord> steps, List<BtsCycleRecord> cycles) { } |
| | | |
| | | private static final class BtsStepAccumulator |
| | | { |
| | | private final int cycle; private final int step; private final int position; private final String name; private final String type; |
| | | private long count; private Instant first; private Instant last; private double duration; private double capacity; private double energy; |
| | | private double startVoltage; private double endVoltage; private double chargePeak = Double.NEGATIVE_INFINITY; private double dischargeValley = Double.POSITIVE_INFINITY; |
| | | BtsStepAccumulator(BtsHistoryRecord first, int position) { cycle = first.cycleNo(); step = first.stepNo(); this.position = position; name = first.stepName(); type = first.stepType(); } |
| | | void add(BtsHistoryRecord item) { count++; if (first == null) { first = item.deviceTime(); startVoltage = item.voltageV(); } last = item.deviceTime(); endVoltage = item.voltageV(); duration = first == null ? 0 : Math.max(0, java.time.Duration.between(first, last).toMillis() / 1000d); capacity = item.capacityAh(); energy = item.energyWh(); if (item.currentA() >= 0) chargePeak = Math.max(chargePeak, item.voltageV()); else dischargeValley = Math.min(dischargeValley, item.voltageV()); } |
| | | BtsStepRecord finish() { return new BtsStepRecord(cycle, step, position, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley, "STEP-" + cycle + "-" + step + "-" + last.toEpochMilli()); } |
| | | } |
| | | |
| | | 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) |
| | |
| | | { |
| | | String devid = reader.getAttributeValue(null, "devid"); |
| | | String chlid = reader.getAttributeValue(null, "chlid"); |
| | | if (devid != null && chlid != null) channels.add(devid + ":" + chlid); |
| | | String devtype = reader.getAttributeValue(null, "devtype"); |
| | | String subdevid = reader.getAttributeValue(null, "subdevid"); |
| | | if (devtype != null && devid != null && subdevid != null && chlid != null) |
| | | channels.add(devtype + ":" + devid + ":" + subdevid + ":" + chlid); |
| | | } |
| | | } |
| | | return channels; |
| | |
| | | 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); |
| | | return java.math.BigDecimal.valueOf(value).toPlainString(); |
| | | } |
| | | |
| | | private String toBtsWorkStatus(DeviceWorkStatus status) |
| | |
| | | case WORKING -> "working"; |
| | | case PAUSED -> "pause"; |
| | | case FINISHED -> "finish"; |
| | | case PROTECTED -> "protect"; |
| | | default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status); |
| | | }; |
| | | } |
| | |
| | | |
| | | private void persist(SimulatorState nextState) |
| | | { |
| | | // 写穿失败时保留当前内存副本,避免控制页显示未持久化状态。 |
| | | stateStore.save(nextState); |
| | | state = nextState; |
| | | } |
| | | |
| | | private void persist(SimulatorState nextState, List<CollectorHistoryAppend> historyAppends) |
| | | { |
| | | // 主状态与原始历史批次必须一起成功,内存序号才能向前推进。 |
| | | stateStore.save(nextState, historyAppends); |
| | | state = nextState; |
| | | // 写穿失败时保留当前内存副本,避免模拟器页面显示未持久化状态。 |
| | | SimulatorState configured = new SimulatorState(nextState.version(), nextState.devices(), nextState.collectors(), |
| | | nextState.nextDeviceId(), nextState.nextCollectorId(), nextState.tombstones(), |
| | | state.softwareAccessible(), state.observationSequence()); |
| | | stateStore.save(configured); |
| | | state = configured; |
| | | } |
| | | |
| | | private String simulatorMac(int devtype, int devid) |
| | |
| | | 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"); |
| | | && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED |
| | | && status != DeviceWorkStatus.PROTECTED && status != DeviceWorkStatus.UNOBSERVABLE)) |
| | | throw new IllegalArgumentException("Unsupported simulator work status"); |
| | | } |
| | | |
| | | private void validateRestoredState() |
| | |
| | | || !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); |
| | | DevelopmentCollectorSimulation.validate(collector); |
| | | maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId()); |
| | | } |
| | | java.util.Set<String> tombstoneIds = new java.util.HashSet<>(); |
| | |
| | | throw new IllegalStateException("Invalid BTS simulator numbering or tombstone state in Redis"); |
| | | } |
| | | |
| | | /** 停止模拟器:标记为不再运行并释放 TCP 端点;释放失败时以运行时异常报告。 */ |
| | | @Override |
| | | public synchronized void close() |
| | | { |
| | |
| | | |
| | | private record ObservedChannel(int deviceId, ChannelState channel) { } |
| | | |
| | | public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices, |
| | | public record StateSnapshot(boolean running, boolean softwareAccessible, 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 ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus, |
| | | ChannelConfiguration configuration) { } |
| | | |
| | | 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) |
| | | List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements) |
| | | { |
| | | public CollectorSnapshot |
| | | { |