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