From 6b99cc391064cb38c706979fafcecb9f50ea1587 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 11 十月 2026 02:07:44 +0800
Subject: [PATCH] 新威直连补上工步层与循环汇总(downloadStepLayer 实机验证后落地)

---
 battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java             |  141 ++++++++++++++
 battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java        |  106 +++++++++++
 battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java                  |  107 +++++++++++
 battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java |  147 +++++++++++++++
 4 files changed, 489 insertions(+), 12 deletions(-)

diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java b/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java
index 9ae7c8f..780799d 100644
--- a/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java
@@ -26,6 +26,7 @@
 import org.xml.sax.helpers.DefaultHandler;
 import com.battery.device.api.StepDirectionResolver;
 import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
+import com.battery.device.api.TestDataObservationAdapter.StepRecord;
 import com.battery.device.api.model.ChannelIdentity;
 import com.battery.device.api.model.DeviceConnectionStatus;
 import com.battery.device.api.model.DetectedChannel;
@@ -190,6 +191,112 @@
     /** 该通道的数据文件状态:测试号、已落盘行数、厂商自报的数据是否完整。 */
     public record DataFileState(long testId, long count, boolean complete) { }
 
+    /**
+     * 构造工步层下载的请求报文({@code downloadStepLayer})。
+     *
+     * <p>现场实测:只发命令与 {@code downloadStepLayer} 元素即可,文档里的 {@code V1I1}/{@code V2I2}
+     * 不填时按文档所说"用默认参数";{@code dcir="0"}"不计算 DCIR",我们只要工步事实、不做派生计算。
+     * 与 {@code download} 一样,{@code testid="0"} 表示"该通道最近一次测试",应答会回显真实测试号。
+     */
+    public String downloadStepLayerRequest(ChannelIdentity channel)
+    {
+        requireOwned(channel);
+        return envelope("<cmd>downloadStepLayer</cmd><downloadStepLayer devtype=\"" + channel.devtype()
+                + "\" devid=\"" + channel.devid() + "\" subdevid=\"" + channel.subdevid()
+                + "\" chlid=\"" + channel.chlid() + "\" testid=\"0\" dcir=\"0\"/>");
+    }
+
+    /**
+     * 解析 {@code downloadStepLayer_resp} 为工步记录。
+     *
+     * <p>每条 {@code <data/>} 是一个工步:{@code startseqid}/{@code endseqid}(该工步的首末数据序号)、
+     * {@code stepindex}(测试内的工步序号)、{@code stepid}(原始工步号,循环里会重复)、
+     * {@code cycleid}、{@code steptype}、{@code steptime}(工步运行时长,**毫秒**)、{@code endatime}
+     * (工步绝对结束时间)、{@code startvolt}/{@code endvolt}、{@code startcurr}/{@code endcurr}、
+     * {@code cap}/{@code eng}、{@code dcir}(mΩ)。
+     *
+     * <p>三处按实测钉死的口径:
+     *
+     * <ul>
+     *   <li><b>循环号要减一。</b>这条命令的 {@code cycleid} 是 **1 起**(第一步 {@code cycleid="1"}),
+     *       而 {@code download} 的 {@code cycleid} 与厂商库的 {@code cycle} 都是 **0 起**。
+     *       实测同一测试两处对照(工步层 {@code cycleid="1"}/逐点 {@code cycleid="0"})确认,
+     *       所以这里统一成 0 起,与库路线、逐点路线同口径。</li>
+     *   <li><b>开始时间 = 结束时间 − 工步时长。</b>应答只给 {@code endatime}。按两个独立样本核对过
+     *       ({@code endatime=09:09:14, steptime=23100 ms} → 09:08:50.9,该测试首行 {@code atime}=09:08:51),
+     *       所以这个减法还原的是设备事实,不是估算。缓冲里缺行时(实测有测试只覆盖约 1 天)
+     *       相邻两工步的首末不会接上,那是来源的数据缺口,不是这里算错。</li>
+     *   <li><b>{@code stepindex} 才是工步身份。</b>{@code stepid} 在循环里会重复(同一步在每轮循环再跑一遍),
+     *       用它做身份会撞键。</li>
+     * </ul>
+     *
+     * <p>方向的判定沿用既有解析器;{@code cap}/{@code eng} 在接口上是**单一值**(不分充放),
+     * 只在方向已确认时给出,方向未知时留空——不按数值大小猜它属于哪一侧(ADR-0069 §3)。
+     *
+     * @param xml        应答原文
+     * @param receivedAt 服务器接收时间
+     * @return 按应答顺序的工步记录;该通道缓冲里没有测试时为空列表
+     * @throws BtsProtocolException 应答不是 {@code downloadStepLayer_resp}
+     */
+    public List<StepRecord> parseStepLayer(String xml, Instant receivedAt)
+    {
+        Document document = parse(xml, "downloadStepLayer_resp");
+        NodeList nodes = document.getElementsByTagName("data");
+        if (nodes.getLength() == 0) return List.of();
+        Element header = optionalElement(document, "downloadStepLayer");
+        String testId = header == null ? "" : optionalAttribute(header, "testid");
+        String zoneId = deviceZone.getId();
+        List<StepRecord> steps = new ArrayList<>(nodes.getLength());
+        for (int i = 0; i < nodes.getLength(); i++)
+        {
+            Element data = (Element) nodes.item(i);
+            String stepType = optionalAttribute(data, "steptype");
+            StepDirectionResolver.Direction direction = directions.resolve(stepType);
+            String rawTime = requiredAttribute(data, "endatime");
+            Instant endedAt = parseDataTime(rawTime);
+            Long durationMillis = optionalDecimal(data, "steptime") == null ? null
+                    : optionalDecimal(data, "steptime").longValue();
+            int stepIndex = integer(requiredAttribute(data, "stepindex"), "stepindex");
+            // 应答里的 cycleid 是 1 起,逐点与厂商库都是 0 起;统一成 0 起。
+            int cycleNo = requiredInteger(data, "cycleid") - 1;
+            BigDecimal capacity = optionalDecimal(data, "cap");
+            BigDecimal energy = optionalDecimal(data, "eng");
+            boolean directionKnown = direction != StepDirectionResolver.Direction.UNKNOWN;
+            steps.add(new StepRecord(testId.isBlank() ? null : "neware:" + testId + ":step:" + stepIndex, cycleNo,
+                    requiredInteger(data, "stepid"), direction.displayName(), stepType,
+                    durationMillis == null ? endedAt : endedAt.minusMillis(durationMillis),
+                    endedAt, doubleValue(data, "endvolt"), doubleValue(data, "endcurr"),
+                    directionKnown && capacity != null ? capacity.doubleValue() : null,
+                    directionKnown && energy != null ? energy.doubleValue() : null,
+                    rawTime, zoneId, receivedAt, stepMeasurements(data, durationMillis)));
+        }
+        return List.copyOf(steps);
+    }
+
+    /** 工步的设备事实明细;映射表按已有键名口径命名,值全部来自应答原文。 */
+    private static Map<String, BigDecimal> stepMeasurements(Element data, Long durationMillis)
+    {
+        Map<String, BigDecimal> measurements = new LinkedHashMap<>();
+        if (durationMillis != null) measurements.put("stepDuration", BigDecimal.valueOf(durationMillis).movePointLeft(3));
+        put(measurements, data, "startSeqId", "startseqid");
+        put(measurements, data, "endSeqId", "endseqid");
+        put(measurements, data, "startVoltage", "startvolt");
+        put(measurements, data, "startCurrent", "startcurr");
+        put(measurements, data, "dcir", "dcir");
+        return Map.copyOf(measurements);
+    }
+
+    private static void put(Map<String, BigDecimal> target, Element element, String key, String attribute)
+    {
+        BigDecimal value = optionalDecimal(element, attribute);
+        if (value != null) target.put(key, value);
+    }
+
+    private static int requiredInteger(Element element, String name)
+    {
+        return integer(requiredAttribute(element, name), name);
+    }
+
 
 
     /** 解析 {@code getdevinfo_resp},按本实例的适配器编码生成检测柜通道清单。 */
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java
index a32c471..5ec2f15 100644
--- a/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java
@@ -1,17 +1,21 @@
 package com.battery.device.bts;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import com.battery.device.api.DeviceAdapter;
 import com.battery.device.api.StepDirectionResolver;
 import com.battery.device.api.TestDataObservationAdapter;
 import com.battery.device.api.model.AdapterCapabilities;
 import com.battery.device.api.model.ChannelIdentity;
 import com.battery.device.api.model.DetectedChannel;
+import com.battery.device.api.model.DeviceWorkStatus;
 import com.battery.device.api.model.RealtimeSnapshot;
 
 /**
@@ -26,13 +30,12 @@
  *   <li>{@code inquiredf} → 当前测试号与已落盘行数,后者是逐点接收的增量水位;</li>
  *   <li>{@code download} → 逐点行。它给全精度测量值**和绝对时间**,所以测量值与时间轴取它,
  *       而不取 {@code inquire}(后者是 4 位小数、且没有绝对时间)。</li>
+ *   <li>{@code downloadStepLayer} → 工步层;循环汇总由这些工步按循环聚合(见 {@link #cycleRecords})。</li>
  * </ul>
  *
- * <p><b>不交出的三类事实</b>(都不猜、不用空值冒充已知):
+ * <p><b>不交出的两类事实</b>(都不猜、不用空值冒充已知):
  *
  * <ul>
- *   <li><b>工步层与循环汇总</b>:协议里读工步层要 {@code downloadStepLayer},该命令**未验证**,
- *       所以这边为空;它们属于该机厂商库那条路(ADR-0068 §5、未决 1)。</li>
  *   <li><b>辅助通道 / 采集器测量</b>:ADR-0068 已复核不可得(该机 {@code aux_data_*} 全空、
  *       API 的 {@code auxtemp}/{@code auxvol} 为 0),所以 {@link #latestCollector} 返回空值。</li>
  *   <li><b>计划方案</b>:协议里没有读方案的命令(现场实测 {@code inquire_resp} 不带计划),
@@ -49,12 +52,16 @@
     public static final String ADAPTER_TYPE = "NEWARE_BTS";
     /** 厂商采样周期实测为 60 秒;实时持久化槽位按它对齐,取数不必更快。 */
     public static final long SAMPLE_INTERVAL_MS = 60_000L;
+    /** 工步层读取结果的复用窗口:只为让工步与循环来自同一次读取,不做缓存加速。 */
+    private static final long STEP_LAYER_CACHE_NANOS = 5_000_000_000L;
 
     private final String adapterCode;
     private final BtsTransport transport;
     private final BtsXmlCodec codec;
     private final StepDirectionResolver directions = new BtsStepDirectionResolver();
     private final AdapterCapabilities capabilities;
+    /** 每通道最近一次工步层读取;见 {@link #stepLayer}。 */
+    private final Map<String, StepLayer> stepLayers = new ConcurrentHashMap<>();
 
     public NewareBtsAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec)
     {
@@ -127,18 +134,33 @@
         return null;
     }
 
-    /** 本路线的工步层需要未验证的 {@code downloadStepLayer},因此不交出工步统计。 */
+    /**
+     * 返回该通道**已结束**的工步。来源是 {@code downloadStepLayer}(现场实测可用)。
+     *
+     * <p>这条命令**会把正在进行的那一步也交出来**,而且它的 {@code endseqid} 恰好等于
+     * {@code inquiredf} 报的已落盘行数(实测四个测试都是相等)——所以**行数比不出结束没有**,
+     * 判据是通道的工作状态:仍在运行、暂停或保护中的测试,最后一步没有结束。
+     * 交出未结束的段会让它的结束时间每轮都在变,同一条记录反复以不同内容到达,既留下一条错的结束时间,
+     * 又不断触发"内容冲突"告警,所以这里把它连同它所在的循环一起按住,等它结束再交出。
+     */
     @Override
     public List<StepRecord> stepRecords(ChannelIdentity channel)
     {
-        return List.of();
+        return stepLayer(channel).steps();
     }
 
-    /** 本路线的循环汇总同上:不交出,也不用实时行推导来源没有的汇总。 */
+    /**
+     * 返回该通道的循环汇总,由已结束的工步按循环聚合({@link CycleRecord#SOURCE_AGGREGATED})。
+     *
+     * <p>接口没有循环汇总行,聚合口径与库路线一致:只把方向已确认的工步容量能量加进对应一侧,
+     * 搁置与未知方向不进任何一侧(按数值猜方向就是编事实);效率 = 放电容量 / 充电容量 × 100,
+     * 没有可用充电容量时留空。含未结束工步的那个循环整轮不交出——那几项合计还在长。
+     */
     @Override
     public List<CycleRecord> cycleRecords(ChannelIdentity channel)
     {
-        return List.of();
+        StepLayer layer = stepLayer(channel);
+        return aggregateCycles(layer);
     }
 
     /** 返回厂商采样周期作为实时持久化槽位间隔。 */
@@ -214,6 +236,111 @@
         transport.close();
     }
 
+    /**
+     * 取该通道的工步层,并按"最后一步行不行"切出已结束的部分。
+     *
+     * <p>结果在 {@link #STEP_LAYER_CACHE_NANOS} 内复用:工步与循环必须来自**同一次读取**,
+     * 否则一个工步边界落在两次读取之间时,循环的合计会包含工步列表里没有的那一步,
+     * 使循环记录的内容每轮都不同(同一条循环反复以不同内容到达)。
+     */
+    private StepLayer stepLayer(ChannelIdentity channel)
+    {
+        String key = channel.vendorKey();
+        StepLayer cached = stepLayers.get(key);
+        if (cached != null && System.nanoTime() - cached.fetchedAtNanos() < STEP_LAYER_CACHE_NANOS) return cached;
+        StepLayer fetched = fetchStepLayer(channel);
+        stepLayers.put(key, fetched);
+        return fetched;
+    }
+
+    private StepLayer fetchStepLayer(ChannelIdentity channel)
+    {
+        long fetchedAtNanos = System.nanoTime();
+        Instant receivedAt = Instant.now();
+        List<StepRecord> layers = codec.parseStepLayer(
+                transport.exchange(codec.downloadStepLayerRequest(channel)), receivedAt);
+        if (layers.isEmpty()) return new StepLayer(List.of(), null, fetchedAtNanos);
+        // 通道仍在动(运行、暂停、保护中)时最后一步没结束:它和它所在的循环都先按住。
+        if (!isStillRunning(channel)) return new StepLayer(layers, null, fetchedAtNanos);
+        StepRecord running = layers.get(layers.size() - 1);
+        return new StepLayer(layers.subList(0, layers.size() - 1), running.cycleNo(), fetchedAtNanos);
+    }
+
+    /**
+     * 通道工作状态是否说明测试仍在进行。
+     *
+     * <p>只有读到"测试进行中"的状态才按住最后一步;其余状态(含读不到该通道)按已结束处理。
+     * **读取失败不吞**:{@code inquire} 抛错时让它传出去,调用方本来就有"来源读不到就本轮放弃、
+     * 下一轮重试"的约定,这样一次传输抖动不会变成"把一个还在跑的工步交出去"。
+     */
+    private boolean isStillRunning(ChannelIdentity channel)
+    {
+        RealtimeSnapshot snapshot = inquire(Set.of(channel)).get(channel);
+        if (snapshot == null) return false;
+        DeviceWorkStatus status = snapshot.workStatus();
+        return status == DeviceWorkStatus.WORKING || status == DeviceWorkStatus.PAUSED
+                || status == DeviceWorkStatus.PROTECTED || status == DeviceWorkStatus.SYNCHRONIZING;
+    }
+
+    /** 把已结束的工步按循环聚合;含未结束工步的那个循环不交出。 */
+    private List<CycleRecord> aggregateCycles(StepLayer layer)
+    {
+        Map<Integer, List<StepRecord>> byCycle = new LinkedHashMap<>();
+        for (StepRecord step : layer.steps())
+        {
+            if (layer.unfinishedCycleNo() != null && layer.unfinishedCycleNo() == step.cycleNo()) continue;
+            byCycle.computeIfAbsent(step.cycleNo(), ignored -> new ArrayList<>()).add(step);
+        }
+        List<CycleRecord> cycles = new ArrayList<>(byCycle.size());
+        byCycle.forEach((cycleNo, group) -> {
+            BigDecimal chargeCapacity = BigDecimal.ZERO, dischargeCapacity = BigDecimal.ZERO;
+            BigDecimal chargeEnergy = BigDecimal.ZERO, dischargeEnergy = BigDecimal.ZERO;
+            for (StepRecord step : group)
+            {
+                if (step.capacityAh() == null || step.energyWh() == null) continue;
+                BigDecimal capacity = BigDecimal.valueOf(step.capacityAh());
+                BigDecimal energy = BigDecimal.valueOf(step.energyWh());
+                switch (directions.resolve(step.stepType()))
+                {
+                    case CHARGE ->
+                    {
+                        chargeCapacity = chargeCapacity.add(capacity);
+                        chargeEnergy = chargeEnergy.add(energy);
+                    }
+                    case DISCHARGE ->
+                    {
+                        dischargeCapacity = dischargeCapacity.add(capacity);
+                        dischargeEnergy = dischargeEnergy.add(energy);
+                    }
+                    // 搁置与未知方向不进任何一侧:把容量算进某一侧就是凭数值猜方向。
+                    default -> { }
+                }
+            }
+            StepRecord first = group.get(0), last = group.get(group.size() - 1);
+            cycles.add(new CycleRecord("neware:" + testIdOf(first) + ":cycle:" + cycleNo, cycleNo,
+                    first.startedAt(), last.endedAt(), chargeCapacity.doubleValue(), dischargeCapacity.doubleValue(),
+                    chargeEnergy.doubleValue(), dischargeEnergy.doubleValue(),
+                    chargeCapacity.signum() == 0 ? null : dischargeCapacity.multiply(BigDecimal.valueOf(100))
+                            .divide(chargeCapacity, 4, RoundingMode.HALF_UP).doubleValue(),
+                    last.deviceTimeRaw(), codec.timezoneId(), last.serverReceivedAt(), CycleRecord.SOURCE_AGGREGATED));
+        });
+        return List.copyOf(cycles);
+    }
+
+    /** 从工步的来源行身份里取回测试号(形如 {@code neware:<测试号>:step:<序号>})。 */
+    private static String testIdOf(StepRecord step)
+    {
+        String identity = step.sourceRecordId();
+        if (identity == null) return "unknown";
+        String[] parts = identity.split(":");
+        return parts.length >= 2 ? parts[1] : "unknown";
+    }
+
+    /**
+     * 一次工步层读取的结果:已结束的工步、被按住的未结束工步所属循环(没有则为空)。
+     */
+    private record StepLayer(List<StepRecord> steps, Integer unfinishedCycleNo, long fetchedAtNanos) { }
+
     private BtsXmlCodec.DataFileState dataFileState(ChannelIdentity channel)
     {
         return codec.parseDataFileState(transport.exchange(codec.inquiredfRequest(channel)));
diff --git a/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java
index cdbdd16..d75dfc1 100644
--- a/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java
+++ b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java
@@ -16,6 +16,7 @@
 import org.junit.jupiter.api.Test;
 import com.battery.device.api.DeviceAdapter;
 import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
 import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
 import com.battery.device.api.model.ChannelIdentity;
 import com.battery.device.api.model.DeviceControlCommand;
@@ -148,14 +149,12 @@
     }
 
     @Test
-    void doesNotInventStepCyclesCollectorOrPlanFacts()
+    void doesNotInventCollectorOrPlanFacts()
     {
         ScriptedPipe pipe = new ScriptedPipe();
         try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
         {
-            // 工步层要未验证的 downloadStepLayer;采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。
-            assertTrue(adapter.stepRecords(CHANNEL).isEmpty());
-            assertTrue(adapter.cycleRecords(CHANNEL).isEmpty());
+            // 采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。
             assertNull(adapter.latestCollector(ADAPTER, "collector-1"));
             assertNull(adapter.testPlan(CHANNEL));
             assertEquals(NewareBtsAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL));
@@ -178,6 +177,128 @@
                 assertThrows(UnsupportedOperationException.class, () -> adapter.control(CHANNEL, command));
             assertTrue(pipe.commands().isEmpty());
         }
+    }
+
+    @Test
+    void handsOutEveryStepAndCycleWhenTheChannelHasEnded()
+    {
+        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED);
+        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+        {
+            var steps = adapter.stepRecords(CHANNEL);
+            var cycles = adapter.cycleRecords(CHANNEL);
+
+            assertEquals(4, steps.size());
+            assertEquals("充电", steps.get(1).stepName());
+            // 循环号已按 0 起归一到与厂商库、逐点同口径(应答里的 cycleid 是 1 起)。
+            assertEquals(0, steps.get(0).cycleNo());
+
+            assertEquals(1, cycles.size());
+            var cycle = cycles.get(0);
+            assertEquals(0, cycle.cycleNo());
+            assertEquals(2.81630277777778, cycle.chargeCapacityAh(), 1e-9);
+            assertEquals(0.00431017035590278, cycle.dischargeCapacityAh(), 1e-12);
+            assertEquals(5.58447055555556, cycle.chargeEnergyWh(), 1e-9);
+            assertEquals(0.00426197048611111, cycle.dischargeEnergyWh(), 1e-12);
+            // 效率 = 放电容量 / 充电容量 × 100;搁置工步不进任何一侧。
+            assertEquals(0.153, cycle.efficiency(), 0.0005);
+            assertEquals("neware:316:cycle:0", cycle.sourceRecordId());
+            assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.sourceKind());
+        }
+    }
+
+    @Test
+    void holdsBackTheRunningStepAndItsCycleWhileTheTestIsStillRunning()
+    {
+        // 实测这条命令会把正在进行的那一步也交出来,而它的 endseqid 恰好等于已落盘行数
+        // (四个测试都相等),所以只能靠通道工作状态判断它有没有结束。
+        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING);
+        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+        {
+            var steps = adapter.stepRecords(CHANNEL);
+            var cycles = adapter.cycleRecords(CHANNEL);
+
+            // 最后一步(stepindex=4)与它所在的循环都不交出,等它结束再交。
+            assertEquals(3, steps.size());
+            assertEquals("neware:316:step:3", steps.get(2).sourceRecordId());
+            assertTrue(cycles.isEmpty());
+        }
+    }
+
+    @Test
+    void readsTheStepLayerOnceForBothStepsAndCycles()
+    {
+        // 工步与循环必须来自同一次读取:否则一个工步边界落在两次读取之间时,
+        // 循环合计会包含工步列表里没有的那一步,使循环记录每轮内容都不同。
+        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED);
+        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+        {
+            adapter.stepRecords(CHANNEL);
+            adapter.cycleRecords(CHANNEL);
+
+            assertEquals(1, pipe.commands().stream().filter("downloadsteplayer"::equals).count());
+        }
+    }
+
+    @Test
+    void aChannelWithNoTestInTheBufferHasNoStepsOrCycles()
+    {
+        ScriptedPipe pipe = new ScriptedPipe()
+                .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd><list count=\"0\" /></bts>");
+        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+        {
+            assertTrue(adapter.stepRecords(CHANNEL).isEmpty());
+            assertTrue(adapter.cycleRecords(CHANNEL).isEmpty());
+            // 缓冲里没有测试就直接是空,不必再去问工作状态。
+            assertEquals(List.of("downloadsteplayer"), pipe.commands());
+        }
+    }
+
+    @Test
+    void aFailedStateReadSkipsTheRoundInsteadOfHandingOutTheRunningStep()
+    {
+        // 读取失败不吞:调用方本来就有"来源读不到就本轮放弃、下一轮重试"的约定,
+        // 所以一次传输抖动不会变成"把一个还在跑的工步交出去",也不会被当成结论缓存下来。
+        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING).failing("inquire");
+        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+        {
+            assertThrows(RuntimeException.class, () -> adapter.stepRecords(CHANNEL));
+
+            pipe.recover();
+            // 恢复后重新读,拿到的是完整结论(在跑 → 交出 3 步、按住最后一步),不是上一次的空结果。
+            assertEquals(3, adapter.stepRecords(CHANNEL).size());
+        }
+    }
+
+    /** 一个已结束测试的工步层(现场实测原文,测试 316):4 步都在循环 1(归一后为 0)。 */
+    private static ScriptedPipe finishedTestPipe(DeviceWorkStatus workStatus)
+    {
+        return new ScriptedPipe()
+                .on("inquire", "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\""
+                        + " cycle_id=\"1\" step_id=\"4\" step_type=\"dc\" workstatus=\""
+                        + workStatus.name().toLowerCase(java.util.Locale.ROOT)
+                        + "\" current=\"0\" voltage=\"1.2\" /></list></bts>")
+                .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd>"
+                        + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" />"
+                        + "<list count=\"4\">"
+                        + "<data startseqid=\"1\" endseqid=\"2\" stepindex=\"1\" stepid=\"1\" cycleid=\"1\""
+                        + " steptype=\"rest\" steptime=\"23100\" endatime=\"2026-10-10 09:09:14\""
+                        + " startvolt=\"0.806611083984375\" endvolt=\"0.80638037109375\" startcurr=\"0\""
+                        + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
+                        + "<data startseqid=\"3\" endseqid=\"304\" stepindex=\"2\" stepid=\"2\" cycleid=\"1\""
+                        + " steptype=\"cc\" steptime=\"18000000\" endatime=\"2026-10-10 14:09:14\""
+                        + " startvolt=\"1.07542861328125\" endvolt=\"1.972108203125\""
+                        + " startcurr=\"0.563903076171875\" endcurr=\"0.563332702636719\""
+                        + " cap=\"2.81630277777778\" eng=\"5.58447055555556\" dcir=\"0\" />"
+                        + "<data startseqid=\"305\" endseqid=\"307\" stepindex=\"3\" stepid=\"3\" cycleid=\"1\""
+                        + " steptype=\"rest\" steptime=\"83600\" endatime=\"2026-10-10 14:10:38\""
+                        + " startvolt=\"1.7850955078125\" endvolt=\"1.42794287109375\" startcurr=\"0\""
+                        + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
+                        + "<data startseqid=\"308\" endseqid=\"309\" stepindex=\"4\" stepid=\"4\" cycleid=\"1\""
+                        + " steptype=\"dc\" steptime=\"27600\" endatime=\"2026-10-10 14:11:05\""
+                        + " startvolt=\"1.27452001953125\" endvolt=\"0.49604326171875\""
+                        + " startcurr=\"-0.562971618652344\" endcurr=\"-0.5622275390625\""
+                        + " cap=\"0.00431017035590278\" eng=\"0.00426197048611111\" dcir=\"0\" /></list></bts>");
     }
 
     private static DeviceAdapter adapter(ScriptedPipe pipe)
@@ -216,13 +337,27 @@
 
         private final Map<String, String> responses = new java.util.LinkedHashMap<>();
         private final List<String> commands = new ArrayList<>();
+        /** 让指定命令暂时失败,用来验"读取失败不吞、不当结论缓存"。 */
+        private final java.util.Set<String> failing = new java.util.LinkedHashSet<>();
         private int firstRow = 0;
         private int lastRow = 0;
 
         ScriptedPipe on(String command, String response)
         {
-            responses.put(command, response);
+            // 请求里的命令名按小写匹配(与生产传输的比对口径一致)。
+            responses.put(command.toLowerCase(java.util.Locale.ROOT), response);
             return this;
+        }
+
+        ScriptedPipe failing(String command)
+        {
+            failing.add(command.toLowerCase(java.util.Locale.ROOT));
+            return this;
+        }
+
+        void recover()
+        {
+            failing.clear();
         }
 
         /** 生成 {@code [first, last]} 的逐点行集,供 download/inquiredf 按窗口回答。 */
@@ -250,6 +385,8 @@
             Matcher matcher = COMMAND.matcher(request);
             if (!matcher.find()) throw new IllegalStateException("请求缺少命令:" + request);
             String command = matcher.group(1).toLowerCase(java.util.Locale.ROOT);
+            if (failing.contains(command))
+                throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT, "测试脚本让 " + command + " 失败", null);
             if ("inquiredf".equals(command))
             {
                 commands.add("inquiredf");
diff --git a/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java
index 9f007be..5e30636 100644
--- a/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java
+++ b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java
@@ -12,6 +12,7 @@
 import java.util.Set;
 import org.junit.jupiter.api.Test;
 import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
+import com.battery.device.api.TestDataObservationAdapter.StepRecord;
 import com.battery.device.api.model.ChannelIdentity;
 import com.battery.device.api.model.DeviceWorkStatus;
 import com.battery.device.api.model.RealtimeSnapshot;
@@ -237,6 +238,111 @@
     }
 
     @Test
+    void buildsTheStepLayerRequestWithoutTheOptionalElements()
+    {
+        String request = codec.downloadStepLayerRequest(CHANNEL);
+
+        assertTrue(request.contains("<cmd>downloadStepLayer</cmd>"));
+        assertTrue(request.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""));
+        assertTrue(request.contains("testid=\"0\""));
+        assertTrue(request.contains("dcir=\"0\""));
+    }
+
+    @Test
+    void parsesRealStepLayerWithNormalisedCycleAndDerivedStartTime()
+    {
+        // 现场实测原文(`26.117.165.166` 的 `25-81-1-1`,测试 316)。三处要点都在这个夹具里:
+        // cycleid 是 1 起要减一;只有 endatime,开始时间由 endatime − steptime 还原;
+        // stepindex 才是身份(stepid 在循环里会重复)。
+        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">"
+                + "<cmd>downloadStepLayer_resp</cmd>"
+                + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"1\" testid=\"316\" />"
+                + "<list count=\"4\">"
+                + "<data startseqid=\"1\" endseqid=\"2\" stepindex=\"1\" stepid=\"1\" cycleid=\"1\" steptype=\"rest\""
+                + " steptime=\"23100\" endatime=\"2026-10-10 09:09:14\" startvolt=\"0.806611083984375\""
+                + " endvolt=\"0.80638037109375\" startcurr=\"0\" endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
+                + "<data startseqid=\"3\" endseqid=\"304\" stepindex=\"2\" stepid=\"2\" cycleid=\"1\" steptype=\"cc\""
+                + " steptime=\"18000000\" endatime=\"2026-10-10 14:09:14\" startvolt=\"1.07542861328125\""
+                + " endvolt=\"1.972108203125\" startcurr=\"0.563903076171875\" endcurr=\"0.563332702636719\""
+                + " cap=\"2.81630277777778\" eng=\"5.58447055555556\" dcir=\"0\" />"
+                + "<data startseqid=\"305\" endseqid=\"307\" stepindex=\"3\" stepid=\"3\" cycleid=\"1\" steptype=\"rest\""
+                + " steptime=\"83600\" endatime=\"2026-10-10 14:10:38\" startvolt=\"1.7850955078125\""
+                + " endvolt=\"1.42794287109375\" startcurr=\"0\" endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
+                + "<data startseqid=\"308\" endseqid=\"309\" stepindex=\"4\" stepid=\"4\" cycleid=\"1\" steptype=\"dc\""
+                + " steptime=\"27600\" endatime=\"2026-10-10 14:11:05\" startvolt=\"1.27452001953125\""
+                + " endvolt=\"0.49604326171875\" startcurr=\"-0.562971618652344\" endcurr=\"-0.5622275390625\""
+                + " cap=\"0.00431017035590278\" eng=\"0.00426197048611111\" dcir=\"0\" /></list></bts>";
+
+        List<StepRecord> steps = codec.parseStepLayer(xml, Instant.parse("2026-10-10T14:12:00Z"));
+
+        assertEquals(4, steps.size());
+        StepRecord first = steps.get(0);
+        assertEquals(0, first.cycleNo());
+        assertEquals(1, first.stepNo());
+        assertEquals("rest", first.stepType());
+        assertEquals("搁置", first.stepName());
+        assertEquals("2026-10-10T01:09:14Z", first.endedAt().toString());
+        // 开始时间 = 结束时间 − 23100 ms;与该测试首行 atime(09:08:51 设备本地)对得上。
+        assertEquals("2026-10-10T01:08:50.900Z", first.startedAt().toString());
+        assertEquals("neware:316:step:1", first.sourceRecordId());
+        assertEquals("Asia/Hong_Kong", first.timezoneId());
+        assertEquals("2026-10-10 09:09:14", first.deviceTimeRaw());
+
+        StepRecord charge = steps.get(1);
+        assertEquals("充电", charge.stepName());
+        assertEquals(2.81630277777778, charge.capacityAh(), 1e-12);
+        assertEquals(5.58447055555556, charge.energyWh(), 1e-12);
+        assertEquals(1.972108203125, charge.endVoltageV(), 1e-12);
+        assertEquals(0.563332702636719, charge.endCurrentA(), 1e-12);
+        assertEquals(3, charge.measurements().get("startSeqId").intValue());
+        assertEquals(304, charge.measurements().get("endSeqId").intValue());
+
+        StepRecord discharge = steps.get(3);
+        assertEquals("放电", discharge.stepName());
+        assertEquals(0.00431017035590278, discharge.capacityAh(), 1e-15);
+    }
+
+    @Test
+    void leavesStepCapacityEmptyWhenTheDirectionIsUnknown()
+    {
+        // 方向未知时不给单项容量能量:cap 是单一值,方向没确认就不知道它属于哪一侧(ADR-0069 §3)。
+        String xml = "<bts><cmd>downloadStepLayer_resp</cmd>"
+                + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"1\" testid=\"9\" />"
+                + "<list count=\"1\"><data startseqid=\"1\" endseqid=\"9\" stepindex=\"1\" stepid=\"1\" cycleid=\"2\""
+                + " steptype=\"--\" steptime=\"60000\" endatime=\"2026-10-10 09:09:14\" startvolt=\"1\" endvolt=\"1\""
+                + " startcurr=\"0\" endcurr=\"0\" cap=\"1.5\" eng=\"2.5\" dcir=\"0\" /></list></bts>";
+
+        StepRecord step = codec.parseStepLayer(xml, Instant.EPOCH).get(0);
+
+        assertNull(step.stepName());
+        assertNull(step.capacityAh());
+        assertNull(step.energyWh());
+        assertEquals(1, step.cycleNo());
+        // 明细里保留的是设备原文事实(起始电压、首末序号),没有按方向分派的单项值。
+        assertEquals(1.0, step.measurements().get("startVoltage").doubleValue(), 1e-9);
+        assertEquals(9, step.measurements().get("endSeqId").intValue());
+    }
+
+    @Test
+    void reportsAnEmptyStepLayerInsteadOfFailing()
+    {
+        // 该通道的缓冲里没有测试时(实测 D 那台就是),应答是空列表而不是错误。
+        List<StepRecord> steps = codec.parseStepLayer(
+                "<bts><cmd>downloadStepLayer_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH);
+
+        assertTrue(steps.isEmpty());
+    }
+
+    @Test
+    void rejectsAStepLayerResponseToAnotherCommand()
+    {
+        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
+                () -> codec.parseStepLayer("<bts><cmd>inquire_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH));
+
+        assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, exception.reason());
+    }
+
+    @Test
     void keepsOnlyAttributesOutsideTheKnownFieldSetAsDynamicMeasurements()
     {
         // 字段集随客户端构建不同(8.0.0.484 有 barcode/auxtemp,8.0.0.530 没有,实测三种构建互不相同)。

--
Gitblit v1.7.1