From 6c38637d89f346a660618558451f2dd7286dc7a5 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 31 八月 2026 22:51:54 +0800
Subject: [PATCH] 补齐检测柜模拟器完整 Excel 数据生命周期

---
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java |  354 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 333 insertions(+), 21 deletions(-)

diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
index 7918e0c..2c583fa 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
@@ -25,6 +25,7 @@
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsPlaybackState;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
 import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
@@ -53,7 +54,8 @@
     private SimulatorState state;
     private boolean running = true;
     private long observationSequence;
-    private final java.util.Map<String, java.util.List<SimBtsHistory>> btsHistory = new java.util.LinkedHashMap<>();
+    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"));
 
@@ -143,8 +145,16 @@
         List<DeviceState> devices = new ArrayList<>(state.devices());
         devices.removeIf(item -> item.deviceId() == deviceId);
         List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
+        BtsDeviceProfile removedProfile = profileFor(removed);
         for (ChannelState channel : removed.channels())
+        {
             upsertTombstone(tombstones, removed.deviceId(), removed.ip(), channel.channelId());
+            String key = historyKey(removedProfile.devtype(), removed.deviceId(), removedProfile.subDeviceId(),
+                    channel.channelId());
+            stateStore.deleteBtsHistory(key);
+            stateStore.deleteBtsPlaybackState(key);
+            btsHistory.remove(key);
+        }
         persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
                 state.nextCollectorId(), tombstones));
     }
@@ -264,6 +274,156 @@
                     state.nextCollectorId(), state.tombstones()), historyAppends);
     }
 
+    /** 按墙上时间推进检测柜模拟播放;设备状态和测试接收状态互不影响。 */
+    public synchronized void advanceBts(Instant now)
+    {
+        for (DeviceState device : state.devices())
+        {
+            if (!device.online()) continue;
+            BtsDeviceProfile profile = profileFor(device);
+            for (ChannelState channel : device.channels())
+            {
+                if (!channel.online() || channel.workStatus() != DeviceWorkStatus.WORKING) continue;
+                String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId());
+                BtsPlaybackState playback = playbackState(key, now);
+                advanceBtsPlayback(key, profile, playback,
+                        profile.currentMaxA().doubleValue() * 0.2d, 3.7d, now);
+            }
+        }
+    }
+
+    /** 设置检测柜通道演示倍速,只接受 1x、60x 和 600x。 */
+    public synchronized void setBtsSpeed(int deviceId, int channelId, int speedMultiplier)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        String key = btsKey(device, channelId);
+        BtsPlaybackState current = playbackState(key, Instant.now());
+        stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(current.playing(), speedMultiplier,
+                current.cycleNo(), current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(),
+                Instant.now(), current.nextSequence(), current.naturallyFinished()));
+    }
+
+    /** 开始播放指定检测柜通道的演示时间线。 */
+    public synchronized void playBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); }
+    /** 暂停模拟器时间线,不改变检测柜通道工作事实。 */
+    public synchronized void pauseBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, false); }
+    /** 从暂停状态恢复模拟器时间线。 */
+    public synchronized void resumeBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); }
+
+    private void setBtsPlaying(int deviceId, int channelId, boolean playing)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        String key = btsKey(device, channelId);
+        Instant now = Instant.now();
+        BtsPlaybackState current = playbackState(key, now);
+        if (current.naturallyFinished() && playing) return;
+        stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(playing, current.speedMultiplier(), current.cycleNo(),
+                current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(), now,
+                current.nextSequence(), current.naturallyFinished()));
+    }
+
+    /** 跳转到下一实际工步;不向设备发送控制报文。 */
+    public synchronized void nextBtsStep(int deviceId, int channelId)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        String key = btsKey(device, channelId);
+        BtsPlaybackState current = playbackState(key, Instant.now());
+        BtsExcelFixture.FixtureRecord next = nextFixtureStep(current.cycleNo(), current.stepNo());
+        if (next == null)
+        {
+            BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount());
+            savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(),
+                    last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(),
+                    BtsExcelFixture.recordCount() + 1L, true));
+            return;
+        }
+        savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(),
+                next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false));
+    }
+
+    /** 跳转到下一循环的首工步;不补齐当前循环缺失的首尾工步。 */
+    public synchronized void nextBtsCycle(int deviceId, int channelId)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        String key = btsKey(device, channelId);
+        BtsPlaybackState current = playbackState(key, Instant.now());
+        BtsExcelFixture.FixtureRecord next = BtsExcelFixture.records().stream()
+                .filter(item -> item.cycleNo() > current.cycleNo()).findFirst().orElse(null);
+        if (next == null)
+        {
+            BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount());
+            savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(),
+                    last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(),
+                    BtsExcelFixture.recordCount() + 1L, true));
+            return;
+        }
+        savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(),
+                next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false));
+    }
+
+    /** 清空单通道播放和原始数据,恢复到首循环首工步。 */
+    public synchronized void resetBts(int deviceId, int channelId)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        String key = btsKey(device, channelId);
+        btsHistory.remove(key);
+        stateStore.deleteBtsHistory(key);
+        stateStore.deleteBtsPlaybackState(key);
+        savePlayback(key, defaultPlayback(Instant.now()));
+    }
+
+    /** 返回播放状态及当前通道的 record、step、cycle 数据。 */
+    public synchronized PlaybackSnapshot btsPlayback(int deviceId, int channelId)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        String key = btsKey(device, channelId);
+        BtsPlaybackState state = playbackState(key, Instant.now());
+        return new PlaybackSnapshot(deviceId, channelId, state, btsRecords(deviceId, channelId),
+                btsSteps(deviceId, channelId), btsCycles(deviceId, channelId));
+    }
+
+    /** 返回只追加的检测柜原始 record。 */
+    public synchronized List<BtsHistoryRecord> btsRecords(int deviceId, int channelId)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        return List.copyOf(btsHistoryFor(btsKey(device, channelId)));
+    }
+
+    /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */
+    public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId)
+    {
+        java.util.Map<String, BtsStepAccumulator> grouped = new java.util.LinkedHashMap<>();
+        for (BtsHistoryRecord item : btsRecords(deviceId, channelId))
+        {
+            String key = item.cycleNo() + ":" + item.stepNo();
+            grouped.computeIfAbsent(key, ignored -> new BtsStepAccumulator(item)).add(item);
+        }
+        return grouped.values().stream().map(BtsStepAccumulator::finish).toList();
+    }
+
+    /** 按实际出现的循环汇总 record,不增加测试结束统计行。 */
+    public synchronized List<BtsCycleRecord> btsCycles(int deviceId, int channelId)
+    {
+        java.util.Map<Integer, BtsCycleAccumulator> grouped = new java.util.LinkedHashMap<>();
+        for (BtsHistoryRecord item : btsRecords(deviceId, channelId))
+            grouped.computeIfAbsent(item.cycleNo(), ignored -> new BtsCycleAccumulator(item)).add(item);
+        return grouped.entrySet().stream().map(entry -> {
+            BtsCycleRecord calculated = entry.getValue().finish();
+            BtsExcelFixture.CycleSummary expected = BtsExcelFixture.cycle(entry.getKey());
+            if (expected == null || entry.getValue().count < expected.recordCount()) return calculated;
+            return new BtsCycleRecord(calculated.cycleNo(), expected.recordCount(), calculated.startedAt(),
+                    calculated.endedAt(), expected.chargeCapacityAh(), expected.dischargeCapacityAh(),
+                    expected.chargeEnergyWh(), expected.dischargeEnergyWh());
+        }).toList();
+    }
+
     /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */
     public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId)
     {
@@ -303,6 +463,7 @@
     {
         SimulatorState defaultState = defaultState();
         stateStore.reset(defaultState);
+        btsHistory.clear();
         state = defaultState;
     }
 
@@ -453,21 +614,27 @@
                     profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d;
             double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(),
                     profile.voltageMaxV().doubleValue());
-            double capacity = working ? Math.max(0d, tick * 0.001d * current) : 0d;
+            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)
             {
-                String key = historyKey(profile.devtype(), observed.deviceId(), profile.subDeviceId(), channel.channelId());
-                java.util.List<SimBtsHistory> history = btsHistory.computeIfAbsent(key, ignored -> new ArrayList<>());
-                history.add(new SimBtsHistory(history.size() + 1L, Instant.now(), current, voltage, capacity, energy));
-                SimBtsHistory latest = history.get(history.size() - 1);
-                stateStore.appendBtsHistory(key, new BtsHistoryRecord(latest.sequence(), latest.at(), latest.current(),
-                        latest.voltage(), latest.capacity(), latest.energy()));
-                if (history.size() > BtsSimulatorStateStore.MAX_HISTORY_RECORDS) history.remove(0);
+                playback = advanceBtsPlayback(key, profile, playback, current, voltage);
+                BtsHistoryRecord latest = latestRecord(key);
+                if (latest != null)
+                {
+                    capacity = latest.capacityAh();
+                    energy = latest.energyWh();
+                }
             }
+            int cycleNo = playback.cycleNo();
+            int stepNo = playback.stepNo();
+            String stepType = playback.stepType();
             response.append("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId())
                     .append('-').append(profile.subDeviceId()).append('-').append(channel.channelId())
-                    .append("-0\" cycle_id=\"1\" step_id=\"1\" step_type=\"cc\" workstatus=\"")
+                    .append("-0\" cycle_id=\"").append(cycleNo).append("\" step_id=\"").append(stepNo)
+                    .append("\" step_type=\"").append(stepType).append("\" workstatus=\"")
                     .append(toBtsWorkStatus(channel.workStatus()))
                     .append("\" current=\"").append(decimal(current)).append("\" voltage=\"")
                     .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity))
@@ -489,7 +656,7 @@
             int devid = Integer.parseInt(attribute(attrs, "devid"));
             int subdevid = Integer.parseInt(attribute(attrs, "subdevid"));
             int chlid = Integer.parseInt(attribute(attrs, "chlid"));
-            int count = btsHistory.getOrDefault(historyKey(devtype, devid, subdevid, chlid), List.of()).size();
+            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=\"")
@@ -507,18 +674,19 @@
         String testId = attribute(request, "testid");
         long start = Long.parseLong(attribute(request, "startpos"));
         int count = Integer.parseInt(attribute(request, "count"));
-        List<SimBtsHistory> history = btsHistory.getOrDefault(historyKey(devtype, devid, subdevid, chlid), List.of());
+        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 (SimBtsHistory item : history.subList(from, to))
-            response.append("<data seqid=\"").append(item.sequence()).append("\" stepid=\"1\" cycleid=\"1\" steptype=\"cc\" atime=\"")
-                    .append(BTS_HISTORY_TIME.format(item.at())).append("\" testtime=\"1000\" volt=\"")
-                    .append(decimal(item.voltage())).append("\" curr=\"").append(decimal(item.current()))
-                    .append("\" cap=\"").append(decimal(item.capacity())).append("\" eng=\"").append(decimal(item.energy())).append("\"/>");
+        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();
     }
 
@@ -542,13 +710,12 @@
             for (ChannelState channel : device.channels())
             {
                 String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId());
-                ArrayList<SimBtsHistory> restored = new ArrayList<>();
+                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(item -> new SimBtsHistory(item.sequenceId(),
-                            item.deviceTime(), item.currentA(), item.voltageV(), item.capacityAh(), item.energyWh())).toList());
+                    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();
@@ -558,7 +725,152 @@
         }
     }
 
-    private record SimBtsHistory(long sequence, Instant at, double current, double voltage, double capacity, double energy) { }
+    private List<BtsHistoryRecord> btsHistoryFor(String key)
+    {
+        return btsHistory.getOrDefault(key, List.of());
+    }
+
+    private BtsHistoryRecord normalizeHistoryRecord(BtsHistoryRecord item)
+    {
+        return item.stepName() == null || item.stepName().isBlank()
+                ? new BtsHistoryRecord(item.sequenceId(), item.deviceTime(), item.currentA(), item.voltageV(),
+                        item.capacityAh(), item.energyWh()) : item;
+    }
+
+    private String btsKey(DeviceState device, int channelId)
+    {
+        BtsDeviceProfile profile = profileFor(device);
+        return historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channelId);
+    }
+
+    private void requireChannel(DeviceState device, int channelId)
+    {
+        if (channelId < 1 || channelId > device.channels().size())
+            throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
+    }
+
+    private BtsPlaybackState playbackState(String key, Instant now)
+    {
+        BtsPlaybackState stored = stateStore.loadBtsPlaybackState(key);
+        if (stored != null) return stored;
+        List<BtsHistoryRecord> records = btsHistoryFor(key);
+        long next = records.stream().mapToLong(BtsHistoryRecord::sequenceId).max().orElse(0L) + 1L;
+        BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1);
+        BtsPlaybackState created = new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(),
+                now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, next, false);
+        stateStore.saveBtsPlaybackState(key, created);
+        return created;
+    }
+
+    private BtsHistoryRecord latestRecord(String key)
+    {
+        List<BtsHistoryRecord> records = btsHistoryFor(key);
+        return records.isEmpty() ? null : records.get(records.size() - 1);
+    }
+
+    private double latestCapacity(String key)
+    {
+        BtsHistoryRecord latest = latestRecord(key);
+        return latest == null ? 0d : latest.capacityAh();
+    }
+
+    private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current,
+            double currentA, double voltageV)
+    {
+        return advanceBtsPlayback(key, profile, current, currentA, voltageV, Instant.now());
+    }
+
+    private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current,
+            double currentA, double voltageV, Instant now)
+    {
+        if (!current.playing() || current.naturallyFinished()) return current;
+        long elapsedMillis = Math.max(0L, java.time.Duration.between(current.wallClockAt(), now).toMillis())
+                * current.speedMultiplier();
+        List<BtsHistoryRecord> history = new ArrayList<>(btsHistoryFor(key));
+        List<BtsHistoryRecord> appended = new ArrayList<>();
+        Instant simulated = current.simulatedAt();
+        long sequence = current.nextSequence();
+        if (sequence < 1) sequence = 1;
+        if (sequence > BtsExcelFixture.recordCount() + 1L)
+            sequence = BtsExcelFixture.recordCount() + 1L;
+        long lastOffset = sequence == 1 ? -SIMULATED_RECORD_INTERVAL_SECONDS
+                : BtsExcelFixture.record(sequence - 1).offsetSeconds();
+        long budgetMillis = elapsedMillis;
+        while (sequence <= BtsExcelFixture.recordCount())
+        {
+            BtsExcelFixture.FixtureRecord fixture = BtsExcelFixture.record(sequence);
+            long deltaSeconds = fixture.offsetSeconds() - lastOffset;
+            long deltaMillis = Math.max(1L, deltaSeconds * 1000L);
+            boolean firstRecord = history.isEmpty() && sequence == 1L;
+            if (!firstRecord && budgetMillis < deltaMillis) break;
+            budgetMillis = Math.max(0L, budgetMillis - deltaMillis);
+            simulated = simulated.plusSeconds(deltaSeconds);
+            BtsHistoryRecord record = new BtsHistoryRecord(fixture.sequenceId(), simulated, fixture.currentA(), fixture.voltageV(),
+                    fixture.capacityAh(), fixture.energyWh(), fixture.cycleNo(), fixture.stepNo(), fixture.stepName(), fixture.stepType());
+            history.add(record);
+            appended.add(record);
+            lastOffset = fixture.offsetSeconds();
+            sequence++;
+        }
+        if (appended.isEmpty()) return current;
+        if (history.size() > BtsSimulatorStateStore.MAX_HISTORY_RECORDS)
+            history = new ArrayList<>(history.subList(history.size() - (int) BtsSimulatorStateStore.MAX_HISTORY_RECORDS,
+                    history.size()));
+        BtsExcelFixture.FixtureRecord latest = BtsExcelFixture.record(sequence - 1L);
+        boolean finished = sequence > BtsExcelFixture.recordCount();
+        BtsPlaybackState next = new BtsPlaybackState(!finished && current.playing(), current.speedMultiplier(),
+                latest.cycleNo(), latest.stepNo(), latest.stepName(), latest.stepType(), simulated, now, sequence, finished);
+        stateStore.saveBtsPlaybackAndAppendHistory(key, next, appended);
+        btsHistory.put(key, history);
+        return next;
+    }
+
+    private BtsPlaybackState defaultPlayback(Instant now)
+    {
+        BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1);
+        return new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(),
+                now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, 1, false);
+    }
+
+    private BtsExcelFixture.FixtureRecord nextFixtureStep(int cycleNo, int stepNo)
+    {
+        return BtsExcelFixture.records().stream()
+                .filter(item -> item.cycleNo() > cycleNo || (item.cycleNo() == cycleNo && item.stepNo() > stepNo))
+                .findFirst().orElse(null);
+    }
+
+    private void savePlayback(String key, BtsPlaybackState playback)
+    {
+        stateStore.saveBtsPlaybackState(key, playback);
+    }
+
+    public record BtsStepRecord(int cycleNo, int stepNo, String stepName, String stepType, long recordCount,
+            Instant startedAt, Instant endedAt, double capacityAh, double energyWh) { }
+
+    public record BtsCycleRecord(int cycleNo, long recordCount, Instant startedAt, Instant endedAt,
+            double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, double dischargeEnergyWh) { }
+
+    public record PlaybackSnapshot(int deviceId, int channelId, BtsPlaybackState playback,
+            List<BtsHistoryRecord> records, List<BtsStepRecord> steps, List<BtsCycleRecord> cycles) { }
+
+    private static final class BtsStepAccumulator
+    {
+        private final int cycle; private final int step; private final String name; private final String type;
+        private long count; private Instant first; private Instant last; private double capacity; private double energy;
+        BtsStepAccumulator(BtsHistoryRecord first) { cycle = first.cycleNo(); step = first.stepNo(); name = first.stepName(); type = first.stepType(); }
+        void add(BtsHistoryRecord item) { count++; if (first == null) first = item.deviceTime(); last = item.deviceTime(); capacity = item.capacityAh(); energy = item.energyWh(); }
+        BtsStepRecord finish() { return new BtsStepRecord(cycle, step, name, type, count, first, last, capacity, energy); }
+    }
+
+    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() { return new BtsCycleRecord(cycle, count, first, last, Math.max(0d, maximumCapacity), Math.max(0d, maximumCapacity - minimumCapacity), chargeEnergy, dischargeEnergy); }
+    }
 
     private java.util.Set<String> requestedChannels(String request)
     {

--
Gitblit v1.7.1