yeg
4 天以前 6b99cc391064cb38c706979fafcecb9f50ea1587
新威直连补上工步层与循环汇总(downloadStepLayer 实机验证后落地)

ADR-0070 未决 1 说"downloadStepLayer 未验证,验证通过后本形态可交出工步层与循环汇总"。
2026-10-11 在四台机器上量过:C/E/F 可用(21 / 15 / 4 步),D 那台缓冲里没有测试、如实为空。

- 新增 downloadStepLayer 请求构造与解析;工步记录按既有契约交出。
- 循环号减一:这条命令的 cycleid 是 1 起,而逐点与厂商库是 0 起,实测同一测试两处对照确认。
- 开始时间 = endatime − steptime(应答只给结束时间,steptime 是工步运行时长),
两个独立样本与该测试首行 atime 核对一致。
- 工步身份用 stepindex:stepid 在循环里会重复(实测同一 stepid 在相邻两个循环各出现一次)。
- 未结束的那一步连它所在的循环一起按住:这条命令会把正在跑的那一步交出来,且它的 endseqid
恰好等于 inquiredf 的已落盘行数(四个测试全部相等),行数比不出结束没有,判据是通道工作状态。
交出未结束的段会让结束时间每轮都变,同一条记录反复以不同内容到达。
判断工作状态的读取失败不吞,让它传出去:调用方本来就有"来源读不到就本轮放弃、下一轮重试"
的约定,这样一次传输抖动不会变成"把一个还在跑的工步交出去",也不会被当成结论缓存。
- 循环汇总为聚合值(sourceKind=AGGREGATED),口径与库路线一致:方向未确认的容量能量不进任何一侧。
工步与循环来自同一次读取(5 秒窗口内复用),避免工步边界落在两次读取之间时两者对不上。
- 接口这一侧交不出的只剩辅助测量与计划方案两类,如实为空。

battery-device-bts 70 项通过;后端全量通过、BUILD SUCCESS。
4个文件已修改
501 ■■■■■ 已修改文件
battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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},按本实例的适配器编码生成检测柜通道清单。 */
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)));
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");
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 没有,实测三种构建互不相同)。