收敛模拟器为设备事实与实时观测
新增软件可访问性及采集器通道状态,按请求生成当前观测并保存连续序号。移除倍速、时间跳跃、生命周期播放、历史生成及对应接口。
修复暂停接收时仍请求采集器和发现失败后残留旧快照的问题。全量测试与干净打包通过,共234项测试。
| | |
| | | /** 发现失败或返回空拓扑时,清除该适配器下已登记设备的过期运行事实。 */ |
| | | public void markUnavailable(String adapterCode) |
| | | { |
| | | Instant failedAt = Instant.now(); |
| | | // 失败立即撤下旧测量,但保留来源身份,不能等候选 TTL 到期才停止展示成功值。 |
| | | candidateStore.recordCabinets(candidateStore.snapshot(failedAt).cabinets().stream() |
| | | .filter(item -> item.adapterCode().equals(adapterCode)) |
| | | .map(item -> new CabinetCandidate(item.adapterCode(), item.adapterId(), item.deviceCode(), |
| | | item.devtype(), item.devid(), item.ip(), item.mac(), item.name(), item.manufacturer(), item.model(), |
| | | item.channels().stream().map(channel -> new ChannelCandidate(channel.identity(), false, |
| | | DeviceWorkStatus.UNOBSERVABLE, failedAt)).toList(), failedAt, item.expiresAt())) |
| | | .toList()); |
| | | detectionCabinetMapper.markRegisteredFactsUnobservable(adapterCode); |
| | | detectionCabinetMapper.markRegisteredCabinetsOffline(adapterCode); |
| | | } |
| | |
| | | if (battery.getBinding() != null) |
| | | sources.add(snapshotFor(cabinetSource(battery, cabinets), receiving)); |
| | | if (battery.getCollectorId() != null) |
| | | sources.add(snapshotFor(collectorSource(battery), receiving)); |
| | | sources.add(collectorSource(battery, receiving)); |
| | | } |
| | | List<Map<String, Object>> exceptions = new ArrayList<>(); |
| | | if (receiving) |
| | |
| | | return value; |
| | | } |
| | | |
| | | private LiveSource collectorSource(TTestBattery battery) |
| | | private LiveSource collectorSource(TTestBattery battery, boolean receiving) |
| | | { |
| | | if (adapter == null || mapper == null) return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(), |
| | | "OFFLINE", Map.of()); |
| | | var collector = adapter.latestCollector(mapper.selectCollectorCode(battery.getCollectorId())); |
| | | String collectorCode = mapper.selectCollectorCode(battery.getCollectorId()); |
| | | // 非接收状态只读设备发现事实,不能先请求测量再隐藏,否则仍会产生新观测。 |
| | | if (!receiving) |
| | | { |
| | | boolean online = candidates.findCollector(collectorCode, Instant.now()) |
| | | .map(DeviceDiscoveryCandidateStore.CollectorCandidate::online).orElse(false); |
| | | return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(), |
| | | online ? "ONLINE" : "OFFLINE", Map.of()); |
| | | } |
| | | var collector = adapter.latestCollector(collectorCode); |
| | | if (collector != null) |
| | | { |
| | | Map<String, Object> value = new LinkedHashMap<>(); |
| | |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState; |
| | | import com.battery.device.simulator.InMemoryBtsSimulatorStateStore; |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation; |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorAdvance; |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage; |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot; |
| | | |
| | | /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */ |
| | |
| | | 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"); |
| | | |
| | | @Override |
| | | public String adapterCode() |
| | |
| | | { |
| | | 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) |
| | | { |
| | | 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)); |
| | | } |
| | | for (ChannelIdentity requested : channels) result.put(requested, observation(requested)); |
| | | recordRequest("observe", channels.toString(), result.toString()); |
| | | return java.util.Map.copyOf(result); |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | |
| | |
| | | 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)); |
| | |
| | | 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)); |
| | | persist(next); |
| | | } |
| | | |
| | | /** 配置一个稳定 S 通道的固定或动态演示指标。 */ |
| | |
| | | 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()); |
| | | return new TestPlan(1, 1, 1, List.of(new PlanRow("STEP", 1, 1, "恒流充电", 60d, 3.7d, |
| | | requireDevice(channel.devid()).channels().get(channel.chlid() - 1).workStatus() == DeviceWorkStatus.FINISHED |
| | | ? "已完成" : "待执行", null, null))); |
| | | } |
| | | |
| | | /** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */ |
| | | @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; |
| | | } |
| | | |
| | | private CabinetRealtime cabinetMeasurement(ChannelIdentity identity) |
| | | { |
| | | 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(); |
| | | double wave = Math.sin((sequence + channel.channelId()) * 0.7d); |
| | | BtsDeviceProfile profile = profileFor(device); |
| | | double current = channel.workStatus() == DeviceWorkStatus.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()); |
| | | CabinetRealtime result = new CabinetRealtime(sequence, now, current, voltage, 1.2d, 4.44d, |
| | | 1, 1, "恒流充电", "cc", now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId()); |
| | | return result; |
| | | } |
| | | |
| | | /** 请求采集器当前配置值;软件不可访问或设备离线时返回空测量。 */ |
| | | @Override |
| | | public synchronized CollectorRealtime latestCollector(String collectorCode) |
| | | { |
| | | var collector = snapshot().collectors().stream().filter(item -> item.deviceCode().equals(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)); |
| | | CollectorState collector = requireCollector(collectorId); |
| | | 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; |
| | | } |
| | | |
| | | /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */ |
| | | public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit) |
| | | private String failureReason() |
| | | { |
| | | CollectorState collector = requireCollector(collectorId); |
| | | return DevelopmentCollectorSimulation.history(collector, stateStore, offset, limit); |
| | | 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; |
| | | } |
| | | |
| | | /** 返回控制页需要的运行状态快照。 */ |
| | |
| | | .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(0).append("\" relativetime=\"").append(0) |
| | | .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; |
| | |
| | | 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<>(); |
| | |
| | | |
| | | 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, |
| | |
| | | |
| | | 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 |
| | | { |
| | |
| | | public interface BtsSimulatorStateStore |
| | | { |
| | | int CURRENT_VERSION = 1; |
| | | int MAX_HISTORY_BATCH_SIZE = 1000; |
| | | /** 开发演示只保留有限历史,避免 Redis 随运行时间无限增长。 */ |
| | | long MAX_HISTORY_RECORDS = 20_000L; |
| | | |
| | | /** 读取已有状态;仅在状态不存在时原子写入并返回默认状态。 */ |
| | | /** 读取已有状态,仅在不存在时保存默认拓扑。 */ |
| | | SimulatorState loadOrInitialize(SimulatorState defaultState); |
| | | |
| | | /** 开发模拟器启动时清理主状态中的旧内嵌历史,保留独立存储的历史。 */ |
| | | default SimulatorState resetHistoryForDemoStartup(SimulatorState state) { return state; } |
| | | |
| | | /** 完整保存下一版模拟器状态。 */ |
| | | /** 保存设备配置和观测序号;返回前必须完成写入。 */ |
| | | void save(SimulatorState state); |
| | | |
| | | /** 状态和独立历史全部校验通过后,提交兼容旧格式所需的迁移。 */ |
| | | default void completeValidatedRestore(SimulatorState state) { } |
| | | |
| | | /** 原子保存主状态并追加本轮新产生的原始历史。 */ |
| | | default void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends) |
| | | { |
| | | if (!historyAppends.isEmpty()) |
| | | throw new UnsupportedOperationException("Collector history append is not supported"); |
| | | save(state); |
| | | } |
| | | |
| | | /** 按原始写入顺序或反向顺序读取有界历史批次。 */ |
| | | default CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, int limit, |
| | | boolean reverse) |
| | | { |
| | | if (offset < 0) throw new IllegalArgumentException("offset must not be negative"); |
| | | if (limit < 1 || limit > MAX_HISTORY_BATCH_SIZE) |
| | | throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_BATCH_SIZE); |
| | | return new CollectorHistoryBatch(offset, 0, false, 0, List.of()); |
| | | } |
| | | |
| | | /** 读取检测柜模拟历史;正式适配器不依赖该演示扩展。 */ |
| | | default BtsHistoryBatch loadBtsHistoryPage(String historyKey, long offset, int limit) |
| | | { |
| | | return new BtsHistoryBatch(offset, 0, false, 0, List.of()); |
| | | } |
| | | |
| | | /** 追加检测柜模拟历史,供重启续采合同使用。 */ |
| | | default void appendBtsHistory(String historyKey, BtsHistoryRecord record) { } |
| | | |
| | | /** 读取检测柜通道的播放状态;实现可在不支持播放持久化时返回 null。 */ |
| | | default BtsPlaybackState loadBtsPlaybackState(String historyKey) { return null; } |
| | | |
| | | /** 保存检测柜通道的播放状态。 */ |
| | | default void saveBtsPlaybackState(String historyKey, BtsPlaybackState state) { } |
| | | |
| | | /** 删除检测柜通道的播放状态。 */ |
| | | default void deleteBtsPlaybackState(String historyKey) { } |
| | | |
| | | /** 删除检测柜通道的持久化历史和对应索引。 */ |
| | | default void deleteBtsHistory(String historyKey) { } |
| | | |
| | | /** 原子提交检测柜新增历史与推进后的播放状态。 */ |
| | | default void saveBtsPlaybackAndAppendHistory(String historyKey, BtsPlaybackState state, |
| | | List<BtsHistoryRecord> records) |
| | | { |
| | | for (BtsHistoryRecord record : records) appendBtsHistory(historyKey, record); |
| | | saveBtsPlaybackState(historyKey, state); |
| | | } |
| | | |
| | | /** 原子保存删除采集器后的主状态,并删除该采集器的独立历史。 */ |
| | | default void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode) |
| | | { |
| | | throw new UnsupportedOperationException("Atomic collector deletion is not supported"); |
| | | } |
| | | |
| | | /** 读取短期保留的最近请求。 */ |
| | | List<RecentRequest> loadRecentRequests(); |
| | |
| | | void reset(SimulatorState defaultState); |
| | | |
| | | record SimulatorState(int version, List<DeviceState> devices, List<CollectorState> collectors, |
| | | int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones) |
| | | int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones, |
| | | Boolean softwareAccessible, long observationSequence) |
| | | { |
| | | public SimulatorState(int version, List<DeviceState> devices, List<CollectorState> collectors, |
| | | int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones) |
| | | { |
| | | this(version, devices, collectors, nextDeviceId, nextCollectorId, tombstones, true, 0L); |
| | | } |
| | | |
| | | public SimulatorState |
| | | { |
| | | if (version != CURRENT_VERSION) |
| | | throw new IllegalStateException("Unsupported BTS simulator state version: " + version); |
| | | softwareAccessible = softwareAccessible == null ? true : softwareAccessible; |
| | | devices = List.copyOf(devices); |
| | | collectors = List.copyOf(collectors); |
| | | tombstones = List.copyOf(tombstones); |
| | |
| | | |
| | | record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name, |
| | | String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs, |
| | | long nextSequence, Instant lastSampledAt, List<CollectorChannelState> channels, |
| | | List<CollectorRealtimeMeasurement> latestMeasurements, |
| | | long historyRecordCount, CollectorHistoryFaults historyFaults) |
| | | List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements) |
| | | { |
| | | public CollectorState |
| | | { |
| | | channels = List.copyOf(channels); |
| | | latestMeasurements = List.copyOf(latestMeasurements); |
| | | historyFaults = historyFaults == null ? CollectorHistoryFaults.none() : historyFaults; |
| | | } |
| | | |
| | | /** 兼容只包含发现配置的旧模拟状态;Redis 读取边界会补齐采样合同。 */ |
| | | public CollectorState(int collectorId, String deviceCode, String ip, String mac, String name, |
| | | String manufacturer, String model, int channelCount, boolean online) |
| | | { |
| | | this(collectorId, deviceCode, ip, mac, name, manufacturer, model, channelCount, online, |
| | | DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, 1L, null, |
| | | DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of(), 0L, |
| | | CollectorHistoryFaults.none()); |
| | | DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, |
| | | DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of()); |
| | | } |
| | | } |
| | | |
| | | record CollectorChannelState(String channelCode, List<CollectorMetricState> metrics) |
| | | record CollectorChannelState(String channelCode, List<CollectorMetricState> metrics, DeviceWorkStatus workStatus) |
| | | { |
| | | public CollectorChannelState { metrics = List.copyOf(metrics); } |
| | | public CollectorChannelState |
| | | { |
| | | metrics = List.copyOf(metrics); |
| | | workStatus = workStatus == null ? DeviceWorkStatus.WORKING : workStatus; |
| | | } |
| | | |
| | | public CollectorChannelState(String channelCode, List<CollectorMetricState> metrics) |
| | | { |
| | | this(channelCode, metrics, DeviceWorkStatus.WORKING); |
| | | } |
| | | } |
| | | |
| | | record CollectorMetricState(String metricCode, BigDecimal value, String unit) { } |
| | |
| | | record CollectorRealtimeMeasurement(String adapterCode, String collectorCode, String channelCode, |
| | | long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt, |
| | | String metricCode, BigDecimal value, String unit) { } |
| | | |
| | | record CollectorMeasurementRecord(String adapterCode, String collectorCode, String channelCode, |
| | | long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt, |
| | | String metricCode, BigDecimal value, String unit) { } |
| | | |
| | | record CollectorHistoryAppend(String collectorCode, List<CollectorMeasurementRecord> records) |
| | | { |
| | | public CollectorHistoryAppend { records = List.copyOf(records); } |
| | | } |
| | | |
| | | record CollectorHistoryBatch(long offset, long nextOffset, boolean hasMore, long total, |
| | | List<CollectorMeasurementRecord> records) |
| | | { |
| | | public CollectorHistoryBatch { records = List.copyOf(records); } |
| | | } |
| | | |
| | | record BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV, |
| | | double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType, |
| | | Instant serverReceivedAt) |
| | | { |
| | | /** 兼容现有调用方;新生成记录应传入服务器接收时间。 */ |
| | | public BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV, |
| | | double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType) |
| | | { |
| | | this(sequenceId, deviceTime, currentA, voltageV, capacityAh, energyWh, cycleNo, stepNo, |
| | | stepName, stepType, Instant.now()); |
| | | } |
| | | |
| | | /** 为仅包含核心测量值的历史记录补齐默认工步信息。 */ |
| | | public BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV, |
| | | double capacityAh, double energyWh) |
| | | { |
| | | this(sequenceId, deviceTime, currentA, voltageV, capacityAh, energyWh, 1, 1, "恒流充电", "cc"); |
| | | } |
| | | } |
| | | |
| | | /** 检测柜单通道演示播放状态;与测试的接收状态相互独立。 */ |
| | | record BtsPlaybackState(boolean playing, int speedMultiplier, int cycleNo, int stepNo, |
| | | String stepName, String stepType, Instant simulatedAt, Instant wallClockAt, |
| | | long nextSequence, boolean naturallyFinished) |
| | | { |
| | | public BtsPlaybackState |
| | | { |
| | | if (speedMultiplier != 1 && speedMultiplier != 60 && speedMultiplier != 600) |
| | | throw new IllegalArgumentException("speedMultiplier must be 1, 60 or 600"); |
| | | if (cycleNo < 1 || stepNo < 1 || nextSequence < 1 || stepName == null || stepName.isBlank() |
| | | || stepType == null || stepType.isBlank()) |
| | | throw new IllegalArgumentException("Invalid BTS playback state"); |
| | | } |
| | | } |
| | | |
| | | record BtsHistoryBatch(long offset, long nextOffset, boolean hasMore, long total, |
| | | List<BtsHistoryRecord> records) |
| | | { |
| | | public BtsHistoryBatch { records = List.copyOf(records); } |
| | | } |
| | | |
| | | record CollectorHistoryFaults(List<Long> missingSequences, Long duplicateSequence, |
| | | Long conflictingSequence, boolean outOfOrder) |
| | | { |
| | | public CollectorHistoryFaults |
| | | { |
| | | missingSequences = missingSequences == null ? List.of() : List.copyOf(missingSequences); |
| | | } |
| | | |
| | | public static CollectorHistoryFaults none() |
| | | { |
| | | return new CollectorHistoryFaults(List.of(), null, null, false); |
| | | } |
| | | } |
| | | |
| | | record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { } |
| | | |
| | |
| | | |
| | | record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { } |
| | | |
| | | record RecentRequest(Instant receivedAt, String command, String request) { } |
| | | record RecentRequest(Instant receivedAt, String command, String request, String response) { } |
| | | } |
| | |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.Instant; |
| | | import java.time.LocalDateTime; |
| | | import java.time.ZoneId; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.time.format.DateTimeParseException; |
| | | import java.time.temporal.ChronoUnit; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState; |
| | | 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.api.model.DeviceWorkStatus; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.*; |
| | | |
| | | /** 实现第一版开发采集器的独立采样与故障注入合同。 */ |
| | | /** 提供请求时的采集器当前值;S 通道仍是待现场验证的开发合同。 */ |
| | | public final class DevelopmentCollectorSimulation |
| | | { |
| | | public static final int DEFAULT_SAMPLE_INTERVAL_MS = 1000; |
| | | public static final int MIN_SAMPLE_INTERVAL_MS = 100; |
| | | public static final int MAX_SAMPLE_INTERVAL_MS = 60000; |
| | | public static final int MAX_HISTORY_PAGE_SIZE = BtsSimulatorStateStore.MAX_HISTORY_BATCH_SIZE; |
| | | private static final int MAX_SAMPLES_PER_ADVANCE = 1000; |
| | | private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong"); |
| | | private static final DateTimeFormatter DEVICE_TIME_FORMATTER = |
| | | DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); |
| | | private static final DateTimeFormatter DEVICE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); |
| | | |
| | | private DevelopmentCollectorSimulation() { } |
| | | |
| | |
| | | return List.copyOf(channels); |
| | | } |
| | | |
| | | /** 调整通道数时保留已有通道配置,并为新增通道生成稳定默认配置。 */ |
| | | public static CollectorState resize(CollectorState current, String ip, int channelCount, boolean online, |
| | | Instant changedAt) |
| | | |
| | | /** 调整设备拓扑,保留已有通道的配置。 */ |
| | | public static CollectorState resize(CollectorState current, String ip, int channelCount, boolean online, Instant changedAt) |
| | | { |
| | | List<CollectorChannelState> defaults = defaultChannels(channelCount); |
| | | List<CollectorChannelState> channels = new ArrayList<>(channelCount); |
| | | var defaults = defaultChannels(channelCount); |
| | | List<CollectorChannelState> channels = new ArrayList<>(); |
| | | for (int index = 0; index < channelCount; index++) |
| | | channels.add(index < current.channels().size() ? current.channels().get(index) : defaults.get(index)); |
| | | Instant lastSampledAt = online == current.online() ? current.lastSampledAt() : changedAt; |
| | | List<CollectorRealtimeMeasurement> latestMeasurements = retainConfiguredMeasurements( |
| | | current.latestMeasurements(), channels); |
| | | return copy(current, ip, channelCount, online, current.sampleIntervalMs(), current.nextSequence(), |
| | | lastSampledAt, channels, latestMeasurements, current.historyRecordCount(), current.historyFaults()); |
| | | return new CollectorState(current.collectorId(), current.deviceCode(), ip.trim(), current.mac(), |
| | | current.name(), current.manufacturer(), current.model(), channelCount, online, |
| | | current.sampleIntervalMs(), channels, List.of()); |
| | | } |
| | | |
| | | /** 更新采样频率和历史下载故障;测量原始历史保持只追加。 */ |
| | | public static CollectorState configureSampling(CollectorState current, int sampleIntervalMs, |
| | | CollectorHistoryFaults historyFaults) |
| | | { |
| | | validateSampleInterval(sampleIntervalMs); |
| | | validateFaults(historyFaults); |
| | | return copy(current, current.ip(), current.channelCount(), current.online(), sampleIntervalMs, |
| | | current.nextSequence(), current.lastSampledAt(), current.channels(), current.latestMeasurements(), |
| | | current.historyRecordCount(), historyFaults); |
| | | } |
| | | |
| | | /** 替换一个演示通道的指标配置;动态添加项与固定指标共用指标编码合同。 */ |
| | | public static CollectorState configureChannel(CollectorState current, String channelCode, |
| | | List<CollectorMetricState> metrics) |
| | | /** 保存当前测量配置,等待下一次实时请求。 */ |
| | | public static CollectorState configureChannel(CollectorState current, String channelCode, List<CollectorMetricState> metrics) |
| | | { |
| | | validateMetrics(metrics); |
| | | int channelIndex = parseChannelIndex(channelCode, current.channelCount()); |
| | | int index = parseChannelIndex(channelCode, current.channelCount()); |
| | | List<CollectorChannelState> channels = new ArrayList<>(current.channels()); |
| | | channels.set(channelIndex, new CollectorChannelState(channelCode(channelIndex + 1), metrics)); |
| | | List<CollectorRealtimeMeasurement> latestMeasurements = retainConfiguredMeasurements( |
| | | current.latestMeasurements(), channels); |
| | | return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(), |
| | | current.nextSequence(), current.lastSampledAt(), channels, latestMeasurements, |
| | | current.historyRecordCount(), current.historyFaults()); |
| | | channels.set(index, new CollectorChannelState(channelCode, metrics, channels.get(index).workStatus())); |
| | | return copy(current, channels, List.of()); |
| | | } |
| | | |
| | | /** 按采集器自身频率推进采样;离线期间不生成记录,恢复后从下一序号继续。 */ |
| | | public static CollectorAdvance advance(CollectorState current, Instant now) |
| | | /** 保存通道工作状态,等待下一次实时请求。 */ |
| | | public static CollectorState configureStatus(CollectorState current, String channelCode, DeviceWorkStatus status) |
| | | { |
| | | if (!current.online()) return new CollectorAdvance(current, List.of()); |
| | | Instant sampleAt = current.lastSampledAt() == null |
| | | ? now |
| | | : current.lastSampledAt().plusMillis(current.sampleIntervalMs()); |
| | | if (sampleAt.isAfter(now)) return new CollectorAdvance(current, List.of()); |
| | | int index = parseChannelIndex(channelCode, current.channelCount()); |
| | | List<CollectorChannelState> channels = new ArrayList<>(current.channels()); |
| | | channels.set(index, new CollectorChannelState(channelCode, channels.get(index).metrics(), status)); |
| | | return copy(current, channels, List.of()); |
| | | } |
| | | |
| | | List<CollectorMeasurementRecord> appendedRecords = new ArrayList<>(); |
| | | Map<String, CollectorRealtimeMeasurement> latest = new LinkedHashMap<>(); |
| | | for (CollectorRealtimeMeasurement measurement : current.latestMeasurements()) |
| | | latest.put(measurementKey(measurement.channelCode(), measurement.metricCode()), measurement); |
| | | long sequence = current.nextSequence(); |
| | | int generated = 0; |
| | | Instant lastSampledAt = current.lastSampledAt(); |
| | | while (!sampleAt.isAfter(now) && generated < MAX_SAMPLES_PER_ADVANCE) |
| | | /** 仅生成本次请求的当前值,不补算上一次请求与本次之间的空档。 */ |
| | | public static CollectorState sample(CollectorState current, Instant now, long sequence) |
| | | { |
| | | Instant observedAt = now.truncatedTo(java.time.temporal.ChronoUnit.MILLIS); |
| | | List<CollectorRealtimeMeasurement> measurements = new ArrayList<>(); |
| | | for (CollectorChannelState channel : current.channels()) |
| | | { |
| | | for (CollectorChannelState channel : current.channels()) |
| | | { |
| | | for (CollectorMetricState metric : channel.metrics()) |
| | | { |
| | | CollectorMeasurementRecord record = new CollectorMeasurementRecord("dev-sim", current.deviceCode(), |
| | | channel.channelCode(), sequence, sampleAt, |
| | | DEVICE_TIME_FORMATTER.format(sampleAt.atZone(DEVICE_ZONE)), now, |
| | | metric.metricCode(), metric.value(), metric.unit()); |
| | | appendedRecords.add(record); |
| | | latest.put(measurementKey(record.channelCode(), record.metricCode()), |
| | | new CollectorRealtimeMeasurement(record.adapterCode(), record.collectorCode(), |
| | | record.channelCode(), record.sequenceId(), record.deviceTime(), |
| | | record.deviceTimeRaw(), record.serverReceivedAt(), record.metricCode(), |
| | | record.value(), record.unit())); |
| | | } |
| | | } |
| | | lastSampledAt = sampleAt; |
| | | sequence++; |
| | | generated++; |
| | | sampleAt = sampleAt.plusMillis(current.sampleIntervalMs()); |
| | | if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) continue; |
| | | for (CollectorMetricState metric : channel.metrics()) |
| | | measurements.add(new CollectorRealtimeMeasurement("dev-sim", current.deviceCode(), channel.channelCode(), |
| | | sequence, observedAt, DEVICE_TIME_FORMATTER.format(observedAt.atZone(DEVICE_ZONE)), observedAt, |
| | | metric.metricCode(), metric.value(), metric.unit())); |
| | | } |
| | | CollectorState next = copy(current, current.ip(), current.channelCount(), current.online(), |
| | | current.sampleIntervalMs(), |
| | | sequence, lastSampledAt, current.channels(), List.copyOf(latest.values()), |
| | | Math.min(BtsSimulatorStateStore.MAX_HISTORY_RECORDS, |
| | | current.historyRecordCount() + appendedRecords.size()), |
| | | current.historyFaults()); |
| | | return new CollectorAdvance(next, appendedRecords); |
| | | return copy(current, current.channels(), measurements); |
| | | } |
| | | |
| | | /** 返回独立保存的最新实时快照,不从原始历史反推当前状态。 */ |
| | | public static CollectorRealtimeSnapshot realtime(CollectorState current) |
| | | /** 校验恢复后的稳定通道和测量配置。 */ |
| | | public static void validate(CollectorState state) |
| | | { |
| | | return new CollectorRealtimeSnapshot(current.deviceCode(), current.online(), current.sampleIntervalMs(), |
| | | current.latestMeasurements()); |
| | | } |
| | | |
| | | /** 对只追加原始模拟历史应用可重复的下载故障视图,再按偏移量分页。 */ |
| | | public static CollectorHistoryPage history(CollectorState current, BtsSimulatorStateStore stateStore, |
| | | long offset, int limit) |
| | | { |
| | | if (offset < 0) throw new IllegalArgumentException("offset must not be negative"); |
| | | if (limit < 1 || limit > MAX_HISTORY_PAGE_SIZE) |
| | | throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_PAGE_SIZE); |
| | | CollectorHistoryFaults faults = current.historyFaults(); |
| | | if (faults.missingSequences().isEmpty() && faults.duplicateSequence() == null |
| | | && faults.conflictingSequence() == null && !faults.outOfOrder()) |
| | | { |
| | | BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage( |
| | | current.deviceCode(), offset, limit, false); |
| | | return new CollectorHistoryPage(current.deviceCode(), offset, batch.nextOffset(), batch.hasMore(), |
| | | batch.total(), batch.records()); |
| | | } |
| | | return faultedHistoryPage(current, stateStore, offset, limit); |
| | | } |
| | | |
| | | /** 校验 Redis 恢复的采集器采样状态,拒绝损坏状态被静默替换。 */ |
| | | public static void validate(CollectorState state, BtsSimulatorStateStore stateStore) |
| | | { |
| | | validateSampleInterval(state.sampleIntervalMs()); |
| | | if (state.nextSequence() < 1 || state.channels().size() != state.channelCount()) |
| | | throw new IllegalStateException("Invalid collector sampling state in Redis"); |
| | | if (state.channels().size() != state.channelCount()) throw new IllegalStateException("Invalid collector topology"); |
| | | for (int index = 0; index < state.channels().size(); index++) |
| | | { |
| | | CollectorChannelState channel = state.channels().get(index); |
| | | if (!channelCode(index + 1).equals(channel.channelCode())) |
| | | throw new IllegalStateException("Invalid collector channel identity in Redis"); |
| | | var channel = state.channels().get(index); |
| | | if (!channelCode(index + 1).equals(channel.channelCode())) throw new IllegalStateException("Invalid collector channel identity"); |
| | | validateMetrics(channel.metrics()); |
| | | } |
| | | validateLatestMeasurements(state); |
| | | validateHistory(state, stateStore); |
| | | validateFaults(state.historyFaults()); |
| | | } |
| | | |
| | | private static void validateLatestMeasurements(CollectorState state) |
| | | private static CollectorState copy(CollectorState current, List<CollectorChannelState> channels, |
| | | List<CollectorRealtimeMeasurement> measurements) |
| | | { |
| | | Set<String> configured = configuredMeasurementKeys(state.channels()); |
| | | Set<String> identities = new java.util.HashSet<>(); |
| | | for (CollectorRealtimeMeasurement measurement : state.latestMeasurements()) |
| | | { |
| | | String key = measurementKey(measurement.channelCode(), measurement.metricCode()); |
| | | if (!validMeasurement(state, measurement.adapterCode(), measurement.collectorCode(), |
| | | measurement.channelCode(), measurement.sequenceId(), measurement.deviceTime(), |
| | | measurement.deviceTimeRaw(), measurement.serverReceivedAt(), measurement.metricCode(), |
| | | measurement.value(), measurement.unit()) || !configured.contains(key) || !identities.add(key) |
| | | || measurement.sequenceId() >= state.nextSequence()) |
| | | throw new IllegalStateException("Invalid collector realtime state in Redis"); |
| | | } |
| | | } |
| | | |
| | | private static void validateHistory(CollectorState state, BtsSimulatorStateStore stateStore) |
| | | { |
| | | long maximumSequence = 0; |
| | | long previousSequence = 0; |
| | | long recordCount = 0; |
| | | long offset = 0; |
| | | Set<String> identities = new java.util.HashSet<>(); |
| | | while (true) |
| | | { |
| | | BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage( |
| | | state.deviceCode(), offset, MAX_HISTORY_PAGE_SIZE, false); |
| | | if (batch.offset() != offset || batch.records().size() > MAX_HISTORY_PAGE_SIZE) |
| | | throw new IllegalStateException("Invalid collector history page in Redis"); |
| | | for (CollectorMeasurementRecord record : batch.records()) |
| | | { |
| | | if (record.sequenceId() < previousSequence) |
| | | throw new IllegalStateException("Invalid collector history state in Redis"); |
| | | if (record.sequenceId() != previousSequence) |
| | | { |
| | | identities.clear(); |
| | | previousSequence = record.sequenceId(); |
| | | } |
| | | String identity = record.channelCode() + "\u0000" + record.metricCode(); |
| | | if (!validMeasurement(state, record.adapterCode(), record.collectorCode(), record.channelCode(), |
| | | record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(), |
| | | record.metricCode(), record.value(), record.unit()) || !identities.add(identity)) |
| | | throw new IllegalStateException("Invalid collector history state in Redis"); |
| | | maximumSequence = Math.max(maximumSequence, record.sequenceId()); |
| | | recordCount++; |
| | | } |
| | | if (!batch.hasMore()) |
| | | { |
| | | if (batch.total() != recordCount) |
| | | throw new IllegalStateException("Invalid collector history count in Redis"); |
| | | break; |
| | | } |
| | | if (batch.nextOffset() <= offset) |
| | | throw new IllegalStateException("Invalid collector history page in Redis"); |
| | | offset = batch.nextOffset(); |
| | | } |
| | | if (state.nextSequence() <= maximumSequence) |
| | | throw new IllegalStateException("Invalid collector history sequence in Redis"); |
| | | if (state.historyRecordCount() != recordCount) |
| | | throw new IllegalStateException("Invalid collector history count in Redis"); |
| | | } |
| | | |
| | | private static boolean validMeasurement(CollectorState state, String adapterCode, String collectorCode, |
| | | String channelCode, long sequenceId, Instant deviceTime, String deviceTimeRaw, |
| | | Instant serverReceivedAt, String metricCode, BigDecimal value, String unit) |
| | | { |
| | | return "dev-sim".equals(adapterCode) && state.deviceCode().equals(collectorCode) |
| | | && validHistoricalChannelCode(channelCode) |
| | | && sequenceId > 0 && deviceTime != null && deviceTimeRaw != null && !deviceTimeRaw.isBlank() |
| | | && validDeviceTime(deviceTime, deviceTimeRaw) && serverReceivedAt != null |
| | | && !serverReceivedAt.isBefore(deviceTime) && metricCode != null && !metricCode.isBlank() |
| | | && metricCode.equals(metricCode.trim()) && value != null && unit != null && !unit.isBlank() |
| | | && unit.equals(unit.trim()); |
| | | } |
| | | |
| | | private static boolean validHistoricalChannelCode(String channelCode) |
| | | { |
| | | if (channelCode == null || !channelCode.matches("S-\\d{2}")) return false; |
| | | int index = Integer.parseInt(channelCode.substring(2)); |
| | | return index >= 1 && channelCode(index).equals(channelCode); |
| | | } |
| | | |
| | | private static boolean validDeviceTime(Instant deviceTime, String deviceTimeRaw) |
| | | { |
| | | try |
| | | { |
| | | Instant parsed = LocalDateTime.parse(deviceTimeRaw, DEVICE_TIME_FORMATTER) |
| | | .atZone(DEVICE_ZONE).toInstant(); |
| | | return parsed.equals(deviceTime.truncatedTo(ChronoUnit.MILLIS)); |
| | | } |
| | | catch (DateTimeParseException exception) |
| | | { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | private static List<CollectorRealtimeMeasurement> retainConfiguredMeasurements( |
| | | List<CollectorRealtimeMeasurement> measurements, List<CollectorChannelState> channels) |
| | | { |
| | | Set<String> configured = configuredMeasurementKeys(channels); |
| | | return measurements.stream() |
| | | .filter(item -> configured.contains(measurementKey(item.channelCode(), item.metricCode()))) |
| | | .toList(); |
| | | } |
| | | |
| | | private static Set<String> configuredMeasurementKeys(List<CollectorChannelState> channels) |
| | | { |
| | | Set<String> configured = new java.util.HashSet<>(); |
| | | for (CollectorChannelState channel : channels) |
| | | for (CollectorMetricState metric : channel.metrics()) |
| | | configured.add(measurementKey(channel.channelCode(), metric.metricCode())); |
| | | return configured; |
| | | } |
| | | |
| | | private static String measurementKey(String channelCode, String metricCode) |
| | | { |
| | | return channelCode + "\u0000" + metricCode; |
| | | } |
| | | |
| | | private static CollectorHistoryPage faultedHistoryPage(CollectorState current, |
| | | BtsSimulatorStateStore stateStore, long offset, int limit) |
| | | { |
| | | // 故障视图需返回精确 total,因此逐批扫描原始历史;正常下载会直接读取目标页。 |
| | | CollectorHistoryFaults faults = current.historyFaults(); |
| | | Set<Long> missing = Set.copyOf(faults.missingSequences()); |
| | | List<CollectorMeasurementRecord> page = new ArrayList<>(limit); |
| | | long rawOffset = 0; |
| | | long total = 0; |
| | | while (true) |
| | | { |
| | | BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage( |
| | | current.deviceCode(), rawOffset, MAX_HISTORY_PAGE_SIZE, faults.outOfOrder()); |
| | | for (CollectorMeasurementRecord record : batch.records()) |
| | | { |
| | | if (missing.contains(record.sequenceId())) continue; |
| | | total = appendFaultedRecord(page, record, total, offset, limit); |
| | | if (Long.valueOf(record.sequenceId()).equals(faults.duplicateSequence())) |
| | | total = appendFaultedRecord(page, record, total, offset, limit); |
| | | if (Long.valueOf(record.sequenceId()).equals(faults.conflictingSequence())) |
| | | total = appendFaultedRecord(page, conflictingRecord(record), total, offset, limit); |
| | | } |
| | | if (!batch.hasMore()) break; |
| | | if (batch.nextOffset() <= rawOffset) |
| | | throw new IllegalStateException("Invalid collector history page in Redis"); |
| | | rawOffset = batch.nextOffset(); |
| | | } |
| | | long from = Math.min(offset, total); |
| | | long nextOffset = from + page.size(); |
| | | return new CollectorHistoryPage(current.deviceCode(), offset, nextOffset, nextOffset < total, total, page); |
| | | } |
| | | |
| | | private static long appendFaultedRecord(List<CollectorMeasurementRecord> page, |
| | | CollectorMeasurementRecord record, long index, long offset, int limit) |
| | | { |
| | | if (index >= offset && page.size() < limit) page.add(record); |
| | | return index + 1; |
| | | } |
| | | |
| | | private static CollectorMeasurementRecord conflictingRecord(CollectorMeasurementRecord record) |
| | | { |
| | | return new CollectorMeasurementRecord(record.adapterCode(), record.collectorCode(), record.channelCode(), |
| | | record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(), |
| | | record.metricCode(), record.value().add(new BigDecimal("0.001")), record.unit()); |
| | | } |
| | | |
| | | private static CollectorState copy(CollectorState current, String ip, int channelCount, boolean online, |
| | | int sampleIntervalMs, long nextSequence, Instant lastSampledAt, |
| | | List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements, |
| | | long historyRecordCount, |
| | | CollectorHistoryFaults faults) |
| | | { |
| | | return new CollectorState(current.collectorId(), current.deviceCode(), ip.trim(), current.mac(), |
| | | current.name(), current.manufacturer(), current.model(), channelCount, online, sampleIntervalMs, |
| | | nextSequence, lastSampledAt, channels, latestMeasurements, historyRecordCount, faults); |
| | | } |
| | | |
| | | private static void validateSampleInterval(int sampleIntervalMs) |
| | | { |
| | | if (sampleIntervalMs < MIN_SAMPLE_INTERVAL_MS || sampleIntervalMs > MAX_SAMPLE_INTERVAL_MS) |
| | | throw new IllegalArgumentException("sampleIntervalMs must be between " + MIN_SAMPLE_INTERVAL_MS |
| | | + " and " + MAX_SAMPLE_INTERVAL_MS); |
| | | return new CollectorState(current.collectorId(), current.deviceCode(), current.ip(), current.mac(), |
| | | current.name(), current.manufacturer(), current.model(), current.channelCount(), current.online(), |
| | | current.sampleIntervalMs(), channels, measurements); |
| | | } |
| | | |
| | | private static void validateMetrics(List<CollectorMetricState> metrics) |
| | |
| | | || !metric.unit().equals(metric.unit().trim()) || !codes.add(metric.metricCode())) |
| | | throw new IllegalArgumentException("Collector metric code, value and unit must be valid and unique"); |
| | | } |
| | | } |
| | | |
| | | private static void validateFaults(CollectorHistoryFaults faults) |
| | | { |
| | | if (faults == null) throw new IllegalArgumentException("historyFaults must not be null"); |
| | | if (faults.missingSequences().stream().anyMatch(sequence -> sequence == null || sequence < 1) |
| | | || faults.duplicateSequence() != null && faults.duplicateSequence() < 1 |
| | | || faults.conflictingSequence() != null && faults.conflictingSequence() < 1) |
| | | throw new IllegalArgumentException("History fault sequences must be positive"); |
| | | } |
| | | |
| | | private static int parseChannelIndex(String value, int channelCount) |
| | |
| | | public CollectorRealtimeSnapshot { measurements = List.copyOf(measurements); } |
| | | } |
| | | |
| | | public record CollectorAdvance(CollectorState state, List<CollectorMeasurementRecord> appendedRecords) |
| | | { |
| | | public CollectorAdvance { appendedRecords = List.copyOf(appendedRecords); } |
| | | } |
| | | |
| | | public record CollectorHistoryPage(String collectorCode, long offset, long nextOffset, boolean hasMore, |
| | | long total, List<CollectorMeasurementRecord> records) |
| | | { |
| | | public CollectorHistoryPage { records = List.copyOf(records); } |
| | | } |
| | | } |
| | |
| | | package com.battery.device.simulator; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryBatch; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryBatch; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState; |
| | | |
| | | /** 测试和独立协议合同使用的内存状态存储。 */ |
| | | public final class InMemoryBtsSimulatorStateStore implements BtsSimulatorStateStore |
| | | { |
| | | private SimulatorState state; |
| | | private List<RecentRequest> recentRequests = List.of(); |
| | | private final Map<String, ArrayList<CollectorMeasurementRecord>> collectorHistory = new LinkedHashMap<>(); |
| | | private final Map<String, ArrayList<BtsHistoryRecord>> btsHistory = new LinkedHashMap<>(); |
| | | private final Map<String, BtsPlaybackState> btsPlayback = new LinkedHashMap<>(); |
| | | |
| | | public InMemoryBtsSimulatorStateStore() { } |
| | | |
| | | public InMemoryBtsSimulatorStateStore(SimulatorState state) |
| | | { |
| | | this.state = state; |
| | | } |
| | | public InMemoryBtsSimulatorStateStore(SimulatorState state) { this.state = state; } |
| | | |
| | | @Override |
| | | public synchronized SimulatorState loadOrInitialize(SimulatorState defaultState) |
| | | public synchronized SimulatorState loadOrInitialize(SimulatorState defaults) |
| | | { |
| | | if (state == null) state = defaultState; |
| | | if (state == null) state = defaults; |
| | | return state; |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void save(SimulatorState state) |
| | | { |
| | | this.state = state; |
| | | } |
| | | |
| | | public synchronized void save(SimulatorState state) { this.state = state; } |
| | | @Override |
| | | public synchronized void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends) |
| | | { |
| | | for (CollectorHistoryAppend append : historyAppends) |
| | | { |
| | | ArrayList<CollectorMeasurementRecord> history = collectorHistory.computeIfAbsent( |
| | | append.collectorCode(), key -> new ArrayList<>()); |
| | | history.addAll(append.records()); |
| | | int overflow = history.size() - (int) MAX_HISTORY_RECORDS; |
| | | if (overflow > 0) history.subList(0, overflow).clear(); |
| | | } |
| | | this.state = state; |
| | | } |
| | | |
| | | public synchronized List<RecentRequest> loadRecentRequests() { return recentRequests; } |
| | | @Override |
| | | public synchronized CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, |
| | | int limit, boolean reverse) |
| | | { |
| | | if (offset < 0) throw new IllegalArgumentException("offset must not be negative"); |
| | | if (limit < 1 || limit > MAX_HISTORY_BATCH_SIZE) |
| | | throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_BATCH_SIZE); |
| | | List<CollectorMeasurementRecord> history = collectorHistory.getOrDefault(collectorCode, |
| | | new ArrayList<>()); |
| | | long total = history.size(); |
| | | int from = (int) Math.min(offset, total); |
| | | int count = Math.min(limit, history.size() - from); |
| | | List<CollectorMeasurementRecord> records = new ArrayList<>(count); |
| | | if (reverse) |
| | | { |
| | | for (int index = history.size() - 1 - from; index >= history.size() - from - count; index--) |
| | | records.add(history.get(index)); |
| | | } |
| | | else |
| | | { |
| | | records.addAll(history.subList(from, from + count)); |
| | | } |
| | | long nextOffset = from + records.size(); |
| | | return new CollectorHistoryBatch(offset, nextOffset, nextOffset < total, total, records); |
| | | } |
| | | |
| | | public synchronized void saveRecentRequests(List<RecentRequest> requests) { recentRequests = List.copyOf(requests); } |
| | | @Override |
| | | public synchronized void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode) |
| | | public synchronized void reset(SimulatorState defaults) |
| | | { |
| | | collectorHistory.remove(collectorCode); |
| | | this.state = state; |
| | | } |
| | | |
| | | @Override |
| | | public synchronized BtsHistoryBatch loadBtsHistoryPage(String historyKey, long offset, int limit) |
| | | { |
| | | List<BtsHistoryRecord> history = btsHistory.getOrDefault(historyKey, new ArrayList<>()); |
| | | int from = (int) Math.min(offset, history.size()); |
| | | int count = Math.min(limit, history.size() - from); |
| | | List<BtsHistoryRecord> records = new ArrayList<>(history.subList(from, from + count)); |
| | | long next = from + records.size(); |
| | | return new BtsHistoryBatch(offset, next, next < history.size(), history.size(), records); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void appendBtsHistory(String historyKey, BtsHistoryRecord record) |
| | | { |
| | | ArrayList<BtsHistoryRecord> history = btsHistory.computeIfAbsent(historyKey, key -> new ArrayList<>()); |
| | | history.add(record); |
| | | if (history.size() > MAX_HISTORY_RECORDS) history.remove(0); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized BtsPlaybackState loadBtsPlaybackState(String historyKey) |
| | | { |
| | | return btsPlayback.get(historyKey); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void saveBtsPlaybackState(String historyKey, BtsPlaybackState playback) |
| | | { |
| | | btsPlayback.put(historyKey, playback); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void deleteBtsPlaybackState(String historyKey) |
| | | { |
| | | btsPlayback.remove(historyKey); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void deleteBtsHistory(String historyKey) |
| | | { |
| | | btsHistory.remove(historyKey); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void saveBtsPlaybackAndAppendHistory(String historyKey, BtsPlaybackState playback, |
| | | List<BtsHistoryRecord> records) |
| | | { |
| | | ArrayList<BtsHistoryRecord> history = btsHistory.computeIfAbsent(historyKey, key -> new ArrayList<>()); |
| | | history.addAll(records); |
| | | int overflow = history.size() - (int) MAX_HISTORY_RECORDS; |
| | | if (overflow > 0) history.subList(0, overflow).clear(); |
| | | btsPlayback.put(historyKey, playback); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized List<RecentRequest> loadRecentRequests() |
| | | { |
| | | return recentRequests; |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void saveRecentRequests(List<RecentRequest> requests) |
| | | { |
| | | recentRequests = List.copyOf(requests); |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void reset(SimulatorState defaultState) |
| | | { |
| | | state = defaultState; |
| | | state = defaults; |
| | | recentRequests = List.of(); |
| | | collectorHistory.clear(); |
| | | btsHistory.clear(); |
| | | btsPlayback.clear(); |
| | | } |
| | | } |
| | |
| | | import com.battery.device.simulator.BtsSimulatorStateStore; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryBatch; |
| | | 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.CollectorState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState; |
| | |
| | | class SimulatorPublicContractTest |
| | | { |
| | | @Test |
| | | void collectorGeneratesIndependentRealtimeAndHistoricalMeasurements() throws Exception |
| | | { |
| | | Instant startedAt = Instant.parse("2026-08-29T01:00:00Z"); |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | simulator.configureCollectorSampling(1, 200, CollectorHistoryFaults.none()); |
| | | simulator.configureCollectorChannel(1, "S-01", List.of( |
| | | new CollectorMetricState("temperature", new BigDecimal("26.5"), "℃"), |
| | | new CollectorMetricState("positive_potential", new BigDecimal("3.801"), "V"), |
| | | new CollectorMetricState("negative_potential", new BigDecimal("-0.120"), "V"), |
| | | new CollectorMetricState("pressure", new BigDecimal("101.2"), "kPa"))); |
| | | |
| | | simulator.advanceCollectors(startedAt); |
| | | simulator.advanceCollectors(startedAt.plusMillis(400)); |
| | | |
| | | var realtime = simulator.snapshot().collectors().get(0).latestMeasurements(); |
| | | var history = simulator.collectorHistory(1, 0, 1000); |
| | | assertEquals(13, realtime.size()); |
| | | assertTrue(realtime.stream().allMatch(item -> item.sequenceId() == 3)); |
| | | assertTrue(realtime.stream().anyMatch(item -> item.channelCode().equals("S-01") |
| | | && item.metricCode().equals("pressure") && item.unit().equals("kPa"))); |
| | | assertEquals(39, history.total()); |
| | | assertEquals("dev-sim", history.records().get(0).adapterCode()); |
| | | assertEquals("dev-sim-collector-1", history.records().get(0).collectorCode()); |
| | | assertEquals(startedAt, history.records().get(0).deviceTime()); |
| | | assertTrue(history.records().get(0).deviceTimeRaw().startsWith("2026-08-29 09:00:00")); |
| | | assertEquals(startedAt, history.records().get(0).serverReceivedAt()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void collectorHistorySupportsPagingAndRepeatableFaultScenarios() throws Exception |
| | | { |
| | | Instant startedAt = Instant.parse("2026-08-29T02:00:00Z"); |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | simulator.configureCollectorSampling(1, 100, |
| | | new CollectorHistoryFaults(List.of(2L), 1L, 3L, true)); |
| | | simulator.advanceCollectors(startedAt); |
| | | simulator.advanceCollectors(startedAt.plusMillis(200)); |
| | | |
| | | var first = simulator.collectorHistory(1, 0, 17); |
| | | var second = simulator.collectorHistory(1, first.nextOffset(), 1000); |
| | | List<com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord> records = |
| | | new java.util.ArrayList<>(first.records()); |
| | | records.addAll(second.records()); |
| | | |
| | | assertEquals(48, first.total()); |
| | | assertTrue(first.hasMore()); |
| | | assertFalse(second.hasMore()); |
| | | assertTrue(records.stream().noneMatch(item -> item.sequenceId() == 2)); |
| | | assertEquals(24, records.stream().filter(item -> item.sequenceId() == 1).count()); |
| | | assertEquals(2, records.stream().filter(item -> item.sequenceId() == 3 |
| | | && item.channelCode().equals("S-01") && item.metricCode().equals("temperature")).count()); |
| | | assertTrue(records.get(0).sequenceId() >= records.get(records.size() - 1).sequenceId()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void collectorStopsWhileOfflineAndResumesFromPersistedSequence() throws Exception |
| | | { |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); |
| | | Instant now = Instant.now(); |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | simulator.advanceCollectors(now); |
| | | simulator.updateCollector(1, "127.0.1.2", 4, false); |
| | | simulator.advanceCollectors(now.plusSeconds(10)); |
| | | assertEquals(1, simulator.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId()); |
| | | } |
| | | |
| | | try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | restored.updateCollector(1, "127.0.1.2", 4, true); |
| | | restored.advanceCollectors(Instant.now().plusSeconds(2)); |
| | | assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId() >= 2); |
| | | assertEquals(List.of("S-01", "S-02", "S-03", "S-04"), |
| | | restored.snapshot().collectors().get(0).channels().stream() |
| | | .map(channel -> channel.channelCode()).toList()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void simulatorPowerSwitchStopsSamplingAndRestoresWithoutDeletingHistory() throws Exception |
| | | { |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); |
| | | long historyCount; |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | simulator.advanceCollectors(Instant.now()); |
| | | historyCount = simulator.snapshot().collectors().get(0).historyRecordCount(); |
| | | |
| | | simulator.setAllDevicesPoweredOn(false); |
| | | simulator.advanceCollectors(Instant.now().plusSeconds(10)); |
| | | |
| | | var poweredOff = simulator.snapshot(); |
| | | assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online() |
| | | || device.channels().stream().anyMatch(channel -> channel.online() |
| | | || channel.workStatus() != DeviceWorkStatus.STOPPED))); |
| | | assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online())); |
| | | assertEquals(historyCount, poweredOff.collectors().get(0).historyRecordCount()); |
| | | } |
| | | |
| | | try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | assertFalse(restored.snapshot().collectors().get(0).online()); |
| | | assertEquals(historyCount, restored.collectorHistory(1, 0, 1000).total()); |
| | | |
| | | restored.setAllDevicesPoweredOn(true); |
| | | var poweredOn = restored.snapshot(); |
| | | assertTrue(poweredOn.devices().stream().allMatch(device -> device.online() |
| | | && device.channels().stream().allMatch(channel -> channel.online() |
| | | && channel.workStatus() == DeviceWorkStatus.STOPPED))); |
| | | assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online())); |
| | | restored.advanceCollectors(Instant.now().plusSeconds(2)); |
| | | assertTrue(restored.snapshot().collectors().get(0).historyRecordCount() > historyCount); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void collectorKeepsOnlyBoundedDemoHistory() throws Exception |
| | | { |
| | | Instant startedAt = Instant.parse("2026-08-29T03:00:00Z"); |
| | | TrackingHistoryStore store = new TrackingHistoryStore(); |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | simulator.configureCollectorSampling(1, 100, CollectorHistoryFaults.none()); |
| | | simulator.advanceCollectors(startedAt); |
| | | for (int batch = 1; batch <= 5; batch++) |
| | | simulator.advanceCollectors(startedAt.plusMillis(batch * 100_000L)); |
| | | |
| | | store.resetReadStats(); |
| | | var page = simulator.collectorHistory(1, 0, 1); |
| | | assertEquals(BtsSimulatorStateStore.MAX_HISTORY_RECORDS, page.total()); |
| | | assertTrue(page.records().get(0).sequenceId() > 1); |
| | | assertEquals(1, store.historyReads); |
| | | assertEquals(1, store.recordsRead); |
| | | } |
| | | |
| | | store.resetReadStats(); |
| | | try (BtsDevelopmentSimulator ignored = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | assertTrue(store.historyReads > 1); |
| | | assertEquals(1000, store.maximumRequestedLimit); |
| | | assertEquals(BtsSimulatorStateStore.MAX_HISTORY_RECORDS, store.recordsRead); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void collectorRawHistoryIsNotEmbeddedInMainSimulatorState() throws Exception |
| | | { |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | simulator.advanceCollectors(Instant.parse("2026-08-29T03:30:00Z")); |
| | | |
| | | String mainState = JSON.toJSONString(store.loadOrInitialize(null)); |
| | | assertFalse(mainState.contains("\"history\":[{")); |
| | | assertEquals(12, simulator.collectorHistory(1, 0, 100).total()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void collectorRealtimeDropsMeasurementsRemovedFromCurrentConfiguration() throws Exception |
| | | { |
| | | Instant startedAt = Instant.parse("2026-08-29T04:00:00Z"); |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | simulator.advanceCollectors(startedAt); |
| | | simulator.collectorRealtime(1); |
| | | assertEquals(12, simulator.snapshot().collectors().get(0).latestMeasurements().size()); |
| | | |
| | | simulator.configureCollectorChannel(1, "S-01", List.of( |
| | | new CollectorMetricState("temperature", new BigDecimal("27.0"), "℃"))); |
| | | simulator.updateCollector(1, "127.0.1.2", 2, true); |
| | | |
| | | simulator.collectorRealtime(1); |
| | | var snapshot = simulator.snapshot().collectors().get(0); |
| | | assertEquals(4, snapshot.latestMeasurements().size()); |
| | | assertTrue(snapshot.latestMeasurements().stream() |
| | |
| | | || item.channelCode().equals("S-04") |
| | | || item.channelCode().equals("S-01") |
| | | && item.metricCode().equals("positive_potential"))); |
| | | assertEquals(12, simulator.collectorHistory(1, 0, 100).total()); |
| | | } |
| | | |
| | | try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | assertEquals(4, restored.snapshot().collectors().get(0).latestMeasurements().size()); |
| | | assertEquals(12, restored.collectorHistory(1, 0, 100).total()); |
| | | assertEquals(4, restored.collectorRealtime(1).measurements().size()); |
| | | } |
| | | } |
| | | |
| | |
| | | { |
| | | @Override public SimulatorState loadOrInitialize(SimulatorState value) |
| | | { return delegate.loadOrInitialize(value); } |
| | | @Override public void save(SimulatorState value) { delegate.save(value); } |
| | | @Override public void saveAndDeleteCollectorHistory(SimulatorState value, String collectorCode) |
| | | { throw new IllegalStateException("redis unavailable"); } |
| | | @Override public void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); } |
| | | @Override public java.util.List<RecentRequest> loadRecentRequests() |
| | | { return delegate.loadRecentRequests(); } |
| | | @Override public void saveRecentRequests(java.util.List<RecentRequest> requests) |
| | |
| | | () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged))); |
| | | } |
| | | |
| | | @Test |
| | | void developmentSimulatorRejectsDamagedCollectorHistoryIdentity() |
| | | { |
| | | Instant observedAt = Instant.parse("2026-08-29T05:00:00Z"); |
| | | List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of( |
| | | new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃")))); |
| | | CollectorMeasurementRecord record = new CollectorMeasurementRecord( |
| | | "dev-sim", "wrong-collector", "S-01", 1L, observedAt, |
| | | "2026-08-29 13:00:00.000", observedAt, "temperature", |
| | | new BigDecimal("25.0"), "℃"); |
| | | CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2", |
| | | "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true, |
| | | 1000, 2L, observedAt, channels, List.of(), 1L, CollectorHistoryFaults.none()); |
| | | SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, |
| | | List.of(), List.of(collector), 1, 2, List.of()); |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged); |
| | | store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), List.of(record)))); |
| | | |
| | | assertThrows(IllegalStateException.class, |
| | | () -> new BtsDevelopmentSimulator(0, store)); |
| | | } |
| | | |
| | | @Test |
| | | void developmentSimulatorRejectsCollectorSequenceBehindPersistedHistory() |
| | | { |
| | | Instant observedAt = Instant.parse("2026-08-29T06:00:00Z"); |
| | | List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of( |
| | | new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃")))); |
| | | CollectorMeasurementRecord record = new CollectorMeasurementRecord( |
| | | "dev-sim", "dev-sim-collector-1", "S-01", 1L, observedAt, |
| | | "2026-08-29 14:00:00.000", observedAt, "temperature", |
| | | new BigDecimal("25.0"), "℃"); |
| | | CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2", |
| | | "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true, |
| | | 1000, 1L, observedAt, channels, List.of(), 1L, CollectorHistoryFaults.none()); |
| | | SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, |
| | | List.of(), List.of(collector), 1, 2, List.of()); |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged); |
| | | store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), List.of(record)))); |
| | | |
| | | assertThrows(IllegalStateException.class, |
| | | () -> new BtsDevelopmentSimulator(0, store)); |
| | | } |
| | | |
| | | @Test |
| | | void developmentSimulatorRejectsHistorySequenceRegressionAcrossPages() |
| | | { |
| | | Instant observedAt = Instant.parse("2026-08-29T06:30:00Z"); |
| | | List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of( |
| | | new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃")))); |
| | | List<CollectorMeasurementRecord> records = new java.util.ArrayList<>(); |
| | | for (long sequence = 1; sequence <= 1000; sequence++) |
| | | records.add(new CollectorMeasurementRecord("dev-sim", "dev-sim-collector-1", "S-01", sequence, |
| | | observedAt, "2026-08-29 14:30:00.000", observedAt, "temperature", |
| | | new BigDecimal("25.0"), "℃")); |
| | | records.add(new CollectorMeasurementRecord("dev-sim", "dev-sim-collector-1", "S-01", 999, |
| | | observedAt, "2026-08-29 14:30:00.000", observedAt, "temperature", |
| | | new BigDecimal("25.0"), "℃")); |
| | | CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2", |
| | | "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true, |
| | | 1000, 1001L, observedAt, channels, List.of(), records.size(), CollectorHistoryFaults.none()); |
| | | SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, |
| | | List.of(), List.of(collector), 1, 2, List.of()); |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged); |
| | | store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), records))); |
| | | |
| | | assertThrows(IllegalStateException.class, |
| | | () -> new BtsDevelopmentSimulator(0, store)); |
| | | } |
| | | |
| | | private static final class TrackingHistoryStore implements BtsSimulatorStateStore |
| | | { |
| | | private final InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore(); |
| | | private int historyReads; |
| | | private int maximumRequestedLimit; |
| | | private long recordsRead; |
| | | |
| | | @Override |
| | | public SimulatorState loadOrInitialize(SimulatorState defaultState) |
| | | { |
| | | return delegate.loadOrInitialize(defaultState); |
| | | } |
| | | |
| | | @Override |
| | | public void save(SimulatorState state) |
| | | { |
| | | delegate.save(state); |
| | | } |
| | | |
| | | @Override |
| | | public void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends) |
| | | { |
| | | delegate.save(state, historyAppends); |
| | | } |
| | | |
| | | @Override |
| | | public CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, int limit, |
| | | boolean reverse) |
| | | { |
| | | CollectorHistoryBatch batch = delegate.loadCollectorHistoryPage(collectorCode, offset, limit, reverse); |
| | | historyReads++; |
| | | maximumRequestedLimit = Math.max(maximumRequestedLimit, limit); |
| | | recordsRead += batch.records().size(); |
| | | return batch; |
| | | } |
| | | |
| | | @Override |
| | | public void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode) |
| | | { |
| | | delegate.saveAndDeleteCollectorHistory(state, collectorCode); |
| | | } |
| | | |
| | | @Override |
| | | public List<RecentRequest> loadRecentRequests() |
| | | { |
| | | return delegate.loadRecentRequests(); |
| | | } |
| | | |
| | | @Override |
| | | public void saveRecentRequests(List<RecentRequest> requests) |
| | | { |
| | | delegate.saveRecentRequests(requests); |
| | | } |
| | | |
| | | @Override |
| | | public void reset(SimulatorState defaultState) |
| | | { |
| | | delegate.reset(defaultState); |
| | | } |
| | | |
| | | private void resetReadStats() |
| | | { |
| | | historyReads = 0; |
| | | maximumRequestedLimit = 0; |
| | | recordsRead = 0; |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | class SimulatorObservationAdapterContractTest |
| | | { |
| | | @Test void configuredChannelFactsSurviveSoftwareFailureAndRestart() throws Exception |
| | | { |
| | | var store = new InMemoryBtsSimulatorStateStore(); |
| | | long firstSequence; |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | simulator.updateChannel(1, 1, true, DeviceWorkStatus.PROTECTED); |
| | | simulator.updateCollectorChannelStatus(1, "S-01", DeviceWorkStatus.UNOBSERVABLE); |
| | | var collector = simulator.latestCollector("dev-sim-collector-1"); |
| | | assertTrue(collector.measurements().stream().noneMatch(item -> item.channelCode().equals("S-01"))); |
| | | firstSequence = collector.measurements().get(0).sequenceId(); |
| | | simulator.setSoftwareAccessible(false); |
| | | simulator.updateDevice(1, "127.0.0.8", 4, false); |
| | | assertFalse(simulator.snapshot().softwareAccessible()); |
| | | } |
| | | try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store)) |
| | | { |
| | | assertFalse(restored.snapshot().softwareAccessible()); |
| | | assertEquals("127.0.0.8", restored.snapshot().devices().get(0).ip()); |
| | | assertEquals(DeviceWorkStatus.PROTECTED, restored.snapshot().devices().get(0).channels().get(0).workStatus()); |
| | | assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().isEmpty()); |
| | | restored.setSoftwareAccessible(true); |
| | | assertTrue(restored.latestCollector("dev-sim-collector-1").measurements().get(0).sequenceId() > firstSequence); |
| | | assertEquals(DeviceWorkStatus.UNOBSERVABLE, restored.snapshot().collectors().get(0).channels().get(0).workStatus()); |
| | | } |
| | | } |
| | | |
| | | @Test void offlineCollectorDoesNotReturnLastSuccessAndStateReadsDoNotSample() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | assertTrue(simulator.snapshot().collectors().get(0).latestMeasurements().isEmpty()); |
| | | var first = simulator.collectorRealtime(1); |
| | | var recent = simulator.snapshot().recentRequests(); |
| | | assertEquals(recent, simulator.snapshot().recentRequests()); |
| | | simulator.updateCollector(1, "127.0.1.2", 4, false); |
| | | assertFalse(simulator.collectorRealtime(1).online()); |
| | | assertTrue(simulator.collectorRealtime(1).measurements().isEmpty()); |
| | | simulator.updateCollector(1, "127.0.1.2", 4, true); |
| | | assertTrue(simulator.collectorRealtime(1).measurements().get(0).sequenceId() > first.measurements().get(0).sequenceId()); |
| | | var channel = simulator.discoverChannels().get(0).identity(); |
| | | simulator.updateChannel(1, 1, true, DeviceWorkStatus.UNOBSERVABLE); |
| | | assertNull(simulator.latestCabinet(channel)); |
| | | assertEquals(DeviceWorkStatus.UNOBSERVABLE, simulator.observe(java.util.Set.of(channel)).get(channel).workStatus()); |
| | | } |
| | | } |
| | | |
| | | @Test void softwareFailurePreservesIdentityAndDoesNotReplayMeasurements() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | var channel = simulator.discoverChannels().get(0).identity(); |
| | | simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING); |
| | | var first = simulator.latestCabinet(channel); |
| | | assertNotNull(first); |
| | | assertFalse(simulator.latestCollector("dev-sim-collector-1").measurements().isEmpty()); |
| | | simulator.setSoftwareAccessible(false); |
| | | assertEquals(channel, simulator.discoverChannels().get(0).identity()); |
| | | assertTrue(simulator.snapshot().devices().get(0).online()); |
| | | assertEquals(DeviceWorkStatus.UNOBSERVABLE, |
| | | simulator.observe(java.util.Set.of(channel)).get(channel).workStatus()); |
| | | assertNull(simulator.latestCabinet(channel)); |
| | | assertTrue(simulator.latestCollector("dev-sim-collector-1").measurements().isEmpty()); |
| | | simulator.setSoftwareAccessible(true); |
| | | var next = simulator.latestCabinet(channel); |
| | | assertTrue(next.sequenceId() > first.sequenceId()); |
| | | assertFalse(next.deviceTime().isBefore(first.deviceTime())); |
| | | assertTrue(simulator.stepRecords(channel).isEmpty()); |
| | | assertTrue(simulator.cycleRecords(channel).isEmpty()); |
| | | assertEquals(60000L, simulator.persistenceIntervalMillis(channel)); |
| | | } |
| | | } |
| | | |
| | | @Test void persistenceRejectsForeignIdentitiesAndOfflineCachedFacts() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | ChannelIdentity channel = simulator.discoverChannels().get(0).identity(); |
| | | simulator.updateChannel(channel.devid(), channel.chlid(), true, DeviceWorkStatus.WORKING); |
| | | simulator.advanceBts(java.time.Instant.now()); |
| | | assertNotNull(simulator.latestCabinet(channel)); |
| | | for (ChannelIdentity foreign : java.util.List.of( |
| | | new ChannelIdentity("another-adapter", channel.devtype(), channel.devid(), channel.subdevid(), channel.chlid()), |
| | |
| | | List<CollectorCandidate> candidates = simulatorState.collectors().stream() |
| | | .map(item -> new CollectorCandidate(BtsDevelopmentSimulator.ADAPTER_CODE, item.deviceCode(), |
| | | item.ip(), item.mac(), item.name(), |
| | | item.manufacturer(), item.model(), item.channelCount(), item.online(), observedAt, |
| | | item.manufacturer(), item.model(), item.channelCount(), simulatorState.softwareAccessible() && item.online(), observedAt, |
| | | observedAt.plus(Duration.ofSeconds(30)))) |
| | | .toList(); |
| | | candidateStore.replaceCollectors(BtsDevelopmentSimulator.ADAPTER_CODE, candidates); |
| | |
| | | @Profile("dev-simulator") |
| | | public class DevelopmentSimulatorDiscoveryScheduler |
| | | { |
| | | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DevelopmentSimulatorDiscoveryScheduler.class); |
| | | private final DeviceAdapterRegistry registry; |
| | | private final DeviceDiscoveryPersistenceService persistence; |
| | | public DevelopmentSimulatorDiscoveryScheduler(DeviceAdapterRegistry registry, |
| | |
| | | public void discover() |
| | | { |
| | | if (!registry.contains(BtsDevelopmentSimulator.ADAPTER_CODE)) return; |
| | | persistence.discoverForReception(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null, |
| | | BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS)); |
| | | try |
| | | { |
| | | persistence.discoverForReception(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null, |
| | | BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS)); |
| | | } |
| | | catch (RuntimeException exception) |
| | | { |
| | | persistence.markUnavailable(BtsDevelopmentSimulator.ADAPTER_CODE); |
| | | log.warn("模拟设备观测失败,已清除过期实时值:{}", exception.getMessage()); |
| | | } |
| | | } |
| | | } |
| | |
| | | package com.battery.business.runtime; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.Instant; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | import java.util.Set; |
| | | import java.util.concurrent.TimeUnit; |
| | | import org.springframework.data.redis.core.RedisOperations; |
| | | import org.springframework.data.redis.core.SessionCallback; |
| | | import org.springframework.data.redis.core.RedisTemplate; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.context.annotation.Profile; |
| | |
| | | import com.alibaba.fastjson2.JSONObject; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryBatch; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | |
| | | /** 将开发模拟器完整状态写入 Redis,主状态不设置普通 TTL。 */ |
| | |
| | | { |
| | | static final String STATE_KEY = "dev:bts-simulator:state:v1"; |
| | | static final String RECENT_REQUESTS_KEY = "dev:bts-simulator:recent-requests:v1"; |
| | | static final String HISTORY_KEYS_KEY = "dev:bts-simulator:collector-history-keys:v1"; |
| | | static final String HISTORY_KEY_PREFIX = "dev:bts-simulator:collector-history:v1:"; |
| | | // 历史与播放位置使用独立键版本,避免把旧的三循环演示历史恢复到当前时间轴。 |
| | | static final String BTS_HISTORY_KEYS_KEY = "dev:bts-simulator:bts-history-keys:v2"; |
| | | static final String BTS_HISTORY_KEY_PREFIX = "dev:bts-simulator:bts-history:v2:"; |
| | | static final String BTS_PLAYBACK_KEY_PREFIX = "dev:bts-simulator:bts-playback:v2:"; |
| | | private static final long RECENT_REQUESTS_TTL_HOURS = 1; |
| | | |
| | | private final RedisTemplate<Object, Object> redisTemplate; |
| | | private List<CollectorHistoryAppend> pendingEmbeddedHistory = List.of(); |
| | | |
| | | public RedisBtsSimulatorStateStore(RedisTemplate<Object, Object> redisTemplate) |
| | | { |
| | |
| | | } |
| | | |
| | | @Override |
| | | public SimulatorState resetHistoryForDemoStartup(SimulatorState state) |
| | | { |
| | | if (pendingEmbeddedHistory.isEmpty()) return state; |
| | | java.util.Map<String, Integer> embeddedCounts = new java.util.HashMap<>(); |
| | | pendingEmbeddedHistory.forEach(append -> embeddedCounts.put(append.collectorCode(), append.records().size())); |
| | | List<CollectorState> collectors = state.collectors().stream().map(collector -> { |
| | | int embeddedCount = embeddedCounts.getOrDefault(collector.deviceCode(), 0); |
| | | if (embeddedCount == 0) return collector; |
| | | long remaining = Math.max(0L, collector.historyRecordCount() - embeddedCount); |
| | | return new CollectorState(collector.collectorId(), collector.deviceCode(), collector.ip(), collector.mac(), |
| | | collector.name(), collector.manufacturer(), collector.model(), collector.channelCount(), |
| | | collector.online(), collector.sampleIntervalMs(), collector.nextSequence(), collector.lastSampledAt(), |
| | | collector.channels(), collector.latestMeasurements().stream() |
| | | .filter(item -> collector.deviceCode().equals(item.collectorCode())) |
| | | .toList(), remaining, collector.historyFaults()); |
| | | }).toList(); |
| | | // 不删除独立历史 Redis key;这里只阻止旧内嵌数据在本次启动时迁移回来。 |
| | | pendingEmbeddedHistory = List.of(); |
| | | SimulatorState cleaned = new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), |
| | | state.nextCollectorId(), state.tombstones()); |
| | | save(cleaned); |
| | | return cleaned; |
| | | } |
| | | |
| | | private SimulatorState restoreState(Object stored) |
| | | { |
| | | ParsedSimulatorState parsed = parseState(stored); |
| | | // 旧格式历史先只供恢复校验读取,损坏状态不得被迁移写回覆盖。 |
| | | pendingEmbeddedHistory = parsed.embeddedHistory(); |
| | | return parsed.state(); |
| | | } |
| | | |
| | | @Override |
| | | public void completeValidatedRestore(SimulatorState state) |
| | | { |
| | | if (pendingEmbeddedHistory.isEmpty()) return; |
| | | save(state, pendingEmbeddedHistory); |
| | | pendingEmbeddedHistory = List.of(); |
| | | } |
| | | |
| | | @Override |
| | | public void save(SimulatorState state) |
| | | { |
| | | save(state, List.of()); |
| | | } |
| | | |
| | | @Override |
| | | @SuppressWarnings({ "rawtypes", "unchecked" }) |
| | | public void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends) |
| | | { |
| | | List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() |
| | | { |
| | | @Override |
| | | public <K, V> List<Object> execute(RedisOperations<K, V> operations) |
| | | { |
| | | RedisOperations<Object, Object> typed = (RedisOperations) operations; |
| | | typed.multi(); |
| | | for (CollectorHistoryAppend append : historyAppends) |
| | | { |
| | | String historyKey = historyKey(append.collectorCode()); |
| | | if (!append.records().isEmpty()) |
| | | { |
| | | List<Object> records = new ArrayList<>( |
| | | append.records().stream().map(JSON::toJSONString).toList()); |
| | | typed.opsForList().rightPushAll(historyKey, records); |
| | | typed.opsForList().trim(historyKey, -MAX_HISTORY_RECORDS, -1); |
| | | typed.opsForSet().add(HISTORY_KEYS_KEY, historyKey); |
| | | } |
| | | } |
| | | typed.opsForValue().set(STATE_KEY, JSON.toJSONString(state)); |
| | | return typed.exec(); |
| | | } |
| | | }); |
| | | if (results == null) throw new IllegalStateException("BTS simulator state transaction failed in Redis"); |
| | | } |
| | | |
| | | @Override |
| | | public CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, int limit, |
| | | boolean reverse) |
| | | { |
| | | if (offset < 0) throw new IllegalArgumentException("offset must not be negative"); |
| | | if (limit < 1 || limit > MAX_HISTORY_BATCH_SIZE) |
| | | throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_BATCH_SIZE); |
| | | String key = historyKey(collectorCode); |
| | | Long storedSize = redisTemplate.opsForList().size(key); |
| | | long redisCount = storedSize == null ? 0 : storedSize; |
| | | List<CollectorMeasurementRecord> embedded = pendingEmbeddedHistory.stream() |
| | | .filter(append -> append.collectorCode().equals(collectorCode)) |
| | | .findFirst().map(CollectorHistoryAppend::records).orElse(List.of()); |
| | | long total = redisCount + embedded.size(); |
| | | long from = Math.min(offset, total); |
| | | int count = (int) Math.min(limit, total - from); |
| | | List<CollectorMeasurementRecord> records = new ArrayList<>(count); |
| | | if (reverse) |
| | | { |
| | | long lower = total - from - count; |
| | | long upper = total - from; |
| | | long embeddedLower = Math.max(lower, redisCount); |
| | | for (long index = upper - 1; index >= embeddedLower; index--) |
| | | records.add(embedded.get((int) (index - redisCount))); |
| | | long redisUpper = Math.min(upper, redisCount); |
| | | if (lower < redisUpper) |
| | | { |
| | | List<Object> stored = redisTemplate.opsForList().range(key, lower, redisUpper - 1); |
| | | if (stored != null) |
| | | for (int index = stored.size() - 1; index >= 0; index--) |
| | | records.add(collectorMeasurementRecord(stored.get(index))); |
| | | } |
| | | } |
| | | else |
| | | { |
| | | long upper = from + count; |
| | | long redisUpper = Math.min(upper, redisCount); |
| | | if (from < redisUpper) |
| | | { |
| | | List<Object> stored = redisTemplate.opsForList().range(key, from, redisUpper - 1); |
| | | if (stored != null) stored.forEach(item -> records.add(collectorMeasurementRecord(item))); |
| | | } |
| | | long embeddedFrom = Math.max(from, redisCount); |
| | | if (embeddedFrom < upper) |
| | | records.addAll(embedded.subList((int) (embeddedFrom - redisCount), |
| | | (int) (upper - redisCount))); |
| | | } |
| | | if (records.size() != count) |
| | | throw new IllegalStateException("Collector history page changed while reading Redis"); |
| | | long nextOffset = from + records.size(); |
| | | return new CollectorHistoryBatch(offset, nextOffset, nextOffset < total, total, records); |
| | | } |
| | | |
| | | @Override |
| | | public BtsHistoryBatch loadBtsHistoryPage(String historyKey, long offset, int limit) |
| | | { |
| | | if (offset < 0 || limit < 1 || limit > MAX_HISTORY_BATCH_SIZE) |
| | | throw new IllegalArgumentException("invalid BTS history page"); |
| | | String key = BTS_HISTORY_KEY_PREFIX + historyKey; |
| | | Long size = redisTemplate.opsForList().size(key); |
| | | long total = size == null ? 0 : size; |
| | | long from = Math.min(offset, total); |
| | | int count = (int) Math.min(limit, total - from); |
| | | List<Object> values = count == 0 ? List.of() : redisTemplate.opsForList().range(key, from, from + count - 1); |
| | | List<BtsHistoryRecord> records = values == null ? List.of() : values.stream() |
| | | .map(item -> JSON.parseObject(item.toString(), BtsHistoryRecord.class)).toList(); |
| | | long next = from + records.size(); |
| | | return new BtsHistoryBatch(offset, next, next < total, total, records); |
| | | } |
| | | |
| | | @Override |
| | | @SuppressWarnings({ "rawtypes", "unchecked" }) |
| | | public void appendBtsHistory(String historyKey, BtsHistoryRecord record) |
| | | { |
| | | String key = BTS_HISTORY_KEY_PREFIX + historyKey; |
| | | RedisOperations<Object, Object> operations = redisTemplate; |
| | | operations.opsForList().rightPush(key, JSON.toJSONString(record)); |
| | | operations.opsForList().trim(key, -MAX_HISTORY_RECORDS, -1); |
| | | operations.opsForSet().add(BTS_HISTORY_KEYS_KEY, key); |
| | | } |
| | | |
| | | @Override |
| | | public BtsPlaybackState loadBtsPlaybackState(String historyKey) |
| | | { |
| | | Object value = redisTemplate.opsForValue().get(BTS_PLAYBACK_KEY_PREFIX + historyKey); |
| | | return value == null ? null : JSON.parseObject(value.toString(), BtsPlaybackState.class); |
| | | } |
| | | |
| | | @Override |
| | | public void saveBtsPlaybackState(String historyKey, BtsPlaybackState playback) |
| | | { |
| | | redisTemplate.opsForValue().set(BTS_PLAYBACK_KEY_PREFIX + historyKey, JSON.toJSONString(playback)); |
| | | } |
| | | |
| | | @Override |
| | | public void deleteBtsPlaybackState(String historyKey) |
| | | { |
| | | redisTemplate.delete(BTS_PLAYBACK_KEY_PREFIX + historyKey); |
| | | } |
| | | |
| | | @Override |
| | | public void deleteBtsHistory(String historyKey) |
| | | { |
| | | String key = BTS_HISTORY_KEY_PREFIX + historyKey; |
| | | redisTemplate.delete(key); |
| | | redisTemplate.opsForSet().remove(BTS_HISTORY_KEYS_KEY, key); |
| | | } |
| | | |
| | | @Override |
| | | @SuppressWarnings({ "rawtypes", "unchecked" }) |
| | | public void saveBtsPlaybackAndAppendHistory(String historyKey, BtsPlaybackState playback, |
| | | List<BtsHistoryRecord> records) |
| | | { |
| | | List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() |
| | | { |
| | | @Override |
| | | public <K, V> List<Object> execute(RedisOperations<K, V> operations) |
| | | { |
| | | RedisOperations<Object, Object> typed = (RedisOperations) operations; |
| | | typed.multi(); |
| | | String key = BTS_HISTORY_KEY_PREFIX + historyKey; |
| | | if (!records.isEmpty()) |
| | | { |
| | | typed.opsForList().rightPushAll(key, new ArrayList<>(records.stream() |
| | | .map(JSON::toJSONString).toList())); |
| | | typed.opsForList().trim(key, -MAX_HISTORY_RECORDS, -1); |
| | | typed.opsForSet().add(BTS_HISTORY_KEYS_KEY, key); |
| | | } |
| | | typed.opsForValue().set(BTS_PLAYBACK_KEY_PREFIX + historyKey, JSON.toJSONString(playback)); |
| | | return typed.exec(); |
| | | } |
| | | }); |
| | | if (results == null) throw new IllegalStateException("BTS simulator playback transaction failed in Redis"); |
| | | } |
| | | |
| | | @Override |
| | | @SuppressWarnings({ "rawtypes", "unchecked" }) |
| | | public void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode) |
| | | { |
| | | String historyKey = historyKey(collectorCode); |
| | | List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() |
| | | { |
| | | @Override |
| | | public <K, V> List<Object> execute(RedisOperations<K, V> operations) |
| | | { |
| | | RedisOperations<Object, Object> typed = (RedisOperations) operations; |
| | | typed.multi(); |
| | | typed.delete(historyKey); |
| | | typed.opsForSet().remove(HISTORY_KEYS_KEY, historyKey); |
| | | typed.opsForValue().set(STATE_KEY, JSON.toJSONString(state)); |
| | | return typed.exec(); |
| | | } |
| | | }); |
| | | if (results == null) throw new IllegalStateException("BTS simulator collector deletion failed in Redis"); |
| | | redisTemplate.opsForValue().set(STATE_KEY, JSON.toJSONString(state)); |
| | | } |
| | | |
| | | @Override |
| | |
| | | @Override |
| | | public void reset(SimulatorState defaultState) |
| | | { |
| | | pendingEmbeddedHistory = List.of(); |
| | | Set<Object> historyKeys = redisTemplate.opsForSet().members(HISTORY_KEYS_KEY); |
| | | if (historyKeys != null && !historyKeys.isEmpty()) redisTemplate.delete(historyKeys); |
| | | redisTemplate.delete(HISTORY_KEYS_KEY); |
| | | Set<Object> btsHistoryKeys = redisTemplate.opsForSet().members(BTS_HISTORY_KEYS_KEY); |
| | | if (btsHistoryKeys != null && !btsHistoryKeys.isEmpty()) redisTemplate.delete(btsHistoryKeys); |
| | | redisTemplate.delete(BTS_HISTORY_KEYS_KEY); |
| | | // 播放状态按通道独立保存,重置演示时必须一并清理。 |
| | | Set<Object> playbackKeys = redisTemplate.keys(BTS_PLAYBACK_KEY_PREFIX + "*"); |
| | | if (playbackKeys != null && !playbackKeys.isEmpty()) redisTemplate.delete(playbackKeys); |
| | | redisTemplate.delete(RECENT_REQUESTS_KEY); |
| | | save(defaultState); |
| | | } |
| | | |
| | | private ParsedSimulatorState parseState(Object stored) |
| | | private SimulatorState restoreState(Object stored) |
| | | { |
| | | try |
| | | { |
| | |
| | | List<DeviceState> devices = new ArrayList<>(); |
| | | requiredArray(root, "devices").forEach(item -> devices.add(deviceState(item))); |
| | | List<CollectorState> collectors = new ArrayList<>(); |
| | | List<CollectorHistoryAppend> embeddedHistory = new ArrayList<>(); |
| | | requiredArray(root, "collectors").forEach(item -> { |
| | | JSONObject collectorObject = jsonObject(item); |
| | | List<CollectorMeasurementRecord> history = embeddedCollectorHistory(collectorObject); |
| | | CollectorState collector = collectorState(collectorObject, history); |
| | | collectors.add(collector); |
| | | if (!history.isEmpty()) |
| | | embeddedHistory.add(new CollectorHistoryAppend(collector.deviceCode(), history)); |
| | | }); |
| | | requiredArray(root, "collectors").forEach(item -> collectors.add(collectorState(jsonObject(item)))); |
| | | List<ChannelTombstone> tombstones = new ArrayList<>(); |
| | | requiredArray(root, "tombstones").forEach(item -> tombstones.add(channelTombstone(item))); |
| | | SimulatorState state = new SimulatorState(version, devices, collectors, |
| | | requiredInteger(root, "nextDeviceId"), requiredInteger(root, "nextCollectorId"), tombstones); |
| | | return new ParsedSimulatorState(state, embeddedHistory); |
| | | requiredInteger(root, "nextDeviceId"), requiredInteger(root, "nextCollectorId"), tombstones, |
| | | root.getBoolean("softwareAccessible"), root.getLongValue("observationSequence")); |
| | | return state; |
| | | } |
| | | catch (IllegalStateException exception) |
| | | { |
| | |
| | | requiredDecimal(object, "voltageMaxV"), requiredInteger(object, "sampleIntervalMs")); |
| | | } |
| | | |
| | | private List<CollectorMeasurementRecord> embeddedCollectorHistory(JSONObject object) |
| | | private CollectorState collectorState(JSONObject object) |
| | | { |
| | | JSONArray values = object.getJSONArray("history"); |
| | | if (values == null) return List.of(); |
| | | List<CollectorMeasurementRecord> history = new ArrayList<>(values.size()); |
| | | values.forEach(item -> history.add(collectorMeasurementRecord(item))); |
| | | return List.copyOf(history); |
| | | } |
| | | |
| | | private CollectorState collectorState(JSONObject object, List<CollectorMeasurementRecord> embeddedHistory) |
| | | { |
| | | int collectorId = requiredInteger(object, "collectorId"); |
| | | String deviceCode = object.getString("deviceCode"); |
| | | String ip = object.getString("ip"); |
| | | String mac = object.getString("mac"); |
| | | String name = object.getString("name"); |
| | | String manufacturer = object.getString("manufacturer"); |
| | | String model = object.getString("model"); |
| | | int channelCount = requiredInteger(object, "channelCount"); |
| | | boolean online = object.getBooleanValue("online"); |
| | | // 仅含发现配置的旧状态首次恢复时,以演示默认值补齐采样状态。 |
| | | if (!object.containsKey("sampleIntervalMs")) |
| | | return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model, |
| | | channelCount, online); |
| | | |
| | | int count = requiredInteger(object, "channelCount"); |
| | | List<CollectorChannelState> channels = new ArrayList<>(); |
| | | requiredArray(object, "channels").forEach(item -> channels.add(collectorChannelState(item))); |
| | | List<CollectorRealtimeMeasurement> latestMeasurements = new ArrayList<>(); |
| | | JSONArray latest = object.getJSONArray("latestMeasurements"); |
| | | if (latest != null) |
| | | latest.forEach(item -> latestMeasurements.add(collectorRealtimeMeasurement(item))); |
| | | else |
| | | latestMeasurements.addAll(latestMeasurementsFromLegacyHistory(embeddedHistory, channels)); |
| | | long historyRecordCount = object.containsKey("historyRecordCount") |
| | | ? requiredLong(object, "historyRecordCount") : embeddedHistory.size(); |
| | | return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model, |
| | | channelCount, online, requiredInteger(object, "sampleIntervalMs"), |
| | | requiredLong(object, "nextSequence"), optionalInstant(object, "lastSampledAt"), |
| | | channels, latestMeasurements, historyRecordCount, |
| | | collectorHistoryFaults(object.get("historyFaults"))); |
| | | JSONArray configured = object.getJSONArray("channels"); |
| | | if (configured == null) channels.addAll(com.battery.device.simulator.DevelopmentCollectorSimulation.defaultChannels(count)); |
| | | else configured.forEach(item -> channels.add(collectorChannelState(item))); |
| | | // 重启只恢复配置,不把上次响应或已废止的历史作为新观测。 |
| | | return new CollectorState(requiredInteger(object, "collectorId"), object.getString("deviceCode"), |
| | | object.getString("ip"), object.getString("mac"), object.getString("name"), |
| | | object.getString("manufacturer"), object.getString("model"), count, object.getBooleanValue("online"), |
| | | com.battery.device.simulator.DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, channels, List.of()); |
| | | } |
| | | |
| | | private CollectorChannelState collectorChannelState(Object value) |
| | |
| | | JSONObject object = jsonObject(value); |
| | | List<CollectorMetricState> metrics = new ArrayList<>(); |
| | | requiredArray(object, "metrics").forEach(item -> metrics.add(collectorMetricState(item))); |
| | | return new CollectorChannelState(object.getString("channelCode"), metrics); |
| | | return new CollectorChannelState(object.getString("channelCode"), metrics, |
| | | object.getString("workStatus") == null ? DeviceWorkStatus.WORKING |
| | | : DeviceWorkStatus.valueOf(object.getString("workStatus"))); |
| | | } |
| | | |
| | | private CollectorMetricState collectorMetricState(Object value) |
| | |
| | | BigDecimal measurement = object.getBigDecimal("value"); |
| | | if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value"); |
| | | return new CollectorMetricState(object.getString("metricCode"), measurement, object.getString("unit")); |
| | | } |
| | | |
| | | private CollectorMeasurementRecord collectorMeasurementRecord(Object value) |
| | | { |
| | | JSONObject object = jsonObject(value); |
| | | BigDecimal measurement = object.getBigDecimal("value"); |
| | | if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value"); |
| | | return new CollectorMeasurementRecord(object.getString("adapterCode"), object.getString("collectorCode"), |
| | | object.getString("channelCode"), requiredLong(object, "sequenceId"), |
| | | requiredInstant(object, "deviceTime"), object.getString("deviceTimeRaw"), |
| | | requiredInstant(object, "serverReceivedAt"), object.getString("metricCode"), |
| | | measurement, object.getString("unit")); |
| | | } |
| | | |
| | | private CollectorRealtimeMeasurement collectorRealtimeMeasurement(Object value) |
| | | { |
| | | JSONObject object = jsonObject(value); |
| | | BigDecimal measurement = object.getBigDecimal("value"); |
| | | if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value"); |
| | | return new CollectorRealtimeMeasurement(object.getString("adapterCode"), object.getString("collectorCode"), |
| | | object.getString("channelCode"), requiredLong(object, "sequenceId"), |
| | | requiredInstant(object, "deviceTime"), object.getString("deviceTimeRaw"), |
| | | requiredInstant(object, "serverReceivedAt"), object.getString("metricCode"), |
| | | measurement, object.getString("unit")); |
| | | } |
| | | |
| | | /** 从尚未拆分快照模型的旧状态中提取最新测量值。 */ |
| | | private List<CollectorRealtimeMeasurement> latestMeasurementsFromLegacyHistory( |
| | | List<CollectorMeasurementRecord> history, List<CollectorChannelState> channels) |
| | | { |
| | | java.util.Set<String> configured = new java.util.HashSet<>(); |
| | | channels.forEach(channel -> channel.metrics().forEach(metric -> |
| | | configured.add(channel.channelCode() + "\u0000" + metric.metricCode()))); |
| | | java.util.Map<String, CollectorRealtimeMeasurement> latest = new java.util.LinkedHashMap<>(); |
| | | for (CollectorMeasurementRecord record : history) |
| | | { |
| | | String key = record.channelCode() + "\u0000" + record.metricCode(); |
| | | if (configured.contains(key)) |
| | | latest.put(key, new CollectorRealtimeMeasurement(record.adapterCode(), record.collectorCode(), |
| | | record.channelCode(), record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), |
| | | record.serverReceivedAt(), record.metricCode(), record.value(), record.unit())); |
| | | } |
| | | return List.copyOf(latest.values()); |
| | | } |
| | | |
| | | private CollectorHistoryFaults collectorHistoryFaults(Object value) |
| | | { |
| | | if (value == null) return CollectorHistoryFaults.none(); |
| | | JSONObject object = jsonObject(value); |
| | | List<Long> missing = new ArrayList<>(); |
| | | JSONArray values = object.getJSONArray("missingSequences"); |
| | | if (values != null) values.forEach(item -> missing.add(Long.valueOf(item.toString()))); |
| | | return new CollectorHistoryFaults(missing, object.getLong("duplicateSequence"), |
| | | object.getLong("conflictingSequence"), object.getBooleanValue("outOfOrder")); |
| | | } |
| | | |
| | | private ChannelState channelState(Object value) |
| | |
| | | requiredInteger(object, "channelId"), requiredInteger(object, "remainingDiscoveries")); |
| | | } |
| | | |
| | | private long requiredLong(JSONObject object, String field) |
| | | { |
| | | Long value = object.getLong(field); |
| | | if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field); |
| | | return value; |
| | | } |
| | | |
| | | private BigDecimal requiredDecimal(JSONObject object, String field) |
| | | { |
| | | BigDecimal value = object.getBigDecimal(field); |
| | |
| | | return value; |
| | | } |
| | | |
| | | private Instant requiredInstant(JSONObject object, String field) |
| | | { |
| | | Instant value = optionalInstant(object, field); |
| | | if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field); |
| | | return value; |
| | | } |
| | | |
| | | private Instant optionalInstant(JSONObject object, String field) |
| | | { |
| | | String value = object.getString(field); |
| | | return value == null ? null : Instant.parse(value); |
| | | } |
| | | |
| | | private String historyKey(String collectorCode) |
| | | { |
| | | return HISTORY_KEY_PREFIX + collectorCode; |
| | | } |
| | | |
| | | private record ParsedSimulatorState(SimulatorState state, List<CollectorHistoryAppend> embeddedHistory) |
| | | { |
| | | private ParsedSimulatorState { embeddedHistory = List.copyOf(embeddedHistory); } |
| | | } |
| | | } |
| | |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.PutMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestParam; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | import org.springframework.web.server.ResponseStatusException; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | |
| | | import com.battery.device.simulator.BtsDevelopmentSimulator.StateSnapshot; |
| | | import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState; |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage; |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot; |
| | | import com.battery.device.simulator.BtsDevelopmentSimulator.PlaybackSnapshot; |
| | | |
| | | /** 本机临时 BTS 模拟器控制 API。 */ |
| | | @Anonymous |
| | |
| | | return simulator.snapshot(); |
| | | } |
| | | |
| | | /** 人工清空长期状态并恢复一台默认检测柜和采集器。 */ |
| | | /** 人工清空模拟配置并恢复默认检测柜和采集器。 */ |
| | | @PostMapping("/dev/bts-simulator/reset") |
| | | public StateSnapshot reset() |
| | | { |
| | |
| | | } |
| | | } |
| | | |
| | | /** 配置采集器采样频率和可重复的历史下载故障场景。 */ |
| | | @PutMapping("/dev/bts-simulator/collectors/{collectorId}/sampling") |
| | | public StateSnapshot configureCollectorSampling(@PathVariable int collectorId, |
| | | @RequestBody CollectorSamplingCommand command) |
| | | { |
| | | try |
| | | { |
| | | simulator.configureCollectorSampling(collectorId, command.sampleIntervalMs(), |
| | | new CollectorHistoryFaults(command.missingSequences(), command.duplicateSequence(), |
| | | command.conflictingSequence(), command.outOfOrder())); |
| | | return simulator.snapshot(); |
| | | } |
| | | catch (IllegalArgumentException exception) |
| | | { |
| | | throw badRequest(exception); |
| | | } |
| | | } |
| | | |
| | | /** 配置稳定 S 通道的固定和动态测量项。 */ |
| | | @PutMapping("/dev/bts-simulator/collectors/{collectorId}/channels/{channelCode}") |
| | | public StateSnapshot configureCollectorChannel(@PathVariable int collectorId, |
| | |
| | | } |
| | | } |
| | | |
| | | /** 查询采集器最新实时快照;离线时保留最后观测并明确返回离线事实。 */ |
| | | /** 请求采集器当前实时快照;不可访问或离线时返回空测量。 */ |
| | | @GetMapping("/dev/bts-simulator/collectors/{collectorId}/realtime") |
| | | public CollectorRealtimeSnapshot collectorRealtime(@PathVariable int collectorId) |
| | | { |
| | | try |
| | | { |
| | | return simulator.collectorRealtime(collectorId); |
| | | } |
| | | catch (IllegalArgumentException exception) |
| | | { |
| | | throw badRequest(exception); |
| | | } |
| | | } |
| | | |
| | | /** 按偏移量分页下载采集器模拟历史,每页最多 1000 条。 */ |
| | | @GetMapping("/dev/bts-simulator/collectors/{collectorId}/history") |
| | | public CollectorHistoryPage collectorHistory(@PathVariable int collectorId, |
| | | @RequestParam(defaultValue = "0") long offset, @RequestParam(defaultValue = "100") int limit) |
| | | { |
| | | try |
| | | { |
| | | return simulator.collectorHistory(collectorId, offset, limit); |
| | | } |
| | | catch (IllegalArgumentException exception) |
| | | { |
| | |
| | | } |
| | | } |
| | | |
| | | /** 返回检测柜通道播放、record、step 和 cycle 的当前快照。 */ |
| | | @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback") |
| | | public PlaybackSnapshot btsPlayback(@PathVariable int deviceId, @PathVariable int channelId) |
| | | /** 修改软件可访问性,设备配置继续保留。 */ |
| | | @PutMapping("/dev/bts-simulator/software") |
| | | public StateSnapshot software(@RequestBody SoftwareCommand command) |
| | | { |
| | | try { return simulator.btsPlayback(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | if (command == null || command.accessible() == null) |
| | | throw badRequest(new IllegalArgumentException("accessible must be true or false")); |
| | | simulator.setSoftwareAccessible(command.accessible()); |
| | | synchronizeOnlineStatusIfConfigured(); |
| | | return simulator.snapshot(); |
| | | } |
| | | |
| | | @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/records") |
| | | public List<com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord> btsRecords( |
| | | @PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { return simulator.btsRecords(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/steps") |
| | | public List<BtsDevelopmentSimulator.BtsStepRecord> btsSteps( |
| | | @PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { return simulator.btsSteps(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/cycles") |
| | | public List<BtsDevelopmentSimulator.BtsCycleRecord> btsCycles( |
| | | @PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { return simulator.btsCycles(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | /** 设置检测柜通道演示倍速。 */ |
| | | @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback") |
| | | public PlaybackSnapshot configureBtsPlayback(@PathVariable int deviceId, @PathVariable int channelId, |
| | | @RequestBody PlaybackCommand command) |
| | | /** 修改采集器通道当前工作状态。 */ |
| | | @PutMapping("/dev/bts-simulator/collectors/{collectorId}/channels/{channelCode}/status") |
| | | public StateSnapshot collectorChannelStatus(@PathVariable int collectorId, @PathVariable String channelCode, |
| | | @RequestBody CollectorChannelStatusCommand command) |
| | | { |
| | | try |
| | | { |
| | | if (command.speedMultiplier() != null) simulator.setBtsSpeed(deviceId, channelId, command.speedMultiplier()); |
| | | if (command.playing() != null) |
| | | { |
| | | if (command.playing()) simulator.playBts(deviceId, channelId); |
| | | else simulator.pauseBts(deviceId, channelId); |
| | | } |
| | | return simulator.btsPlayback(deviceId, channelId); |
| | | simulator.updateCollectorChannelStatus(collectorId, channelCode, command.workStatus()); |
| | | return simulator.snapshot(); |
| | | } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/play") |
| | | public PlaybackSnapshot playBts(@PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { simulator.playBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/pause") |
| | | public PlaybackSnapshot pauseBts(@PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { simulator.pauseBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/resume") |
| | | public PlaybackSnapshot resumeBts(@PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { simulator.resumeBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/next-step") |
| | | public PlaybackSnapshot nextBtsStep(@PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { simulator.nextBtsStep(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/next-cycle") |
| | | public PlaybackSnapshot nextBtsCycle(@PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { simulator.nextBtsCycle(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/reset") |
| | | public PlaybackSnapshot resetBts(@PathVariable int deviceId, @PathVariable int channelId) |
| | | { |
| | | try { simulator.resetBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | |
| | | |
| | | public record CollectorStateCommand(String ip, int channelCount, boolean online) { } |
| | | |
| | | public record CollectorSamplingCommand(int sampleIntervalMs, List<Long> missingSequences, |
| | | Long duplicateSequence, Long conflictingSequence, boolean outOfOrder) |
| | | { |
| | | public CollectorSamplingCommand |
| | | { |
| | | missingSequences = missingSequences == null ? List.of() : List.copyOf(missingSequences); |
| | | } |
| | | } |
| | | |
| | | public record CollectorChannelCommand(List<CollectorMetricCommand> metrics) { } |
| | | |
| | | public record CollectorMetricCommand(String metricCode, BigDecimal value, String unit) { } |
| | | |
| | | public record PlaybackCommand(Integer speedMultiplier, Boolean playing) { } |
| | | public record SoftwareCommand(Boolean accessible) { } |
| | | |
| | | public record CollectorChannelStatusCommand(DeviceWorkStatus workStatus) { } |
| | | } |
| | |
| | | .status::before { width: 8px; height: 8px; content: ""; border-radius: 50%; background: #c43d4b; } |
| | | .status.online::before { background: #218a5b; } |
| | | .add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; } |
| | | .device-settings { grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; } |
| | | .collector-settings { display: grid; grid-template-columns: minmax(160px, 1fr) 110px 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; } |
| | | .sampling-settings { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; gap: 12px; align-items: end; padding: 0 16px 16px; } |
| | | .device-settings { grid-template-columns: minmax(130px, 1fr) 90px minmax(150px, 1fr) 100px auto auto; } |
| | | .collector-settings { display: grid; grid-template-columns: minmax(130px, 1fr) 90px 110px auto auto; gap: 12px; align-items: end; padding: 16px; } |
| | | |
| | | label { color: #5e6a73; font-size: 12px; } |
| | | label > span { display: block; margin-bottom: 6px; } |
| | | input[type="text"], input[type="number"], select, textarea { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; } |
| | |
| | | td select { width: 150px; } |
| | | .request-time { width: 185px; font: 12px Consolas, monospace; color: #53616b; } |
| | | .request-command { width: 130px; font: 12px Consolas, monospace; font-weight: 600; } |
| | | .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } |
| | | .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: pre-wrap; overflow-wrap: anywhere; } |
| | | .collector-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #e3e9ed; } |
| | | .collector-output { min-height: 46px; margin: 0 16px 16px; padding: 10px; overflow: auto; color: #34434d; background: #f7f9fa; border: 1px solid #d5dde3; font: 11px Consolas, monospace; white-space: pre-wrap; } |
| | | .empty { height: 96px; color: #7d8992; text-align: center; } |
| | |
| | | main { width: calc(100% - 20px); margin-top: 12px; } |
| | | .summary { grid-template-columns: 1fr; } |
| | | .summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; } |
| | | .add-form, .device-settings, .collector-settings, .sampling-settings { grid-template-columns: 1fr 1fr; } |
| | | .add-form, .device-settings, .collector-settings { grid-template-columns: 1fr 1fr; } |
| | | .device-settings button, .collector-settings button { width: 100%; } |
| | | .device { margin: 10px; overflow-x: auto; } |
| | | .device table { min-width: 520px; } |
| | | .device table { min-width: 620px; } |
| | | .section-title, .device-title { flex-wrap: wrap; padding: 10px; } |
| | | .device-title .label { overflow-wrap: anywhere; } |
| | | th, td { padding: 8px; } |
| | | .request-time { width: 130px; } |
| | | .request-command { width: 90px; } |
| | |
| | | <div id="error" class="error"></div> |
| | | <section> |
| | | <div class="section-title"> |
| | | <h2>运行状态</h2> |
| | | <h2>软件连接</h2> |
| | | <div class="power-actions"> |
| | | <button id="power-on" type="button">一键模拟开机</button> |
| | | <button id="power-off" class="danger" type="button">一键模拟关机</button> |
| | | <label class="switch-label"><input id="software-accessible" type="checkbox"><span>新威软件可访问</span></label> |
| | | <button id="power-on" type="button">全部设备上线</button> |
| | | <button id="power-off" class="danger" type="button">全部设备离线</button> |
| | | <button id="reset-simulator" class="danger" type="button">重置模拟器</button> |
| | | </div> |
| | | </div> |
| | |
| | | <section> |
| | | <div class="section-title"><h2>最近请求</h2><span id="request-count" class="label">0 条</span></div> |
| | | <table> |
| | | <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 XML</th></tr></thead> |
| | | <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 / 响应</th></tr></thead> |
| | | <tbody id="requests"></tbody> |
| | | </table> |
| | | </section> |
| | | </main> |
| | | <script> |
| | | const api = '/dev/bts-simulator'; |
| | | const labels = { STOPPED: '空闲', WORKING: '工作中', PAUSED: '暂停', FINISHED: '完成' }; |
| | | const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' }; |
| | | const errorBox = document.querySelector('#error'); |
| | | |
| | | async function request(path, options) { |
| | |
| | | collectors: Array.isArray(state.collectors) ? state.collectors : [], |
| | | recentRequests: Array.isArray(state.recentRequests) ? state.recentRequests : [], |
| | | }; |
| | | document.querySelector('#software-accessible').checked = state.softwareAccessible; |
| | | const runtime = document.querySelector('#runtime'); |
| | | runtime.textContent = state.running ? '运行中' : '已停止'; |
| | | runtime.classList.toggle('online', state.running); |
| | | const devicesPoweredOn = state.devices.length + state.collectors.length > 0 |
| | | && state.devices.every(device => device.online |
| | | && device.channels.every(channel => channel.online)) |
| | | && state.devices.every(device => device.online) |
| | | && state.collectors.every(collector => collector.online); |
| | | const devicesPoweredOff = state.devices.every(device => !device.online |
| | | && device.channels.every(channel => !channel.online && channel.workStatus === 'STOPPED')) |
| | | const devicesPoweredOff = state.devices.every(device => !device.online) |
| | | && state.collectors.every(collector => !collector.online); |
| | | const devicePower = document.querySelector('#device-power'); |
| | | devicePower.textContent = devicesPoweredOn ? '已开机' : devicesPoweredOff ? '已关机' : '部分开启'; |
| | | devicePower.textContent = devicesPoweredOn ? '全部在线' : devicesPoweredOff ? '全部离线' : '部分在线'; |
| | | devicePower.classList.toggle('online', devicesPoweredOn); |
| | | document.querySelector('#power-on').disabled = devicesPoweredOn; |
| | | document.querySelector('#power-off').disabled = devicesPoweredOff; |
| | |
| | | heading.textContent = `检测柜 ${device.deviceId}`; |
| | | const identity = document.createElement('span'); |
| | | identity.className = 'label'; |
| | | identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / ${device.profile.model}`; |
| | | identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / subdevid ${device.profile.subDeviceId} / ${device.profile.model}`; |
| | | title.append(heading, identity); |
| | | |
| | | const settings = document.createElement('div'); |
| | |
| | | settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove); |
| | | |
| | | const table = document.createElement('table'); |
| | | table.innerHTML = '<thead><tr><th style="width:18%">通道</th><th style="width:22%">在线状态</th><th style="width:20%">工作状态</th><th>模拟播放</th></tr></thead>'; |
| | | table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th></tr></thead>'; |
| | | const body = document.createElement('tbody'); |
| | | body.append(...device.channels.map(channel => channelRow(device.deviceId, channel))); |
| | | table.append(body); |
| | |
| | | const channelCount = field('通道数', 'number', collector.channelCount); |
| | | channelCount.input.min = '1'; |
| | | channelCount.input.max = '99'; |
| | | const sampleInterval = field('采样间隔(毫秒)', 'number', collector.sampleIntervalMs); |
| | | sampleInterval.input.min = '100'; |
| | | sampleInterval.input.max = '60000'; |
| | | const onlineLabel = document.createElement('label'); |
| | | onlineLabel.className = 'switch-label'; |
| | | const online = document.createElement('input'); |
| | |
| | | save.textContent = '保存配置'; |
| | | save.addEventListener('click', async () => { |
| | | try { |
| | | await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) }); |
| | | render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), ...collector.historyFaults }) })); |
| | | render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) })); |
| | | } |
| | | catch (error) { showError(error); } |
| | | }); |
| | |
| | | try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | settings.append(ip.label, channelCount.label, sampleInterval.label, onlineLabel, save, remove); |
| | | |
| | | const faults = document.createElement('div'); |
| | | faults.className = 'sampling-settings'; |
| | | const missing = field('缺失序号(逗号分隔)', 'text', collector.historyFaults.missingSequences.join(',')); |
| | | const duplicate = field('重复序号', 'number', collector.historyFaults.duplicateSequence || ''); |
| | | const conflict = field('冲突序号', 'number', collector.historyFaults.conflictingSequence || ''); |
| | | const outOfOrderLabel = document.createElement('label'); |
| | | outOfOrderLabel.className = 'switch-label'; |
| | | const outOfOrder = document.createElement('input'); |
| | | outOfOrder.type = 'checkbox'; |
| | | outOfOrder.checked = collector.historyFaults.outOfOrder; |
| | | const outOfOrderText = document.createElement('span'); |
| | | outOfOrderText.textContent = '历史乱序'; |
| | | outOfOrderLabel.append(outOfOrder, outOfOrderText); |
| | | const saveFaults = document.createElement('button'); |
| | | saveFaults.type = 'button'; |
| | | saveFaults.textContent = '保存历史场景'; |
| | | saveFaults.addEventListener('click', async () => { |
| | | const missingSequences = missing.input.value.split(',').map(value => value.trim()).filter(Boolean).map(Number); |
| | | try { render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), missingSequences, duplicateSequence: numberOrNull(duplicate.input.value), conflictingSequence: numberOrNull(conflict.input.value), outOfOrder: outOfOrder.checked }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | faults.append(missing.label, duplicate.label, conflict.label, outOfOrderLabel, saveFaults); |
| | | settings.append(ip.label, channelCount.label, onlineLabel, save, remove); |
| | | |
| | | const table = document.createElement('table'); |
| | | table.innerHTML = '<thead><tr><th style="width:110px">稳定通道</th><th>测量项(每行:指标编码=值 单位)</th><th style="width:130px">操作</th></tr></thead>'; |
| | | table.innerHTML = '<thead><tr><th style="width:100px">稳定通道</th><th style="width:170px">工作状态</th><th>测量项</th><th style="width:130px">操作</th></tr></thead>'; |
| | | const channelBody = document.createElement('tbody'); |
| | | channelBody.append(...collector.channels.map(channel => collectorChannelRow(collector.collectorId, channel))); |
| | | table.append(channelBody); |
| | |
| | | const realtimeButton = document.createElement('button'); |
| | | realtimeButton.type = 'button'; |
| | | realtimeButton.textContent = '查看实时'; |
| | | const historyButton = document.createElement('button'); |
| | | historyButton.type = 'button'; |
| | | historyButton.textContent = '下载历史'; |
| | | const historyCount = document.createElement('span'); |
| | | historyCount.className = 'label'; |
| | | historyCount.textContent = `原始历史 ${collector.historyRecordCount} 条`; |
| | | const output = document.createElement('pre'); |
| | | output.className = 'collector-output'; |
| | | output.textContent = collector.latestMeasurements.length |
| | | ? measurementLines(collector.latestMeasurements) |
| | | : '等待第一条采样'; |
| | | : '暂无成功观测'; |
| | | realtimeButton.addEventListener('click', async () => { |
| | | try { output.textContent = measurementLines((await request(`/collectors/${collector.collectorId}/realtime`)).measurements); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | historyButton.addEventListener('click', async () => { |
| | | try { |
| | | const page = await request(`/collectors/${collector.collectorId}/history?offset=0&limit=20`); |
| | | output.textContent = `历史 ${page.total} 条 / 本页 ${page.records.length} 条\n${measurementLines(page.records)}`; |
| | | } catch (error) { showError(error); } |
| | | }); |
| | | actions.append(realtimeButton, historyButton, historyCount); |
| | | panel.append(title, settings, faults, table, actions, output); |
| | | actions.append(realtimeButton); |
| | | panel.append(title, settings, table, actions, output); |
| | | return panel; |
| | | })); |
| | | } |
| | |
| | | catch (error) { showError(error); } |
| | | }); |
| | | action.append(save); |
| | | row.append(identity, metricsCell, action); |
| | | const statusCell = document.createElement('td'); |
| | | const status = document.createElement('select'); |
| | | status.setAttribute('aria-label', channel.channelCode + ' 工作状态'); |
| | | Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value))); |
| | | status.value = channel.workStatus; |
| | | status.addEventListener('change', async () => { |
| | | try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}/status`, { method: 'PUT', body: JSON.stringify({ workStatus: status.value }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | statusCell.append(status); |
| | | row.append(identity, statusCell, metricsCell, action); |
| | | return row; |
| | | } |
| | | |
| | |
| | | function measurementLines(items) { |
| | | if (!items.length) return '暂无测量'; |
| | | return items.map(item => `${item.collectorCode} #${item.sequenceId} ${item.channelCode} ${item.metricCode}=${item.value} ${item.unit} 设备 ${item.deviceTimeRaw} / 接收 ${item.serverReceivedAt}`).join('\n'); |
| | | } |
| | | |
| | | function numberOrNull(value) { |
| | | return value.trim() ? Number(value) : null; |
| | | } |
| | | |
| | | function field(labelText, type, value) { |
| | |
| | | const status = document.createElement('select'); |
| | | Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value))); |
| | | status.value = channel.workStatus; |
| | | status.setAttribute('aria-label', `检测柜 ${deviceId} 通道 ${channel.channelId} 工作状态`); |
| | | const update = async () => { |
| | | try { render(await request(`/devices/${deviceId}/channels/${channel.channelId}`, { method: 'PUT', body: JSON.stringify({ online: online.checked, workStatus: status.value }) })); } |
| | | catch (error) { showError(error); await refresh(); } |
| | |
| | | online.addEventListener('change', update); |
| | | status.addEventListener('change', update); |
| | | statusCell.append(status); |
| | | const playbackCell = document.createElement('td'); |
| | | const speed = document.createElement('select'); |
| | | [['1','1x'], ['60','60x'], ['600','600x']].forEach(([value, label]) => speed.add(new Option(label, value))); |
| | | const controls = document.createElement('span'); |
| | | controls.style.cssText = 'display:inline-flex;gap:5px;margin-left:6px'; |
| | | const action = (label, path) => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback/${path}`, { method: 'POST' }); await refresh(); } catch (error) { showError(error); } }); return button; }; |
| | | speed.addEventListener('change', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback`, { method: 'PUT', body: JSON.stringify({ speedMultiplier: Number(speed.value) }) }); await refresh(); } catch (error) { showError(error); } }); |
| | | controls.append(action('播放', 'play'), action('暂停', 'pause'), action('下一工步', 'next-step'), action('下一循环', 'next-cycle'), action('重置', 'reset')); |
| | | playbackCell.append(speed, controls); |
| | | row.append(identity, onlineCell, statusCell, playbackCell); |
| | | row.append(identity, onlineCell, statusCell); |
| | | return row; |
| | | } |
| | | |
| | |
| | | command.textContent = item.command; |
| | | const xml = document.createElement('td'); |
| | | xml.className = 'request-xml'; |
| | | xml.textContent = item.request; |
| | | xml.title = item.request; |
| | | xml.textContent = `${item.request}\n${item.response || '暂无响应'}`; |
| | | xml.title = xml.textContent; |
| | | row.append(time, command, xml); |
| | | return row; |
| | | })); |
| | |
| | | try { render(await request('/reset', { method: 'POST' })); } catch (error) { showError(error); } |
| | | }); |
| | | |
| | | document.getElementById('software-accessible').addEventListener('change', async event => { |
| | | try { render(await request('/software', { method: 'PUT', body: JSON.stringify({ accessible: event.target.checked }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | |
| | | document.getElementById('power-on').addEventListener('click', async () => { |
| | | try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: true }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | | |
| | | document.getElementById('power-off').addEventListener('click', async () => { |
| | | if (!confirm('确认关闭全部模拟检测柜和采集器?已有历史会保留,停止增长。')) return; |
| | | if (!confirm('确认将全部模拟检测柜和采集器设为离线?')) return; |
| | | try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: false }) })); } |
| | | catch (error) { showError(error); } |
| | | }); |
| | |
| | | List.of(new CollectorState(3, "dev-sim-collector-3", "127.0.1.3", |
| | | "02:43:00:00:00:03", "模拟采集器", "开发模拟器", |
| | | "Virtual Collector", 3, true)), |
| | | 3, 4, List.of(new ChannelTombstone(1, "127.0.0.2", 4, 2))); |
| | | 3, 4, List.of(new ChannelTombstone(1, "127.0.0.2", 4, 2)), false, 900L); |
| | | FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class); |
| | | AtomicReference<Object> stored = new AtomicReference<>( |
| | | serializer.deserialize(serializer.serialize(JSON.toJSONString(expected)))); |
| | |
| | | |
| | | CollectorState collector = restored.collectors().get(0); |
| | | assertEquals(1000, collector.sampleIntervalMs()); |
| | | assertEquals(1L, collector.nextSequence()); |
| | | assertEquals(List.of("S-01", "S-02"), |
| | | collector.channels().stream().map(channel -> channel.channelCode()).toList()); |
| | | assertEquals(0L, collector.historyRecordCount()); |
| | | } |
| | | |
| | | @Test |
| | | void migratesEmbeddedCollectorHistoryToAppendOnlyRedisList() |
| | | { |
| | | AtomicReference<Object> stored = new AtomicReference<>("{\"version\":1,\"devices\":[]," |
| | | + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\"," |
| | | + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\"," |
| | | + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\"," |
| | | + "\"model\":\"Virtual Collector\",\"channelCount\":1,\"online\":true," |
| | | + "\"sampleIntervalMs\":1000,\"nextSequence\":2," |
| | | + "\"lastSampledAt\":\"2026-08-29T05:00:00Z\"," |
| | | + "\"channels\":[{\"channelCode\":\"S-01\",\"metrics\":[{" |
| | | + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]}]," |
| | | + "\"history\":[{\"adapterCode\":\"dev-sim\"," |
| | | + "\"collectorCode\":\"dev-sim-collector-1\",\"channelCode\":\"S-01\"," |
| | | + "\"sequenceId\":1,\"deviceTime\":\"2026-08-29T05:00:00Z\"," |
| | | + "\"deviceTimeRaw\":\"2026-08-29 13:00:00.000\"," |
| | | + "\"serverReceivedAt\":\"2026-08-29T05:00:00Z\"," |
| | | + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]," |
| | | + "\"historyFaults\":{\"missingSequences\":[],\"outOfOrder\":false}}]," |
| | | + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}"); |
| | | RedisBtsSimulatorStateStore store = store(stored); |
| | | |
| | | SimulatorState restored = store.loadOrInitialize(state(1, "127.0.0.2")); |
| | | |
| | | assertEquals(1L, restored.collectors().get(0).historyRecordCount()); |
| | | assertEquals(1, store.loadCollectorHistoryPage("dev-sim-collector-1", 0, 1000, false) |
| | | .records().size()); |
| | | assertEquals(true, stored.get().toString().contains("\"history\":[{")); |
| | | store.completeValidatedRestore(restored); |
| | | assertEquals(false, stored.get().toString().contains("\"history\":[{")); |
| | | } |
| | | |
| | | @Test |
| | | void clearsLegacyEmbeddedHistoryForDemoStartup() throws Exception |
| | | { |
| | | AtomicReference<Object> stored = new AtomicReference<>("{\"version\":1,\"devices\":[]," |
| | | + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\"," |
| | | + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\"," |
| | | + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\"," |
| | | + "\"model\":\"Virtual Collector\",\"channelCount\":1,\"online\":true," |
| | | + "\"sampleIntervalMs\":1000,\"nextSequence\":2," |
| | | + "\"lastSampledAt\":\"2026-08-29T05:00:00Z\"," |
| | | + "\"channels\":[{\"channelCode\":\"S-01\",\"metrics\":[{" |
| | | + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]}]," |
| | | + "\"history\":[{\"adapterCode\":\"dev-sim\"," |
| | | + "\"collectorCode\":\"wrong-collector\",\"channelCode\":\"S-01\"," |
| | | + "\"sequenceId\":1,\"deviceTime\":\"2026-08-29T05:00:00Z\"," |
| | | + "\"deviceTimeRaw\":\"2026-08-29 13:00:00.000\"," |
| | | + "\"serverReceivedAt\":\"2026-08-29T05:00:00Z\"," |
| | | + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]," |
| | | + "\"historyFaults\":{\"missingSequences\":[],\"outOfOrder\":false}}]," |
| | | + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}"); |
| | | |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store(stored))) |
| | | { |
| | | assertEquals(0L, simulator.snapshot().collectors().get(0).historyRecordCount()); |
| | | } |
| | | assertEquals(false, stored.get().toString().contains("\"history\":[{")); |
| | | } |
| | | |
| | | @Test |
| | |
| | | |
| | | assertEquals("Unsupported BTS simulator state version: 2", exception.getMessage()); |
| | | assertEquals(true, stored.get().toString().contains("\"version\":2")); |
| | | } |
| | | |
| | | @Test |
| | | void atomicallySavesCollectorDeletionAndRemovesIndependentHistory() |
| | | { |
| | | AtomicReference<Object> stored = new AtomicReference<>(); |
| | | RedisBtsSimulatorStateStore store = store(stored); |
| | | SimulatorState current = state(1, "127.0.0.2"); |
| | | store.loadOrInitialize(current); |
| | | var record = new com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord( |
| | | "dev-sim", "dev-sim-collector-1", "S-01", 1L, |
| | | java.time.Instant.parse("2026-08-29T05:00:00Z"), "2026-08-29 13:00:00.000", |
| | | java.time.Instant.parse("2026-08-29T05:00:00Z"), "temperature", |
| | | new java.math.BigDecimal("25.1"), "℃"); |
| | | store.save(current, List.of( |
| | | new com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend( |
| | | "dev-sim-collector-1", List.of(record)))); |
| | | SimulatorState next = state(7, "127.0.0.9"); |
| | | |
| | | store.saveAndDeleteCollectorHistory(next, "dev-sim-collector-1"); |
| | | |
| | | assertEquals(next, store.loadOrInitialize(current)); |
| | | assertEquals(List.of(), store.loadCollectorHistoryPage("dev-sim-collector-1", 0, 1000, false) |
| | | .records()); |
| | | } |
| | | |
| | | @Test |
| | | void readsOnlyRequestedCollectorHistoryRange() |
| | | { |
| | | AtomicReference<Object> stored = new AtomicReference<>(); |
| | | RedisTemplate<Object, Object> redis = redis(stored); |
| | | RedisBtsSimulatorStateStore store = new RedisBtsSimulatorStateStore(redis); |
| | | SimulatorState current = state(1, "127.0.0.2"); |
| | | store.loadOrInitialize(current); |
| | | var record = new com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord( |
| | | "dev-sim", "dev-sim-collector-1", "S-01", 1L, |
| | | java.time.Instant.parse("2026-08-29T05:00:00Z"), "2026-08-29 13:00:00.000", |
| | | java.time.Instant.parse("2026-08-29T05:00:00Z"), "temperature", |
| | | new java.math.BigDecimal("25.1"), "℃"); |
| | | store.save(current, List.of( |
| | | new com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend( |
| | | "dev-sim-collector-1", java.util.Collections.nCopies(1010, record)))); |
| | | ListOperations<Object, Object> lists = redis.opsForList(); |
| | | clearInvocations(lists); |
| | | |
| | | var page = store.loadCollectorHistoryPage("dev-sim-collector-1", 1000, 10, false); |
| | | |
| | | assertEquals(1010, page.total()); |
| | | assertEquals(10, page.records().size()); |
| | | assertEquals(false, page.hasMore()); |
| | | verify(lists).range(RedisBtsSimulatorStateStore.HISTORY_KEY_PREFIX + "dev-sim-collector-1", |
| | | 1000L, 1009L); |
| | | } |
| | | |
| | | @Test |
| | |
| | | class DeviceDiscoveryPersistenceServiceTest |
| | | { |
| | | @Test |
| | | void failedDiscoveryInvalidatesSuccessfulSnapshotUntilSoftwareReturns() throws Exception |
| | | { |
| | | try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0); |
| | | var registry = new DeviceAdapterRegistry(List.of(new com.battery.device.bts.BtsDeviceAdapterFactory()))) |
| | | { |
| | | registry.synchronize(new com.battery.device.api.registry.AdapterInstanceConfig("dev-sim", "BTS", |
| | | "127.0.0.1", simulator.port(), 5000, "Asia/Hong_Kong", true, |
| | | com.battery.device.api.model.AdapterCapabilities.observeOnly())); |
| | | var store = new InMemoryDeviceDiscoveryCandidateStore(); |
| | | var mapper = Mockito.mock(TDetectionCabinetMapper.class); |
| | | var persistence = new DeviceDiscoveryPersistenceService(store, mapper); |
| | | var scheduler = new com.battery.business.runtime.DevelopmentSimulatorDiscoveryScheduler(registry, persistence); |
| | | simulator.updateChannel(1, 1, true, com.battery.device.api.model.DeviceWorkStatus.WORKING); |
| | | scheduler.discover(); |
| | | var before = store.snapshot(java.time.Instant.now()).cabinets().get(0); |
| | | org.junit.jupiter.api.Assertions.assertNotNull(before.channels().get(0).latestSnapshot()); |
| | | simulator.setSoftwareAccessible(false); |
| | | scheduler.discover(); |
| | | var failed = store.snapshot(java.time.Instant.now()).cabinets().get(0); |
| | | assertEquals(before.deviceCode(), failed.deviceCode()); |
| | | org.junit.jupiter.api.Assertions.assertFalse(failed.online()); |
| | | org.junit.jupiter.api.Assertions.assertNull(failed.channels().get(0).latestSnapshot()); |
| | | verify(mapper).markRegisteredCabinetsOffline("dev-sim"); |
| | | simulator.setSoftwareAccessible(true); |
| | | scheduler.discover(); |
| | | org.junit.jupiter.api.Assertions.assertTrue(store.snapshot(java.time.Instant.now()).cabinets().get(0).online()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void discoveryPersistsCandidatesWithoutReceivingTest() |
| | | { |
| | | InMemoryDeviceDiscoveryCandidateStore candidateStore = new InMemoryDeviceDiscoveryCandidateStore(); |
| | |
| | | class TTestLiveServiceImplTest |
| | | { |
| | | @Test |
| | | void nonReceivingConsoleReadsCollectorStatusWithoutRequestingMeasurements() throws Exception |
| | | { |
| | | try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0)) |
| | | { |
| | | TTest test = test("RECEIVING"); |
| | | test.getBatteries().get(0).setCollectorId(7L); |
| | | var tests = mock(ITTestService.class); |
| | | when(tests.selectTestById(10L)).thenReturn(test); |
| | | var mapper = mock(TTestMapper.class); |
| | | when(mapper.selectCollectorCode(7L)).thenReturn("dev-sim-collector-1"); |
| | | var candidates = new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(); |
| | | Instant now = Instant.now(); |
| | | candidates.replaceCollectors("dev-sim", List.of(new DeviceDiscoveryCandidateStore.CollectorCandidate( |
| | | "dev-sim", "dev-sim-collector-1", "127.0.1.2", null, "模拟采集器", null, null, |
| | | 4, true, now, now.plusSeconds(30)))); |
| | | var service = new TTestLiveServiceImpl(tests, candidates, simulator, mapper); |
| | | service.current(10L); |
| | | var received = simulator.snapshot().recentRequests(); |
| | | assertTrue(received.stream().anyMatch(item -> item.command().equals("collector-realtime"))); |
| | | for (String status : List.of("PAUSED", "ENDED", "NOT_STARTED")) |
| | | { |
| | | test.setReceptionStatus(status); |
| | | var source = service.current(10L).sources().stream() |
| | | .filter(item -> item.sourceType().equals("COLLECTOR")).findFirst().orElseThrow(); |
| | | assertEquals("ONLINE", source.deviceStatus()); |
| | | assertTrue(source.latestSnapshot().isEmpty()); |
| | | assertEquals(received, simulator.snapshot().recentRequests()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void notStartedShouldNotExposePreTestDeviceSnapshot() |
| | | { |
| | | Instant now = Instant.parse("2026-09-02T06:00:00Z"); |
| | |
| | | class BtsDevelopmentSimulatorControllerTest |
| | | { |
| | | @Test |
| | | void restChangesSoftwareAndCollectorChannelFactsAndRetiresPlayback() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build(); |
| | | mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/dev/bts-simulator/software") |
| | | .contentType("application/json").content("{\"accessible\":false}")) |
| | | .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()); |
| | | assertFalse(simulator.snapshot().softwareAccessible()); |
| | | mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put( |
| | | "/dev/bts-simulator/collectors/1/channels/S-01/status") |
| | | .contentType("application/json").content("{\"workStatus\":\"PROTECTED\"}")) |
| | | .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()); |
| | | assertEquals(DeviceWorkStatus.PROTECTED, simulator.snapshot().collectors().get(0).channels().get(0).workStatus()); |
| | | for (String path : List.of("/devices/1/channels/1/playback/play", "/devices/1/channels/1/playback/next-step")) |
| | | mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/dev/bts-simulator" + path)) |
| | | .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound()); |
| | | for (String path : List.of("/collectors/1/history", "/devices/1/channels/1/records")) |
| | | mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/dev/bts-simulator" + path)) |
| | | .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void controllerUpdatesDeviceAndChannelState() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | |
| | | } |
| | | |
| | | @Test |
| | | void controllerExposesCollectorRealtimeAndPagedHistory() throws Exception |
| | | void controllerExposesOnlyCurrentCollectorMeasurements() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | BtsDevelopmentSimulatorController controller = controller(simulator); |
| | | controller.configureCollectorSampling(1, |
| | | new BtsDevelopmentSimulatorController.CollectorSamplingCommand( |
| | | 100, List.of(), null, null, false)); |
| | | controller.configureCollectorChannel(1, "S-01", |
| | | new BtsDevelopmentSimulatorController.CollectorChannelCommand(List.of( |
| | | new BtsDevelopmentSimulatorController.CollectorMetricCommand( |
| | | "temperature", new BigDecimal("27.2"), "℃"), |
| | | new BtsDevelopmentSimulatorController.CollectorMetricCommand( |
| | | "custom_voltage", new BigDecimal("1.234"), "V")))); |
| | | simulator.advanceCollectors(Instant.parse("2026-08-29T01:00:00Z")); |
| | | |
| | | var realtime = controller.collectorRealtime(1); |
| | | var firstPage = controller.collectorHistory(1, 0, 2); |
| | | |
| | | assertTrue(realtime.online()); |
| | | assertTrue(realtime.measurements().stream().anyMatch(item -> item.metricCode().equals("custom_voltage"))); |
| | | assertEquals(2, firstPage.records().size()); |
| | | assertTrue(firstPage.hasMore()); |
| | | assertEquals(2, firstPage.nextOffset()); |
| | | |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void controllerPowersAllSimulatorDevicesWithoutResettingHistory() throws Exception |
| | | void controllerChangesOnlineFactsWithoutResettingChannelStatus() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | BtsDevelopmentSimulatorController controller = controller(simulator); |
| | | simulator.advanceCollectors(Instant.now()); |
| | | long historyCount = controller.state().collectors().get(0).historyRecordCount(); |
| | | simulator.updateChannel(1, 1, true, DeviceWorkStatus.PAUSED); |
| | | |
| | | var poweredOff = controller.setPower( |
| | | new BtsDevelopmentSimulatorController.SimulatorPowerCommand(false)); |
| | | |
| | | assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online() |
| | | || device.channels().stream().anyMatch(channel -> channel.online()))); |
| | | assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online())); |
| | | assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online())); |
| | | assertEquals(historyCount, poweredOff.collectors().get(0).historyRecordCount()); |
| | | assertEquals(DeviceWorkStatus.PAUSED, poweredOff.devices().get(0).channels().get(0).workStatus()); |
| | | |
| | | var poweredOn = controller.setPower( |
| | | new BtsDevelopmentSimulatorController.SimulatorPowerCommand(true)); |
| | | assertTrue(poweredOn.devices().stream().allMatch(device -> device.online() |
| | | && device.channels().stream().allMatch(channel -> channel.online() |
| | | && channel.workStatus() == DeviceWorkStatus.STOPPED))); |
| | | ))); |
| | | assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online())); |
| | | } |
| | | } |