From 99fd4ccdf5d410e78a3785375036c34a2132ee04 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 11 十月 2026 01:51:41 +0800
Subject: [PATCH] 新威对外接口改为后端直连管道,撤销机器端取数程序
---
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java | 240 +++++++
battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java | 2
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsPipeTransportTest.java | 116 +++
config/vendor-credentials.properties.example | 23
battery-device-bts/src/main/java/com/battery/device/bts/BtsTransportException.java | 4
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterFactoryContractTest.java | 89 ++
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsReadOnlyCommands.java | 54 +
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsPipeTransport.java | 394 ++++++++++++
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java | 10
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapterFactory.java | 76 ++
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java | 254 ++++++++
scripts/register-neware-bts-sources.sql | 53 +
battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java | 236 ++++++
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java | 294 +++++++++
ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java | 4
battery-device-bts/src/main/java/com/battery/device/bts/BtsDeviceAdapter.java | 4
16 files changed, 1,815 insertions(+), 38 deletions(-)
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java
index 242b8d5..994915e 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java
@@ -39,14 +39,16 @@
* 适配器类型按存储形态划分;BTS 保留给开发模拟器与将来的协议路线(ADR-0065)。
* `NEWARE_MYSQL` 只能写字面量:battery-business 不依赖 battery-device-neware,
* 拿不到那边的常量;推式来源的实现就在本模块,所以用它的常量。
+ * `NEWARE_BTS` 是直连新威对外接口(命名管道)的形态,常量同样取不到,写字面量。
*/
- private static final List<String> SUPPORTED_TYPES = List.of("BTS", "NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE);
+ private static final List<String> SUPPORTED_TYPES =
+ List.of("BTS", "NEWARE_BTS", "NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE);
/**
- * 通道清单长期存在的来源,候选不按时间过期:直连厂商存储的来源读的是厂商的登记表,
- * 推式来源读的是取数程序上报的拓扑——两者都不是"轮询发现"的结果。
+ * 通道清单长期存在的来源,候选不按时间过期:直连厂商存储与直连厂商接口的来源读的都是厂商的登记事实,
+ * 推式来源读的是取数程序上报的拓扑——三者都不是"轮询发现"的结果。
*/
private static final List<String> PERSISTENT_CANDIDATE_TYPES =
- List.of("NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE);
+ List.of("NEWARE_BTS", "NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE);
private final TDeviceAdapterMapper mapper;
private final DeviceAdapterRegistry registry;
diff --git a/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java b/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java
index 692233d..c86515b 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java
@@ -59,6 +59,8 @@
case "TIMEOUT" -> ObservationFailureReason.TIMEOUT;
case "DISCONNECTED" -> ObservationFailureReason.DISCONNECTED;
case "UNSUPPORTED" -> ObservationFailureReason.UNSUPPORTED;
+ // 只读白名单拒绝(ADR-0068)不是"传输坏了",而是这条请求本就不该发:归为不支持。
+ case "READ_ONLY_REJECTED" -> ObservationFailureReason.UNSUPPORTED;
case "MALFORMED_XML", "MISSING_FIELD", "INVALID_VALUE", "IDENTITY_MISMATCH",
"UNEXPECTED_COMMAND" -> ObservationFailureReason.PARSE_FAILED;
default -> ObservationFailureReason.TRANSPORT_FAILED;
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/BtsDeviceAdapter.java b/battery-device-bts/src/main/java/com/battery/device/bts/BtsDeviceAdapter.java
index f18583f..b934ca6 100644
--- a/battery-device-bts/src/main/java/com/battery/device/bts/BtsDeviceAdapter.java
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/BtsDeviceAdapter.java
@@ -57,7 +57,9 @@
public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
{
if (channels == null || channels.isEmpty()) return Map.of();
- return codec.parseSnapshots(transport.exchange(codec.inquireRequest(channels, false)), Instant.now());
+ // 带上请求集合:服务端会改写应答里的 devtype,按请求的身份归集才拿得到自己要的通道。
+ return codec.parseSnapshots(transport.exchange(codec.inquireRequest(channels, false)), channels,
+ Instant.now());
}
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/BtsTransportException.java b/battery-device-bts/src/main/java/com/battery/device/bts/BtsTransportException.java
index 2146284..a5a51ea 100644
--- a/battery-device-bts/src/main/java/com/battery/device/bts/BtsTransportException.java
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/BtsTransportException.java
@@ -16,7 +16,9 @@
DISCONNECTED,
RESPONSE_TOO_LARGE,
INVALID_UTF8,
- IO_FAILURE
+ IO_FAILURE,
+ /** 请求不在只读命令白名单内,在代码层被拒绝发出(ADR-0068)。 */
+ READ_ONLY_REJECTED
}
private final Reason reason;
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 16c5b49..9ae7c8f 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
@@ -2,6 +2,7 @@
import java.io.StringReader;
import java.math.BigDecimal;
+import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -23,6 +24,8 @@
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.helpers.DefaultHandler;
+import com.battery.device.api.StepDirectionResolver;
+import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DeviceConnectionStatus;
import com.battery.device.api.model.DetectedChannel;
@@ -35,13 +38,20 @@
public final class BtsXmlCodec
{
private static final DateTimeFormatter BTS_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss");
+ /** {@code download} 的 {@code atime} 用空格分隔(与 {@code inquire} 的点号形式不同)。 */
+ private static final DateTimeFormatter BTS_DATA_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final Set<String> SNAPSHOT_ATTRIBUTES = Set.of(
"dev", "cycle_id", "step_id", "step_type", "workstatus", "barcode", "current", "voltage",
"capacity", "energy", "totaltime", "relativetime", "auxtemp", "auxvol", "open_or_close", "log_code",
"device_time", "deviceTime", "time", "atime", "testtime");
+ /** 单次 {@code download} 一页的行数上限;协议文档写 {@code count} 不超过 1000。 */
+ public static final int MAX_DOWNLOAD_PAGE = 1000;
+ /** 增量水位与逐点读取用的命令名;实测应答把 {@code download} 拼成了 {@code donwload}。 */
+ private static final Set<String> DOWNLOAD_COMMANDS = Set.of("download_resp", "donwload_resp");
private final String adapterCode;
private final ZoneId deviceZone;
+ private final StepDirectionResolver directions = new BtsStepDirectionResolver();
public BtsXmlCodec(String adapterCode, ZoneId deviceZone)
{
@@ -56,7 +66,15 @@
return envelope("<cmd>getdevinfo</cmd>");
}
- /** 构造按通道批量查询实时快照的请求报文({@code inquire});只接受属于本实例的通道身份。 */
+ /** 返回解释设备时间用的现场时区标识,供逐点行按来源保存时区。 */
+ public String timezoneId()
+ {
+ return deviceZone.getId();
+ }
+
+ /**
+ * 构造按通道批量查询实时快照的请求报文({@code inquire});只接受属于本实例的通道身份。
+ */
public String inquireRequest(Set<ChannelIdentity> channels, boolean includeBarcode)
{
StringBuilder list = new StringBuilder("<cmd>inquire</cmd><list count=\"").append(channels.size()).append("\">");
@@ -71,6 +89,106 @@
}
return envelope(list.append("</list>").toString());
}
+
+ /**
+ * 构造通道数据文件状态的请求报文({@code inquiredf}):返回该通道当前测试号与已落盘行数。
+ *
+ * <p>{@code testid="0"} 表示"该通道最近一次测试",实测应答会把真实测试号回显回来。
+ * 行数随测试进行实时增长,因此它是逐点接收的增量水位。
+ */
+ public String inquiredfRequest(ChannelIdentity channel)
+ {
+ requireOwned(channel);
+ return envelope("<cmd>inquiredf</cmd><list count=\"1\"><chl devtype=\"" + channel.devtype()
+ + "\" devid=\"" + channel.devid() + "\" subdevid=\"" + channel.subdevid()
+ + "\" chlid=\"" + channel.chlid() + "\" testid=\"0\"/></list>");
+ }
+
+ /**
+ * 构造逐点下载的请求报文({@code download}),分页取 {@code [startPosition, …]} 起 {@code count} 行。
+ *
+ * <p>{@code testid="0"} 由厂商解释为"该通道最近一次测试";实测应答回显真实测试号,
+ * 所以调用方不必先知道测试号。{@code startPosition} 从 1 起且等于行的 {@code seqid}。
+ */
+ public String downloadRequest(ChannelIdentity channel, long testId, long startPosition, int count)
+ {
+ requireOwned(channel);
+ if (startPosition < 1) throw new IllegalArgumentException("startPosition must be positive");
+ if (count < 1 || count > MAX_DOWNLOAD_PAGE)
+ throw new IllegalArgumentException("count must be between 1 and " + MAX_DOWNLOAD_PAGE);
+ return envelope("<cmd>download</cmd><download devtype=\"" + channel.devtype()
+ + "\" devid=\"" + channel.devid() + "\" subdevid=\"" + channel.subdevid()
+ + "\" chlid=\"" + channel.chlid() + "\" auxid=\"0\" testid=\"" + testId
+ + "\" startpos=\"" + startPosition + "\" count=\"" + count + "\"/>");
+ }
+
+ /**
+ * 解析 {@code inquiredf_resp},取出该通道当前测试号与已落盘行数。
+ *
+ * <p>现场实测应答形如
+ * {@code <chl devtype="25" devid="81" subdevid="1" chlid="2" testid="316" count="637">false</chl>}。
+ * 末尾的布尔值是厂商自报的"该测试数据是否已传完";{@code false} 表示数据尚不完整,
+ * 调用方**不得把这段数据当完整事实**(ADR-0068 未决 5:实测有历史测试只覆盖约 1 天却报 {@code false})。
+ *
+ * @param xml 应答原文
+ * @return 该通道的数据文件状态
+ * @throws BtsProtocolException 应答不是 {@code inquiredf_resp},或没有该通道的记录
+ */
+ public DataFileState parseDataFileState(String xml)
+ {
+ Document document = parse(xml, "inquiredf_resp");
+ Element chl = firstElement(document, "chl");
+ return new DataFileState(toLong(requiredAttribute(chl, "testid"), "testid"),
+ toLong(requiredAttribute(chl, "count"), "count"),
+ Boolean.parseBoolean(chl.getTextContent().trim()));
+ }
+
+ /**
+ * 解析 {@code download_resp}(厂商固件拼作 {@code donwload_resp})为逐点行。
+ *
+ * <p>每条 {@code <data/>} 是设备采样行原文:{@code seqid}/{@code stepid}/{@code cycleid}/
+ * {@code steptype}/{@code testtime}/{@code atime}/{@code volt}/{@code curr}/{@code cap}/{@code eng}。
+ * 与 {@code inquire} 相比,这里的测量值是**全精度**、且带**绝对时间**,所以测量值与时间轴取这里。
+ *
+ * <p>约定与实测口径:{@code testtime} 是毫秒(换成秒存的相对时长);{@code cycleid} 是 0 起
+ * (与库里的 {@code cycle} 一致,而 {@code inquire} 的 {@code cycle_id} 是 1 起);
+ * {@code volt}/{@code curr}/{@code cap}/{@code eng} 已是 V/A/Ah/Wh 物理单位,不做换算。
+ * 每行身份 = {@code 测试号:seqid}({@code seqid} 在每个测试内从 1 重编,只按它会在两次测试间撞键)。
+ *
+ * @param xml 应答原文
+ * @param receivedAt 服务器接收时间
+ * @return 按应答顺序的逐点行;该页没有数据时为空列表
+ * @throws BtsProtocolException 应答不是 download 命令的应答
+ */
+ public List<CabinetRealtime> parseDownload(String xml, Instant receivedAt)
+ {
+ Document document = parse(xml, DOWNLOAD_COMMANDS.toArray(String[]::new));
+ NodeList nodes = document.getElementsByTagName("data");
+ if (nodes.getLength() == 0) return List.of();
+ // 测试号取自应答回显(请求发的 0 会被换成真实测试号),它和 seqid 一起构成行的稳定身份。
+ Element header = optionalElement(document, "download");
+ String testId = header == null ? "" : optionalAttribute(header, "testid");
+ String zoneId = deviceZone.getId();
+ List<CabinetRealtime> rows = new ArrayList<>(nodes.getLength());
+ for (int i = 0; i < nodes.getLength(); i++)
+ {
+ Element data = (Element) nodes.item(i);
+ String stepType = optionalAttribute(data, "steptype");
+ String rawTime = requiredAttribute(data, "atime");
+ long sequenceId = toLong(requiredAttribute(data, "seqid"), "seqid");
+ rows.add(new CabinetRealtime(sequenceId, parseDataTime(rawTime),
+ doubleValue(data, "curr"), doubleValue(data, "volt"), doubleValue(data, "cap"),
+ doubleValue(data, "eng"), optionalInteger(data, "cycleid") == null ? 0 : optionalInteger(data, "cycleid"),
+ optionalInteger(data, "stepid") == null ? 0 : optionalInteger(data, "stepid"),
+ directions.resolve(stepType).displayName(), stepType, receivedAt, rawTime, zoneId,
+ millisecondsToSeconds(optionalDecimal(data, "testtime")),
+ testId.isBlank() ? null : testId + ":" + sequenceId));
+ }
+ return List.copyOf(rows);
+ }
+
+ /** 该通道的数据文件状态:测试号、已落盘行数、厂商自报的数据是否完整。 */
+ public record DataFileState(long testId, long count, boolean complete) { }
@@ -95,11 +213,38 @@
return List.copyOf(channels);
}
- /** 解析 {@code inquire_resp} 并按通道身份归集实时快照;{@code receivedAt} 记为服务器接收时间。 */
+ /**
+ * 解析 {@code inquire_resp} 并按通道身份归集实时快照;{@code receivedAt} 记为服务器接收时间。
+ *
+ * <p>身份取应答里的 {@code dev}。只有不带请求上下文的调用方该用它——服务端**会改写 {@code devtype}**
+ * (实测第四台:{@code getdevinfo} 报 {@code 27-76},{@code inquire} 却答 {@code dev="25-76-1-1-0"}),
+ * 所以按它归集会与调用方请求的身份对不上。带请求集合的调用请用
+ * {@link #parseSnapshots(String, Set, Instant)}。
+ */
public Map<ChannelIdentity, RealtimeSnapshot> parseSnapshots(String xml, Instant receivedAt)
+ {
+ return parseSnapshots(xml, null, receivedAt);
+ }
+
+ /**
+ * 解析 {@code inquire_resp},并把结果归到**请求时用的通道身份**上。
+ *
+ * <p>归集按「{@code devid} + {@code subdevid} + {@code chlid}」匹配:服务端会改写 {@code devtype}
+ * (设备类型是服务端侧的登记事实,与它回报的取值不总一致),而单元与通道号在应答里是稳的。
+ * 这使调用方用自己登记的身份就能取回快照,不会因为服务端改写设备类型而拿到空值。
+ *
+ * @param xml 应答原文
+ * @param requested 本次请求的通道集合;为空时按应答自己的身份归集
+ * @param receivedAt 服务器接收时间
+ * @return 按通道身份归集的实时快照
+ */
+ public Map<ChannelIdentity, RealtimeSnapshot> parseSnapshots(String xml, Set<ChannelIdentity> requested,
+ Instant receivedAt)
{
Document document = parse(xml, "inquire_resp");
NodeList nodes = firstElement(document, "list").getElementsByTagName("inquire");
+ Map<String, ChannelIdentity> requestedByVendorKey = new HashMap<>();
+ if (requested != null) requested.forEach(channel -> requestedByVendorKey.put(vendorKey(channel), channel));
Map<ChannelIdentity, RealtimeSnapshot> snapshots = new LinkedHashMap<>();
for (int i = 0; i < nodes.getLength(); i++)
{
@@ -108,23 +253,30 @@
if (device.length < 4)
throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
"Invalid BTS dev identity: " + element.getAttribute("dev"));
- ChannelIdentity identity = new ChannelIdentity(adapterCode, integer(device[0], "devtype"), integer(device[1], "devid"),
- integer(device[2], "subdevid"), integer(device[3], "chlid"));
+ ChannelIdentity reported = new ChannelIdentity(adapterCode, integer(device[0], "devtype"),
+ integer(device[1], "devid"), integer(device[2], "subdevid"), integer(device[3], "chlid"));
+ ChannelIdentity identity = requestedByVendorKey.getOrDefault(vendorKey(reported), reported);
Instant deviceTime = optionalDeviceTime(element);
snapshots.put(identity, new RealtimeSnapshot(identity,
DeviceWorkStatus.fromBts(element.getAttribute("workstatus")), optionalInteger(element, "cycle_id"),
optionalInteger(element, "step_id"), optionalAttribute(element, "step_type"), optionalAttribute(element, "barcode"),
optionalDecimal(element, "current"), optionalDecimal(element, "voltage"), optionalDecimal(element, "capacity"),
- optionalDecimal(element, "energy"), optionalLong(element, "totaltime"), optionalLong(element, "relativetime"),
+ optionalDecimal(element, "energy"), optionalSeconds(element, "totaltime"), optionalSeconds(element, "relativetime"),
optionalAttribute(element, "log_code"), receivedAt, dynamicMeasurements(element, SNAPSHOT_ATTRIBUTES),
DeviceConnectionStatus.ONLINE, deviceTime, parsePlannedSteps(element), null));
}
return Map.copyOf(snapshots);
}
+ /** 匹配实时快照用的键:不含适配器与设备类型,只取单元与通道号。 */
+ private static String vendorKey(ChannelIdentity channel)
+ {
+ return channel.devid() + "-" + channel.subdevid() + "-" + channel.chlid();
+ }
- private Document parse(String xml, String expectedCommand)
+
+ private Document parse(String xml, String... expectedCommands)
{
try
{
@@ -140,9 +292,9 @@
builder.setErrorHandler(new DefaultHandler());
Document document = builder.parse(new InputSource(new StringReader(xml)));
String command = firstElement(document, "cmd").getTextContent().trim();
- if (!expectedCommand.equals(command))
+ if (!Set.of(expectedCommands).contains(command))
throw new BtsProtocolException(BtsProtocolException.Reason.UNEXPECTED_COMMAND,
- "Expected BTS command " + expectedCommand + " but received " + command);
+ "Expected BTS command " + List.of(expectedCommands) + " but received " + command);
return document;
}
catch (BtsProtocolException ex)
@@ -283,6 +435,13 @@
return (Element) elements.item(0);
}
+ /** 可缺省的元素:{@code download} 在空应答里不出现(只有 {@code <list count="0"/>})。 */
+ private static Element optionalElement(Document document, String tag)
+ {
+ NodeList elements = document.getElementsByTagName(tag);
+ return elements.getLength() == 0 ? null : (Element) elements.item(0);
+ }
+
private static String requiredAttribute(Element element, String name)
{
String value = element.getAttribute(name);
@@ -302,32 +461,42 @@
return integer(requiredAttribute(element, name), name);
}
- private static long longAttribute(Element element, String name)
- {
- try
- {
- return Long.parseLong(requiredAttribute(element, name));
- }
- catch (NumberFormatException ex)
- {
- throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
- "Invalid integer BTS attribute: " + name, ex);
- }
- }
-
private static Integer optionalInteger(Element element, String name)
{
String value = optionalAttribute(element, name);
return value == null ? null : integer(value, name);
}
- private static Long optionalLong(Element element, String name)
+ /**
+ * 解析以**秒**为单位、但可能带小数的属性({@code totaltime}/{@code relativetime})。
+ *
+ * <p>实测应答给的是 {@code totaltime="37737.6"} 这种小数秒,早期实现按整数解析会在这里
+ * 直接判成非法取值而使整次观测失败——所以这里按十进制读,四舍五入到秒。
+ */
+ private static Long optionalSeconds(Element element, String name)
{
- String value = optionalAttribute(element, name);
- if (value == null) return null;
+ BigDecimal value = optionalDecimal(element, name);
+ return value == null ? null : value.setScale(0, RoundingMode.HALF_UP).longValue();
+ }
+
+ /** 毫秒转秒;{@code download} 的 {@code testtime} 单位是毫秒,契约里的相对时长是秒。 */
+ private static Long millisecondsToSeconds(BigDecimal milliseconds)
+ {
+ return milliseconds == null ? null : milliseconds.divide(BigDecimal.valueOf(1000), 0, RoundingMode.HALF_UP).longValue();
+ }
+
+ /** 逐点行的测量值:缺失时按 0 读(契约里是基本类型,缺失只在报文异常时出现)。 */
+ private static double doubleValue(Element element, String name)
+ {
+ BigDecimal value = optionalDecimal(element, name);
+ return value == null ? 0d : value.doubleValue();
+ }
+
+ private static long toLong(String value, String name)
+ {
try
{
- return Long.parseLong(value);
+ return Long.parseLong(value.trim());
}
catch (NumberFormatException ex)
{
@@ -336,6 +505,23 @@
}
}
+ /**
+ * 解析逐点行的设备绝对时间。{@code download} 的 {@code atime} 形如 {@code 2026-10-10 23:50:28},
+ * 按适配器配置的现场时区解释后转 UTC;原文由调用方另行保留。
+ */
+ private Instant parseDataTime(String value)
+ {
+ try
+ {
+ return LocalDateTime.parse(value, BTS_DATA_TIME).atZone(deviceZone).toInstant();
+ }
+ catch (DateTimeParseException ex)
+ {
+ throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE,
+ "Invalid BTS data time: " + value, ex);
+ }
+ }
+
private static int integer(String value, String name)
{
try
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
new file mode 100644
index 0000000..a32c471
--- /dev/null
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java
@@ -0,0 +1,240 @@
+package com.battery.device.bts;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+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.RealtimeSnapshot;
+
+/**
+ * 新威 BTS 对外接口的只读适配器:走远程命名管道,一台上位机一个实例(ADR-0068)。
+ *
+ * <p>它同时实现观测契约与测试数据观测契约,使这条路线直接接上既有的接收与展示路径,
+ * 不需要在业务层为新威另开一条路。各命令的分工是现场实测定下来的:
+ *
+ * <ul>
+ * <li>{@code getdevinfo} → 通道登记事实(发现);</li>
+ * <li>{@code inquire} → 实时值与**通道工作状态**(读库路线拿不到工作状态,这是本路线的主要收获);</li>
+ * <li>{@code inquiredf} → 当前测试号与已落盘行数,后者是逐点接收的增量水位;</li>
+ * <li>{@code download} → 逐点行。它给全精度测量值**和绝对时间**,所以测量值与时间轴取它,
+ * 而不取 {@code inquire}(后者是 4 位小数、且没有绝对时间)。</li>
+ * </ul>
+ *
+ * <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} 不带计划),
+ * 方案来自方案文件与厂商库表,因此这里不提供。</li>
+ * </ul>
+ *
+ * <p><b>空闲通道的读数不进本适配器的持久化路径</b>:实测空闲通道的 {@code inquire} 电压是悬空负值
+ * (≈ −2.4 / −3.4 V),而该通道库内从无负值。逐点行({@code download})只在测试进行中有意义,
+ * 所以按行接收天然只覆盖测试期;实时值本身仍可用于展示。
+ */
+public final class NewareBtsAdapter implements DeviceAdapter, TestDataObservationAdapter
+{
+ /** 该存储形态的适配器类型标识,登记实例时用它匹配本工厂。 */
+ public static final String ADAPTER_TYPE = "NEWARE_BTS";
+ /** 厂商采样周期实测为 60 秒;实时持久化槽位按它对齐,取数不必更快。 */
+ public static final long SAMPLE_INTERVAL_MS = 60_000L;
+
+ private final String adapterCode;
+ private final BtsTransport transport;
+ private final BtsXmlCodec codec;
+ private final StepDirectionResolver directions = new BtsStepDirectionResolver();
+ private final AdapterCapabilities capabilities;
+
+ public NewareBtsAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec)
+ {
+ this(adapterCode, transport, codec, new AdapterCapabilities(true, true, true, false));
+ }
+
+ public NewareBtsAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec,
+ AdapterCapabilities capabilities)
+ {
+ this.adapterCode = adapterCode;
+ this.transport = transport;
+ this.codec = codec;
+ this.capabilities = capabilities;
+ }
+
+ /** 返回本实例登记的适配器编码。 */
+ @Override
+ public String adapterCode()
+ {
+ return adapterCode;
+ }
+
+ /** 返回本实例登记的能力;本路线可发现、可观测实时值、可下载历史逐点,且始终不控制。 */
+ @Override
+ public AdapterCapabilities capabilities()
+ {
+ return capabilities;
+ }
+
+ /** 通过 {@code getdevinfo} 读取通道登记事实,作为发现候选返回。 */
+ @Override
+ public List<DetectedChannel> discoverChannels()
+ {
+ return codec.parseDeviceInfo(transport.exchange(codec.getDeviceInfoRequest()));
+ }
+
+ /** 按给定通道集合请求一次实时状态;通道为空时不发请求。 */
+ @Override
+ public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
+ {
+ if (channels == null || channels.isEmpty()) return Map.of();
+ // 带上请求集合:实测服务端会改写应答里的 devtype,按请求的身份归集才拿得到自己要的通道。
+ return codec.parseSnapshots(transport.exchange(codec.inquireRequest(channels, false)), channels,
+ Instant.now());
+ }
+
+ /**
+ * 返回该通道最近一次实时观测。
+ *
+ * <p>{@code inquire} 不带绝对时间与协议序号,所以这里的时间口径是**服务器接收时间**,
+ * 序号为 0;需要设备时间与真实序号的场合取 {@link #samplesSince}。
+ */
+ @Override
+ public CabinetRealtime latestCabinet(ChannelIdentity channel)
+ {
+ RealtimeSnapshot snapshot = inquire(Set.of(channel)).get(channel);
+ if (snapshot == null) return null;
+ return new CabinetRealtime(0L, snapshot.deviceTime(), value(snapshot.currentA()), value(snapshot.voltageV()),
+ value(snapshot.capacityAh()), value(snapshot.energyWh()),
+ snapshot.cycleNo() == null ? 0 : snapshot.cycleNo(),
+ snapshot.stepNo() == null ? 0 : snapshot.stepNo(),
+ directions.resolve(snapshot.stepType()).displayName(), snapshot.stepType(), snapshot.receivedAt(),
+ null, codec.timezoneId(), snapshot.relativeTimeSeconds(), null);
+ }
+
+ /** 本路线的辅助测量不可得(ADR-0068 已复核),返回空值而不是零值。 */
+ @Override
+ public CollectorRealtime latestCollector(String adapterCode, String collectorCode)
+ {
+ return null;
+ }
+
+ /** 本路线的工步层需要未验证的 {@code downloadStepLayer},因此不交出工步统计。 */
+ @Override
+ public List<StepRecord> stepRecords(ChannelIdentity channel)
+ {
+ return List.of();
+ }
+
+ /** 本路线的循环汇总同上:不交出,也不用实时行推导来源没有的汇总。 */
+ @Override
+ public List<CycleRecord> cycleRecords(ChannelIdentity channel)
+ {
+ return List.of();
+ }
+
+ /** 返回厂商采样周期作为实时持久化槽位间隔。 */
+ @Override
+ public long persistenceIntervalMillis(ChannelIdentity channel)
+ {
+ return SAMPLE_INTERVAL_MS;
+ }
+
+ /**
+ * 读取该通道当前的接收水位:{@code inquiredf} 给测试号与已落盘行数,
+ * 再用 {@code download} 取最后一行拿到它的设备时间。
+ *
+ * <p>行数就是最后一行的 {@code seqid}(实测一致),所以水位是"行数那一行"。
+ * 该通道当前没有行时水位为 {@code (空时间, 0)}:契约规定空时间表示"这一刻来源里没有任何可用行,
+ * 之后出现的行全部属于本次接收"。
+ *
+ * <p>客户端不提供 {@code inquiredf} 时(实测 7.6.0.1475 无此命令)返回空值,
+ * 表示本来源不按设备数据行接收,调用方回落到轮询槽位——这不是失败,是该构建的真实能力。
+ */
+ @Override
+ public SampleWatermark currentWatermark(ChannelIdentity channel)
+ {
+ BtsXmlCodec.DataFileState state;
+ try
+ {
+ state = dataFileState(channel);
+ }
+ catch (BtsProtocolException unsupported)
+ {
+ return null;
+ }
+ if (state.count() < 1) return new SampleWatermark(null, 0L);
+ List<CabinetRealtime> tail = download(channel, state.testId(), state.count(), 1);
+ if (tail.isEmpty()) return new SampleWatermark(null, 0L);
+ CabinetRealtime last = tail.get(0);
+ return new SampleWatermark(last.deviceTime(), last.sequenceId());
+ }
+
+ /**
+ * 返回严格晚于水位的逐点行,按 {@code startpos} 分页取到已落盘行数为止。
+ *
+ * <p>{@code seqid} 在每个测试内从 1 重编:水位比现有行数还大说明该通道换了测试,
+ * 此时从第 1 行重读,并按设备时间把旧测试的行过掉(新测试的行时间必然晚于旧水位)。
+ *
+ * @param channel 检测柜通道身份
+ * @param watermark 上次取得的水位;为空表示尚未取水位,返回空列表而不并入设备历史
+ */
+ @Override
+ public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark)
+ {
+ if (watermark == null) return List.of();
+ BtsXmlCodec.DataFileState state = dataFileState(channel);
+ if (state.count() < 1) return List.of();
+ long next = watermark.sequenceId() >= state.count() ? 1L : watermark.sequenceId() + 1L;
+ List<CabinetRealtime> rows = new ArrayList<>();
+ while (next <= state.count())
+ {
+ int page = (int) Math.min(BtsXmlCodec.MAX_DOWNLOAD_PAGE, state.count() - next + 1);
+ List<CabinetRealtime> fetched = download(channel, state.testId(), next, page);
+ if (fetched.isEmpty()) break;
+ rows.addAll(fetched);
+ if (fetched.size() < page) break;
+ next = fetched.get(fetched.size() - 1).sequenceId() + 1L;
+ }
+ return rows.stream().filter(row -> isAfter(watermark, row)).toList();
+ }
+
+ /** 关闭底层传输连接。 */
+ @Override
+ public void close()
+ {
+ transport.close();
+ }
+
+ private BtsXmlCodec.DataFileState dataFileState(ChannelIdentity channel)
+ {
+ return codec.parseDataFileState(transport.exchange(codec.inquiredfRequest(channel)));
+ }
+
+ private List<CabinetRealtime> download(ChannelIdentity channel, long testId, long startPosition, int count)
+ {
+ return codec.parseDownload(transport.exchange(codec.downloadRequest(channel, testId, startPosition, count)),
+ Instant.now());
+ }
+
+ /** 行的序号比水位大就是要的行;序号反而更小说明换了测试,此时按设备时间判定。 */
+ private static boolean isAfter(SampleWatermark watermark, CabinetRealtime row)
+ {
+ if (row.sequenceId() > watermark.sequenceId()) return true;
+ return watermark.deviceTime() != null && row.deviceTime() != null
+ && row.deviceTime().isAfter(watermark.deviceTime());
+ }
+
+ private static double value(BigDecimal source)
+ {
+ return source == null ? 0d : source.doubleValue();
+ }
+}
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapterFactory.java b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapterFactory.java
new file mode 100644
index 0000000..e7148fb
--- /dev/null
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapterFactory.java
@@ -0,0 +1,76 @@
+package com.battery.device.bts;
+
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.ZoneId;
+import java.util.Map;
+import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.DeviceAdapterException;
+import com.battery.device.api.credentials.LocalCredentials;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+import com.battery.device.api.registry.DeviceAdapterFactory;
+
+/**
+ * 按存储形态 {@code NEWARE_BTS} 构造**直连新威对外接口**的只读适配器实例(ADR-0068)。
+ *
+ * <p>与 {@code NEWARE_MYSQL}(直连厂商库)并列:这台机器能不能远程取数,取决于它的上位机形态,
+ * 而不是厂商。8.0 形态的库内没有逐点数据,所以它的逐点只能从接口的 {@code download} 取。
+ *
+ * <p><b>为什么口令不进数据库</b>:这里要的是那台机器的**本地管理员口令**——它和厂商库口令一样是来源凭据,
+ * 因此共用同一套取值顺序(本地凭据文件 → 同名环境变量 → 同名 JVM 启动参数,见
+ * {@link LocalCredentials}),{@code source_config} 里只放变量名。三者都没有时装配失败,
+ * 错误信息同时点名文件路径与变量名,使"忘了放口令"是可见的失败而不是沉默的缺失。
+ *
+ * <p>本类不缓存实例:实例由 {@code DeviceAdapterRegistry} 持有并按实例串行执行。
+ */
+public final class NewareBtsAdapterFactory implements DeviceAdapterFactory
+{
+ /** 实例配置里承载"本机管理员账号变量名"的键。 */
+ public static final String USER_ENV_OPTION = "userEnv";
+ /** 实例配置里承载"本机管理员口令变量名"的键。 */
+ public static final String PASSWORD_ENV_OPTION = "passwordEnv";
+ public static final String DEFAULT_USER_ENV = "NEWARE_WIN_USER";
+ /** 建 SMB 会话是本地进程操作,超时给短一些:卡住也没有数据可取。 */
+ private static final Duration SESSION_TIMEOUT = Duration.ofSeconds(10);
+ /** 读应答超时;{@code download} 一页上千行在 SMB 上会慢一些,但远不到这个量级。 */
+ private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(60);
+ /** 凭据文件相对进程工作目录定位,使开发(仓库根)与交付(应用目录)用同一个位置。 */
+ static final Path CREDENTIALS_FILE = LocalCredentials.DEFAULT_FILE;
+
+ /** 返回本形态的适配器类型标识 {@code NEWARE_BTS}。 */
+ @Override
+ public String adapterType()
+ {
+ return NewareBtsAdapter.ADAPTER_TYPE;
+ }
+
+ /** 从实例配置取账号与口令来源,装配远程命名管道传输与报文编解码器。 */
+ @Override
+ public DeviceAdapter create(AdapterInstanceConfig config)
+ {
+ Map<String, String> sourceConfig = config.sourceConfig();
+ String user = LocalCredentials.resolve(option(sourceConfig, USER_ENV_OPTION, DEFAULT_USER_ENV),
+ CREDENTIALS_FILE.toAbsolutePath(), System.getenv(), System::getProperty);
+ String password = LocalCredentials.resolve(required(sourceConfig, PASSWORD_ENV_OPTION),
+ CREDENTIALS_FILE.toAbsolutePath(), System.getenv(), System::getProperty);
+ NewareBtsPipeTransport transport = new NewareBtsPipeTransport(config.host(), user, password,
+ SESSION_TIMEOUT, RESPONSE_TIMEOUT);
+ return new NewareBtsAdapter(config.adapterCode(), transport,
+ new BtsXmlCodec(config.adapterCode(), ZoneId.of(config.timezoneId())), config.capabilities());
+ }
+
+ private static String option(Map<String, String> sourceConfig, String key, String fallback)
+ {
+ String value = sourceConfig.get(key);
+ return value == null || value.isBlank() ? fallback : value.trim();
+ }
+
+ /** 口令变量名没有缺省值:五台机器的口令实测有两种,猜一个就会连错机器。 */
+ private static String required(Map<String, String> sourceConfig, String key)
+ {
+ String value = option(sourceConfig, key, null);
+ if (value == null) throw new DeviceAdapterException("新威 BTS 实例缺少配置项 " + key
+ + "(本机管理员口令的变量名):口令有两种取值,不能有缺省");
+ return value;
+ }
+}
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsPipeTransport.java b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsPipeTransport.java
new file mode 100644
index 0000000..1d1bc0e
--- /dev/null
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsPipeTransport.java
@@ -0,0 +1,394 @@
+package com.battery.device.bts;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 新威 BTS 对外接口的**远程命名管道**传输(ADR-0068)。
+ *
+ * <p>现场实测的三条关键事实决定了这个类的形状:
+ *
+ * <ol>
+ * <li><b>接口是命名管道,不是 TCP。</b>协议文档的 TCP 小节在现场没有对应物;
+ * 可达的是服务端命名管道 {@code \\.\pipe\NewareBtsAPI}。跨机访问它要走 SMB,
+ * 所以这里用 {@code \\<主机>\pipe\NewareBtsAPI} 这个 Win32 路径,
+ * 并在打开管道前先建一条 SMB 会话。</li>
+ * <li><b>一条连接只服务一条请求。</b>实测在同一条连接上发第二条命令会永久挂住
+ * (不报错、不返回)。所以每次请求都新建连接、收到应答即关闭,不做长连接复用。</li>
+ * <li><b>报文结束符是纯 {@code \n\n}。</b>文档里的 {@code #\r\n} 与 {@code #\r\n} 变体实测均无效
+ * (后者零响应)。这里按实测取 {@code \n\n}。</li>
+ * </ol>
+ *
+ * <p><b>权限</b>:调用方需管理员身份,且 SMB 会话用的是那台机器的本地管理员账号——
+ * 口令从本地凭据文件取({@link com.battery.device.api.credentials.LocalCredentials}),不进数据库、不进仓库。
+ * 会话由本类用 {@code net.exe use} 建立:Java 无法给命名管道传凭据,
+ * 只有先有一条已认证的会话,路径才打得开。会话按"主机 + 账号 + 口令"缓存,进程内每条只建一次。
+ *
+ * <p><b>只读边界</b>:出站请求先过 {@link NewareBtsReadOnlyCommands} 的白名单,
+ * 非只读命令在写进管道之前就被拒绝。
+ *
+ * <p><b>读超时</b>:{@code RandomAccessFile} 没有读超时(管道挂住就是永久阻塞),
+ * 因此读取放在单线程执行器里、由调用方带超时等待;超时从另一个线程关掉在途句柄把读打断,
+ * 使"挂住"变成一次可分类的失败,而不是把调用线程钉死。
+ */
+public final class NewareBtsPipeTransport implements BtsTransport
+{
+ /** 服务端命名管道的名字;跨机时路径为 {@code \\<主机>\pipe\<名字>}。 */
+ private static final String PIPE_NAME = "NewareBtsAPI";
+ /** 实测的报文结束符(协议文档的 {@code #\r\n} 在本接口上无效)。 */
+ private static final byte[] FRAME_END = "\n\n".getBytes(StandardCharsets.UTF_8);
+ private static final int DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
+ private static final int MAX_SESSION_TIMEOUT_MS = 30_000;
+ private static final int MAX_RESPONSE_TIMEOUT_MS = 120_000;
+ private static final int READ_BUFFER_BYTES = 64 * 1024;
+ /** 已建立的 SMB 会话;键为「主机 + 账号 + 口令」的摘要,进程内复用。 */
+ private static final Set<String> ESTABLISHED_SESSIONS = ConcurrentHashMap.newKeySet();
+
+ private final String host;
+ private final String user;
+ private final String password;
+ private final String pipePath;
+ private final String sessionKey;
+ private final int sessionTimeoutMs;
+ private final int responseTimeoutMs;
+ private final int maxResponseBytes;
+ private final ReentrantLock requestLock = new ReentrantLock(true);
+ /** 读取放在这里跑,使调用方能带超时等待;线程是守护线程,不拖住进程退出。 */
+ private final ExecutorService reader = Executors.newSingleThreadExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "neware-bts-pipe-reader");
+ thread.setDaemon(true);
+ return thread;
+ });
+ /** 当前在途的管道句柄:读超时时由调用线程关掉它,把阻塞中的读打断。 */
+ private volatile RandomAccessFile inFlight;
+
+ public NewareBtsPipeTransport(String host, String user, String password,
+ Duration sessionTimeout, Duration responseTimeout)
+ {
+ this(host, user, password, sessionTimeout, responseTimeout, DEFAULT_MAX_RESPONSE_BYTES);
+ }
+
+ NewareBtsPipeTransport(String host, String user, String password,
+ Duration sessionTimeout, Duration responseTimeout, int maxResponseBytes)
+ {
+ this.host = requireText(host, "host");
+ this.user = requireText(user, "user");
+ this.password = Objects.requireNonNull(password, "password");
+ this.sessionTimeoutMs = timeout(sessionTimeout, MAX_SESSION_TIMEOUT_MS, "session");
+ this.responseTimeoutMs = timeout(responseTimeout, MAX_RESPONSE_TIMEOUT_MS, "response");
+ this.maxResponseBytes = maxResponseBytes;
+ if (maxResponseBytes < 1) throw new IllegalArgumentException("maxResponseBytes must be positive");
+ this.pipePath = "\\\\" + this.host + "\\pipe\\" + PIPE_NAME;
+ this.sessionKey = this.host + "|" + this.user + "|" + Integer.toHexString(this.password.hashCode());
+ }
+
+ /**
+ * 发出一条只读请求并返回原始应答。
+ *
+ * <p>前置条件:请求只含只读白名单内的命令。并发调用由锁串行化(本接口一条连接只服务一条请求)。
+ * SMB 会话首次使用时建立;管道打开或读取失败时重建会话并重试一次,仍失败则归类抛出。
+ *
+ * @param request 完整请求报文(不含结束符)
+ * @return 应答原文(已去掉 {@code \n\n})
+ * @throws BtsTransportException 请求越权,或会话、管道、读取失败
+ */
+ @Override
+ public String exchange(String request)
+ {
+ NewareBtsReadOnlyCommands.requireAllowed(request);
+ requestLock.lock();
+ try
+ {
+ ensureSession(false);
+ try
+ {
+ return perform(request);
+ }
+ catch (BtsTransportException failure)
+ {
+ // 会话可能已被对端或系统回收(长时间空闲、重启上位机):重建一次再试,只试一次。
+ if (!isSessionRecoverable(failure.reason())) throw failure;
+ ensureSession(true);
+ return perform(request);
+ }
+ }
+ finally
+ {
+ requestLock.unlock();
+ }
+ }
+
+ private String perform(String request)
+ {
+ Future<String> pending = reader.submit(() -> openAndExchange(request));
+ try
+ {
+ return pending.get(responseTimeoutMs, TimeUnit.MILLISECONDS);
+ }
+ catch (TimeoutException ex)
+ {
+ closeInFlight();
+ pending.cancel(true);
+ throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT,
+ "BTS 管道应答超时(" + responseTimeoutMs + " ms):" + pipePath, ex);
+ }
+ catch (InterruptedException ex)
+ {
+ closeInFlight();
+ pending.cancel(true);
+ Thread.currentThread().interrupt();
+ throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE,
+ "BTS 管道读取被中断:" + pipePath, ex);
+ }
+ catch (ExecutionException ex)
+ {
+ Throwable cause = ex.getCause();
+ if (cause instanceof BtsTransportException failure) throw failure;
+ throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE,
+ "BTS 管道交换失败:" + pipePath, cause);
+ }
+ }
+
+ /** 每次请求新建一条连接:实测同一连接上的第二条命令不会返回。 */
+ private String openAndExchange(String request) throws IOException
+ {
+ RandomAccessFile pipe;
+ try
+ {
+ pipe = new RandomAccessFile(pipePath, "rw");
+ }
+ catch (FileNotFoundException ex)
+ {
+ throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED,
+ "无法打开新威 BTS 管道(接口未启用?未激活?无管理员凭据?):" + pipePath, ex);
+ }
+ inFlight = pipe;
+ try
+ {
+ pipe.write((request + new String(FRAME_END, StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8));
+ return decode(readFrame(pipe, maxResponseBytes));
+ }
+ catch (java.io.EOFException ex)
+ {
+ throw new BtsTransportException(BtsTransportException.Reason.DISCONNECTED,
+ "新威 BTS 管道在应答结束前关闭:" + pipePath, ex);
+ }
+ catch (FrameTooLargeException ex)
+ {
+ throw new BtsTransportException(BtsTransportException.Reason.RESPONSE_TOO_LARGE,
+ "新威 BTS 应答超过 " + maxResponseBytes + " 字节:" + pipePath, ex);
+ }
+ catch (InvalidUtf8Exception ex)
+ {
+ throw new BtsTransportException(BtsTransportException.Reason.INVALID_UTF8,
+ "新威 BTS 应答不是合法 UTF-8:" + pipePath, ex);
+ }
+ finally
+ {
+ inFlight = null;
+ try { pipe.close(); } catch (IOException ignored) { }
+ }
+ }
+
+ /**
+ * 按块读取并扫描 {@code \n\n},返回去掉结束符的应答字节。逐字节读在 SMB 上是一次读一次往返,
+ * 而 {@code download} 一页最多上千行,所以必须成块读。
+ */
+ static byte[] readFrame(RandomAccessFile pipe, int maxPayloadBytes) throws IOException
+ {
+ byte[] chunk = new byte[READ_BUFFER_BYTES];
+ ByteArrayOutputStream received = new ByteArrayOutputStream();
+ int matched = 0;
+ while (true)
+ {
+ int read = pipe.read(chunk);
+ if (read < 0) throw new java.io.EOFException("新威 BTS 管道在应答结束前关闭");
+ for (int i = 0; i < read; i++)
+ {
+ byte value = chunk[i];
+ if (value == FRAME_END[matched])
+ {
+ matched++;
+ if (matched == FRAME_END.length) return received.toByteArray();
+ continue;
+ }
+ if (matched > 0)
+ {
+ // 已匹配到的结束符前缀是正文的一部分:原样补回,再按当前字节重判。
+ for (int prefix = 0; prefix < matched; prefix++) append(received, FRAME_END[prefix], maxPayloadBytes);
+ matched = 0;
+ }
+ if (value == FRAME_END[0])
+ {
+ matched = 1;
+ continue;
+ }
+ append(received, value, maxPayloadBytes);
+ }
+ }
+ }
+
+ private static void append(ByteArrayOutputStream buffer, byte value, int maxPayloadBytes) throws FrameTooLargeException
+ {
+ if (buffer.size() >= maxPayloadBytes) throw new FrameTooLargeException();
+ buffer.write(value);
+ }
+
+ private static String decode(byte[] bytes) throws InvalidUtf8Exception
+ {
+ try
+ {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes)).toString();
+ }
+ catch (CharacterCodingException ex)
+ {
+ throw new InvalidUtf8Exception(ex);
+ }
+ }
+
+ /**
+ * 建立 SMB 会话。Java 无法向命名管道传凭据,所以只能先让系统用 {@code net.exe use} 认证一次,
+ * 之后管道路径才打得开;这是"把人工那步 net use 收进后端"的落点。
+ *
+ * @param force true 表示先撤掉旧会话再建(用于重试路径)
+ */
+ private void ensureSession(boolean force)
+ {
+ if (force)
+ {
+ runNet("use", "\\\\" + host + "\\ipc$", "/delete", "/y");
+ ESTABLISHED_SESSIONS.remove(sessionKey);
+ }
+ if (!force && ESTABLISHED_SESSIONS.contains(sessionKey)) return;
+ String output = runNet("use", "\\\\" + host + "\\ipc$", "/user:" + host + "\\" + user, password);
+ if (output == null) throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED,
+ "无法建立到 " + host + " 的 SMB 会话", null);
+ ESTABLISHED_SESSIONS.add(sessionKey);
+ }
+
+ /**
+ * 执行一条 net.exe 命令。
+ *
+ * <p>返回值:成功返回输出;失败时若输出表明"该主机的会话已存在"(错误 1219/多重连接)
+ * 也按成功处理——会话已经在了,管道照常可开。其余失败返回空值。
+ * 建立与撤销都失败不抛出,交给后续管道打开去报错,避免凭据问题被当成会话问题。
+ */
+ private String runNet(String... arguments)
+ {
+ String[] command = new String[arguments.length + 1];
+ command[0] = netExecutable();
+ System.arraycopy(arguments, 0, command, 1, arguments.length);
+ try
+ {
+ Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
+ String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ if (!process.waitFor(sessionTimeoutMs, TimeUnit.MILLISECONDS))
+ {
+ process.destroyForcibly();
+ return null;
+ }
+ if (process.exitValue() == 0) return output;
+ String normalized = output.toLowerCase(Locale.ROOT);
+ if (normalized.contains("1219") || normalized.contains("多重连接") || normalized.contains("multiple connections"))
+ return output;
+ return null;
+ }
+ catch (IOException ex)
+ {
+ return null;
+ }
+ catch (InterruptedException ex)
+ {
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ }
+
+ private static String netExecutable()
+ {
+ String systemRoot = System.getenv("SystemRoot");
+ return (systemRoot == null || systemRoot.isBlank() ? "C:\\Windows" : systemRoot) + "\\System32\\net.exe";
+ }
+
+ /** 会话或连接类失败值得重建会话再试一次;协议与内容类失败重试没有意义。 */
+ private static boolean isSessionRecoverable(BtsTransportException.Reason reason)
+ {
+ return reason == BtsTransportException.Reason.CONNECTION_FAILED
+ || reason == BtsTransportException.Reason.DISCONNECTED
+ || reason == BtsTransportException.Reason.IO_FAILURE;
+ }
+
+ /** 关掉在途句柄:这是唯一能打断阻塞中 ReadFile 的手段。 */
+ private void closeInFlight()
+ {
+ RandomAccessFile pipe = inFlight;
+ if (pipe == null) return;
+ try { pipe.close(); } catch (IOException ignored) { }
+ }
+
+ @Override
+ public void close()
+ {
+ requestLock.lock();
+ try
+ {
+ closeInFlight();
+ }
+ finally
+ {
+ requestLock.unlock();
+ }
+ reader.shutdownNow();
+ }
+
+ private static String requireText(String value, String field)
+ {
+ String normalized = Objects.requireNonNull(value, field).trim();
+ if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank");
+ return normalized;
+ }
+
+ private static int timeout(Duration value, int maximumMs, String field)
+ {
+ int millis = Math.toIntExact(Objects.requireNonNull(value, field).toMillis());
+ if (millis < 1) throw new IllegalArgumentException(field + " timeout must be positive");
+ if (millis > maximumMs) throw new IllegalArgumentException(field + " timeout must not exceed " + maximumMs + " ms");
+ return millis;
+ }
+
+ private static final class FrameTooLargeException extends IOException
+ {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final class InvalidUtf8Exception extends IOException
+ {
+ private static final long serialVersionUID = 1L;
+
+ private InvalidUtf8Exception(Throwable cause)
+ {
+ super(cause);
+ }
+ }
+}
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsReadOnlyCommands.java b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsReadOnlyCommands.java
new file mode 100644
index 0000000..1226281
--- /dev/null
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsReadOnlyCommands.java
@@ -0,0 +1,54 @@
+package com.battery.device.bts;
+
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 新威 BTS 对外接口的**只读命令白名单**(ADR-0068 §1)。
+ *
+ * <p>启用厂商对外接口等于同时打开了控制通道:厂商协议里 {@code start}/{@code stop}/{@code reset}/
+ * {@code broadcaststop} 与只读命令走同一条管道、同一套报文格式,服务端不做区分。
+ * 因此"只观测不控制"这条不变量在新威这条路上**不是服务端保证,而是代码纪律**,
+ * 落点就是这里:每条出站请求在发出前按 {@code <cmd>} 取值比对白名单,不在白名单内的一律拒绝。
+ *
+ * <p>为什么拦在传输层而不是各调用处:调用处会随需求增加,白名单只有一处才可能被审计。
+ * 独立成类而不写在传输类里,是为了让这条边界能被单独测到(不需要真的有一条管道)。
+ */
+final class NewareBtsReadOnlyCommands
+{
+ /** 允许发送的命令,全部是只读命令;取值来自《BTS API技术协议》与现场实测(ADR-0068)。 */
+ private static final Set<String> ALLOWED = Set.of(
+ "getdevinfo", "getchlstatus", "inquire", "inquiredf", "download",
+ "downloadsteplayer", "downloadlog", "getparallel", "getpause");
+
+ private static final Pattern COMMAND = Pattern.compile("<cmd>\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*</cmd>");
+
+ private NewareBtsReadOnlyCommands()
+ {
+ }
+
+ /**
+ * 校验请求只含白名单内的命令,返回解析出的命令名(小写)。
+ *
+ * <p>没有 {@code <cmd>} 的报文不是本协议的请求,同样拒绝——放行一个解析不出命令的报文,
+ * 等于把"这道门"的判定权交给厂商服务端。
+ *
+ * @param request 完整请求报文
+ * @return 请求里的命令名(小写)
+ * @throws BtsTransportException 缺少命令,或命令不在只读白名单内
+ */
+ static String requireAllowed(String request)
+ {
+ Matcher matcher = request == null ? null : COMMAND.matcher(request);
+ if (matcher == null || !matcher.find())
+ throw new BtsTransportException(BtsTransportException.Reason.READ_ONLY_REJECTED,
+ "BTS 请求缺少 <cmd>,拒绝发出", null);
+ String command = matcher.group(1).toLowerCase(Locale.ROOT);
+ if (!ALLOWED.contains(command))
+ throw new BtsTransportException(BtsTransportException.Reason.READ_ONLY_REJECTED,
+ "BTS 只读白名单外的命令被拒绝:" + command, null);
+ return command;
+ }
+}
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
new file mode 100644
index 0000000..cdbdd16
--- /dev/null
+++ b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java
@@ -0,0 +1,294 @@
+package com.battery.device.bts;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.Test;
+import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.model.DeviceControlCommand;
+import com.battery.device.api.model.DeviceWorkStatus;
+
+/**
+ * 直连新威接口的适配器契约:命令分工、水位与增量逐点、以及三处"不猜"的边界。
+ *
+ * <p>传输用脚本化的假管道替代(真实管道只在现场那台机器上存在),
+ * 但应答原文取自现场实测,所以这里验的是"适配器怎么用这些命令",不是"命令能不能通"。
+ */
+class NewareBtsAdapterContractTest
+{
+ private static final String ADAPTER = "neware-f";
+ private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER, 25, 81, 1, 2);
+
+ @Test
+ void discoversChannelsAndObservesRealtimeThroughThePipe()
+ {
+ ScriptedPipe pipe = new ScriptedPipe()
+ .on("getdevinfo", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">"
+ + "<cmd>getdevinfo_resp</cmd><middle count=\"2\">"
+ + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"1\">true</channel>"
+ + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"2\">true</channel>"
+ + "</middle></bts>")
+ .on("inquire", "<bts><cmd>inquire_resp</cmd><list count=\"1\">"
+ + "<inquire dev=\"25-81-1-2-0\" cycle_id=\"1\" step_id=\"6\" step_type=\"cc\""
+ + " workstatus=\"working\" current=\"0.6853\" voltage=\"1.7337\" capacity=\"5.3894\""
+ + " energy=\"9.3408\" totaltime=\"37737.6\" relativetime=\"28311.8\" /></list></bts>");
+ try (DeviceAdapter adapter = adapter(pipe))
+ {
+ assertEquals(2, adapter.discoverChannels().size());
+
+ var observation = adapter.observe(Set.of(CHANNEL)).get(CHANNEL);
+
+ assertTrue(observation.online());
+ // 读库路线拿不到通道工作状态,这正是本路线的主要收获。
+ assertEquals(DeviceWorkStatus.WORKING, observation.workStatus());
+ assertEquals(List.of("getdevinfo", "inquire"), pipe.commands());
+ }
+ }
+
+ @Test
+ void latestCabinetCarriesRealtimeValuesAndTheServerReceiptTime()
+ {
+ ScriptedPipe pipe = new ScriptedPipe().on("inquire",
+ "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\" cycle_id=\"1\""
+ + " step_id=\"6\" step_type=\"cc\" workstatus=\"working\" current=\"0.6853\""
+ + " voltage=\"1.7337\" capacity=\"5.3894\" energy=\"9.3408\" relativetime=\"28311.8\" />"
+ + "</list></bts>");
+ try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+ {
+ var latest = adapter.latestCabinet(CHANNEL);
+
+ assertEquals(0.6853, latest.currentA(), 1e-9);
+ assertEquals(1.7337, latest.voltageV(), 1e-9);
+ assertEquals(1, latest.cycleNo());
+ assertEquals("cc", latest.stepType());
+ assertEquals("充电", latest.stepName());
+ // inquire 不带绝对时间:这里的时间口径是服务器接收时间,序号留 0。
+ assertNull(latest.deviceTime());
+ assertEquals(0L, latest.sequenceId());
+ assertEquals(Long.valueOf(28312L), latest.relativeTimeSeconds());
+ }
+ }
+
+ @Test
+ void watermarkIsTheLastPersistedRowAndSamplesAreStrictlyAfterIt()
+ {
+ ScriptedPipe pipe = new ScriptedPipe().rows(1, 5);
+ try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+ {
+ SampleWatermark watermark = adapter.currentWatermark(CHANNEL);
+
+ // 水位 = 已落盘行数那一行本身;行数就是最后一行的 seqid(实测一致)。
+ assertEquals(5L, watermark.sequenceId());
+ assertEquals(rowTime(5), watermark.deviceTime());
+ assertEquals(List.of("inquiredf", "download:5:1"), pipe.commands());
+
+ pipe.reset();
+ var rows = adapter.samplesSince(CHANNEL, new SampleWatermark(rowTime(3), 3L));
+
+ assertEquals(2, rows.size());
+ assertEquals(4L, rows.get(0).sequenceId());
+ assertEquals("316:4", rows.get(0).sourceRecordId());
+ // 分页从「水位 + 1」开始,只取到已落盘行数为止。
+ assertEquals(List.of("inquiredf", "download:4:2"), pipe.commands());
+ }
+ }
+
+ @Test
+ void samplesSinceReadsNothingWithoutAWatermark()
+ {
+ ScriptedPipe pipe = new ScriptedPipe().rows(1, 5);
+ try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+ {
+ assertTrue(adapter.samplesSince(CHANNEL, null).isEmpty());
+ // 没有水位就不该去读来源:来源里的设备历史不得并入本次接收。
+ assertTrue(pipe.commands().isEmpty());
+ }
+ }
+
+ @Test
+ void treatsAClientWithoutInquiredfAsNotRowBasedInsteadOfFailing()
+ {
+ // 实测 7.6.0.1475 不支持 inquiredf(应答里没有 chl 记录)。这不是失败,是该构建的真实能力。
+ ScriptedPipe pipe = new ScriptedPipe().on("inquiredf",
+ "<bts><cmd>inquiredf_resp</cmd><list count=\"0\" /></bts>");
+ try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+ {
+ assertNull(adapter.currentWatermark(CHANNEL));
+ }
+ }
+
+ @Test
+ void aChannelWithNoTestHasAZeroWatermarkAndNoSamples()
+ {
+ ScriptedPipe pipe = new ScriptedPipe().on("inquiredf",
+ "<bts><cmd>inquiredf_resp</cmd><list count=\"1\"><chl devtype=\"25\" devid=\"81\" subdevid=\"1\""
+ + " chlid=\"2\" testid=\"316\" count=\"0\">true</chl></list></bts>");
+ try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+ {
+ SampleWatermark watermark = adapter.currentWatermark(CHANNEL);
+
+ // 空时间表示"这一刻来源里没有任何可用行":之后出现的行全部属于本次接收。
+ assertNull(watermark.deviceTime());
+ assertEquals(0L, watermark.sequenceId());
+ assertTrue(adapter.samplesSince(CHANNEL, watermark).isEmpty());
+ }
+ }
+
+ @Test
+ void doesNotInventStepCyclesCollectorOrPlanFacts()
+ {
+ ScriptedPipe pipe = new ScriptedPipe();
+ try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
+ {
+ // 工步层要未验证的 downloadStepLayer;采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。
+ assertTrue(adapter.stepRecords(CHANNEL).isEmpty());
+ assertTrue(adapter.cycleRecords(CHANNEL).isEmpty());
+ assertNull(adapter.latestCollector(ADAPTER, "collector-1"));
+ assertNull(adapter.testPlan(CHANNEL));
+ assertEquals(NewareBtsAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL));
+ assertTrue(pipe.commands().isEmpty());
+ }
+ }
+
+ @Test
+ void readOnlySurfaceStaysClosedForEveryControlCommand()
+ {
+ ScriptedPipe pipe = new ScriptedPipe();
+ try (DeviceAdapter adapter = adapter(pipe))
+ {
+ assertTrue(adapter.capabilities().discovery());
+ assertTrue(adapter.capabilities().realtimeObservation());
+ assertTrue(adapter.capabilities().historyDownload());
+ assertFalse(adapter.capabilities().control());
+
+ for (DeviceControlCommand command : DeviceControlCommand.values())
+ assertThrows(UnsupportedOperationException.class, () -> adapter.control(CHANNEL, command));
+ assertTrue(pipe.commands().isEmpty());
+ }
+ }
+
+ private static DeviceAdapter adapter(ScriptedPipe pipe)
+ {
+ return new NewareBtsAdapter(ADAPTER, pipe, new BtsXmlCodec(ADAPTER, ZoneId.of(DEVICE_ZONE)));
+ }
+
+ /** 假管道里第 {@code sequenceId} 行对应的设备绝对时间(每行间隔 60 秒)。 */
+ private static Instant rowTime(int sequenceId)
+ {
+ return Instant.parse("2026-10-10T15:00:00Z").plusSeconds(sequenceId * 60L);
+ }
+
+ /** 设备本地时间文本(厂商格式):与适配器时区一致,编解码器按它解释回 UTC。 */
+ private static final java.time.format.DateTimeFormatter DEVICE_TIME_TEXT =
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private static final String DEVICE_ZONE = "Asia/Hong_Kong";
+
+ private static String rowTimeText(int sequenceId)
+ {
+ return DEVICE_TIME_TEXT.withZone(ZoneId.of(DEVICE_ZONE)).format(rowTime(sequenceId));
+ }
+
+ /**
+ * 脚本化的假管道:按 {@code <cmd>} 返回固定应答,{@code download}/{@code inquiredf} 按内存里的行集动态生成。
+ *
+ * <p>它也把只读白名单当断言用:任何越权命令在到达"对端"之前就被拒绝,测试会因此失败。
+ */
+ private static final class ScriptedPipe implements BtsTransport
+ {
+ private static final Pattern COMMAND = Pattern.compile("<cmd>\\s*([A-Za-z_]+)\\s*</cmd>");
+ private static final Pattern START_POSITION = Pattern.compile("startpos=\"(\\d+)\"");
+ private static final Pattern COUNT = Pattern.compile("count=\"(\\d+)\"");
+ private static final long TEST_ID = 316L;
+
+ private final Map<String, String> responses = new java.util.LinkedHashMap<>();
+ private final List<String> commands = new ArrayList<>();
+ private int firstRow = 0;
+ private int lastRow = 0;
+
+ ScriptedPipe on(String command, String response)
+ {
+ responses.put(command, response);
+ return this;
+ }
+
+ /** 生成 {@code [first, last]} 的逐点行集,供 download/inquiredf 按窗口回答。 */
+ ScriptedPipe rows(int first, int last)
+ {
+ firstRow = first;
+ lastRow = last;
+ return this;
+ }
+
+ void reset()
+ {
+ commands.clear();
+ }
+
+ List<String> commands()
+ {
+ return List.copyOf(commands);
+ }
+
+ @Override
+ public String exchange(String request)
+ {
+ NewareBtsReadOnlyCommands.requireAllowed(request);
+ Matcher matcher = COMMAND.matcher(request);
+ if (!matcher.find()) throw new IllegalStateException("请求缺少命令:" + request);
+ String command = matcher.group(1).toLowerCase(java.util.Locale.ROOT);
+ if ("inquiredf".equals(command))
+ {
+ commands.add("inquiredf");
+ if (responses.containsKey("inquiredf")) return responses.get("inquiredf");
+ return "<bts><cmd>inquiredf_resp</cmd><list count=\"1\"><chl devtype=\"25\" devid=\"81\""
+ + " subdevid=\"1\" chlid=\"2\" testid=\"" + TEST_ID + "\" count=\"" + lastRow
+ + "\">false</chl></list></bts>";
+ }
+ if ("download".equals(command))
+ {
+ long from = Long.parseLong(value(START_POSITION, request, "1"));
+ int count = Integer.parseInt(value(COUNT, request, "1"));
+ commands.add("download:" + from + ":" + count);
+ return downloadResponse(from, count);
+ }
+ commands.add(command);
+ String response = responses.get(command);
+ if (response == null) throw new IllegalStateException("测试脚本没有为命令准备应答:" + command);
+ return response;
+ }
+
+ private String downloadResponse(long from, int count)
+ {
+ StringBuilder rows = new StringBuilder();
+ int returned = 0;
+ for (int sequenceId = (int) from; sequenceId <= lastRow && returned < count; sequenceId++, returned++)
+ rows.append("<data seqid=\"").append(sequenceId).append("\" stepid=\"6\" cycleid=\"0\""
+ + " steptype=\"cc\" testtime=\"").append(sequenceId * 60000L).append("\""
+ + " atime=\"").append(rowTimeText(sequenceId)).append("\"")
+ .append(" volt=\"1.75\" curr=\"0.685\" cap=\"4.1\" eng=\"7.1\" />");
+ return "<bts><cmd>donwload_resp</cmd><download devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""
+ + " testid=\"" + TEST_ID + "\" startpos=\"" + from + "\" count=\"" + returned
+ + "\" auxid=\"0\" /><list count=\"" + returned + "\">" + rows + "</list></bts>";
+ }
+
+ private static String value(Pattern pattern, String request, String fallback)
+ {
+ Matcher matcher = pattern.matcher(request);
+ return matcher.find() ? matcher.group(1) : fallback;
+ }
+ }
+}
diff --git a/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterFactoryContractTest.java b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterFactoryContractTest.java
new file mode 100644
index 0000000..17d39f7
--- /dev/null
+++ b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterFactoryContractTest.java
@@ -0,0 +1,89 @@
+package com.battery.device.bts;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.DeviceAdapterException;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+import com.battery.device.api.registry.DeviceAdapterRegistry;
+import com.battery.device.api.registry.DeviceAdapterFactory;
+
+/**
+ * 直连新威接口的类型装配:类型标识、口令来源与只读能力。
+ *
+ * <p>口令走"本地凭据文件 → 环境变量 → 启动参数",测试用启动参数这一层注入只在本测试里存在的键,
+ * 以免碰到真实凭据文件。
+ */
+class NewareBtsAdapterFactoryContractTest
+{
+ private static final String PASSWORD_KEY = "TEST_NEWARE_WIN_PASSWORD_FF";
+
+ @BeforeEach
+ void provideAdminAccount()
+ {
+ System.setProperty(NewareBtsAdapterFactory.DEFAULT_USER_ENV, "Administrator");
+ }
+
+ @AfterEach
+ void clearAccount()
+ {
+ System.clearProperty(NewareBtsAdapterFactory.DEFAULT_USER_ENV);
+ System.clearProperty(PASSWORD_KEY);
+ }
+
+ @Test
+ void declaresItsOwnStorageShapeType()
+ {
+ assertEquals("NEWARE_BTS", new NewareBtsAdapterFactory().adapterType());
+ assertEquals("NEWARE_BTS", NewareBtsAdapter.ADAPTER_TYPE);
+ }
+
+ @Test
+ void buildsAReadOnlyAdapterForAConfiguredHost()
+ {
+ System.setProperty(PASSWORD_KEY, "159357");
+ DeviceAdapterFactory factory = new NewareBtsAdapterFactory();
+
+ DeviceAdapter adapter = factory.create(config(Map.of(NewareBtsAdapterFactory.PASSWORD_ENV_OPTION, PASSWORD_KEY)));
+
+ assertEquals("neware-f", adapter.adapterCode());
+ assertEquals(true, adapter.capabilities().historyDownload());
+ assertFalse(adapter.capabilities().control());
+ }
+
+ @Test
+ void refusesToGuessTheMachinePassword()
+ {
+ DeviceAdapterFactory factory = new NewareBtsAdapterFactory();
+
+ DeviceAdapterException exception = assertThrows(DeviceAdapterException.class,
+ () -> factory.create(config(Map.of())));
+
+ // 五台机器的口令实测有两种,猜一个就会连错机器,所以这个配置项没有缺省值。
+ assertEquals(true, exception.getMessage().contains(NewareBtsAdapterFactory.PASSWORD_ENV_OPTION));
+ }
+
+ @Test
+ void registryRejectsAnInstanceWhoseCredentialsAreMissingWithoutEnablingIt()
+ {
+ try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(new NewareBtsAdapterFactory())))
+ {
+ assertThrows(DeviceAdapterException.class, () -> registry.synchronize(config(Map.of())));
+
+ assertFalse(registry.contains("neware-f"));
+ }
+ }
+
+ private static AdapterInstanceConfig config(Map<String, String> sourceConfig)
+ {
+ return new AdapterInstanceConfig("neware-f", "NEWARE_BTS", "26.117.165.166", 445, 60000,
+ "Asia/Hong_Kong", true, new AdapterCapabilities(true, true, true, false), sourceConfig);
+ }
+}
diff --git a/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsPipeTransportTest.java b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsPipeTransportTest.java
new file mode 100644
index 0000000..614fc27
--- /dev/null
+++ b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsPipeTransportTest.java
@@ -0,0 +1,116 @@
+package com.battery.device.bts;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 远程命名管道传输的行为:分帧按实测的 {@code \n\n}、只读白名单在发出前生效。
+ * 分帧用普通文件驱动 {@link NewareBtsPipeTransport#readFrame} —— 生产方法读写的就是 {@code RandomAccessFile},
+ * 换掉的是对端是不是管道,帧扫描这一段完全一致。
+ */
+class NewareBtsPipeTransportTest
+{
+ private static final int MAX = 1024;
+
+ @Test
+ void returnsPayloadUpToThePlainDoubleNewlineTerminator() throws Exception
+ {
+ assertEquals("<bts><cmd>getdevinfo_resp</cmd></bts>", frame("<bts><cmd>getdevinfo_resp</cmd></bts>\n\n"));
+ }
+
+ @Test
+ void ignoresBytesAfterTheTerminator() throws Exception
+ {
+ assertEquals("first", frame("first\n\nsecond\n\n"));
+ }
+
+ @Test
+ void keepsTerminatorPrefixThatBelongsToThePayload() throws Exception
+ {
+ // 正文里出现结束符的前缀时要原样补回,不能把它吃掉。
+ assertEquals("a\n#b", frame("a\n#b\n\n"));
+ }
+
+ @Test
+ void doesNotTreatDocumentedHashTerminatorAsAnEnd() throws Exception
+ {
+ // 协议文档给的 #\r\n 现场实测无效;照它写就永远读不到帧尾,这里以"读到尾也没结束"表示这一点。
+ assertThrows(EOFException.class, () -> frame("<bts/>#\r\n"));
+ }
+
+ @Test
+ void rejectsPayloadBeyondTheLimit() throws Exception
+ {
+ assertThrows(IOException.class, () -> frameRaw("123456789\n\n", 8));
+ }
+
+ @Test
+ void allowsPayloadExactlyAtTheLimit() throws Exception
+ {
+ assertEquals("12345678", frameRaw("12345678\n\n", 8));
+ }
+
+ @Test
+ void rejectsEveryCommandOutsideTheReadOnlyWhitelistBeforeAnyTransportWork()
+ {
+ try (NewareBtsPipeTransport transport = new NewareBtsPipeTransport("203.0.113.1", "Administrator", "secret",
+ Duration.ofSeconds(1), Duration.ofSeconds(1)))
+ {
+ // 控制命令一律不得发出:这里没有任何会话与管道,若白名单没生效就会变成连接失败而不是被拒绝。
+ for (String command : new String[] {"start", "stop", "reset", "broadcaststop", "setpause",
+ "cancelpause", "goto", "chl_ctrl", "parallel", "light", "continue", "clearflag"})
+ {
+ BtsTransportException exception = assertThrows(BtsTransportException.class,
+ () -> transport.exchange("<bts><cmd>" + command + "</cmd></bts>"));
+ assertEquals(BtsTransportException.Reason.READ_ONLY_REJECTED, exception.reason(), command);
+ }
+ }
+ }
+
+ @Test
+ void acceptsTheReadOnlyWhitelistAndRejectsRequestsWithoutACommand()
+ {
+ try (NewareBtsPipeTransport transport = new NewareBtsPipeTransport("203.0.113.1", "Administrator", "secret",
+ Duration.ofSeconds(1), Duration.ofSeconds(1)))
+ {
+ for (String command : new String[] {"getdevinfo", "getchlstatus", "inquire", "inquiredf", "download",
+ "downloadStepLayer", "downloadlog", "getparallel", "getpause"})
+ assertEquals(command.toLowerCase(java.util.Locale.ROOT),
+ NewareBtsReadOnlyCommands.requireAllowed("<bts><cmd>" + command + "</cmd></bts>"));
+
+ BtsTransportException exception = assertThrows(BtsTransportException.class,
+ () -> NewareBtsReadOnlyCommands.requireAllowed("<bts><note>没有命令</note></bts>"));
+ assertEquals(BtsTransportException.Reason.READ_ONLY_REJECTED, exception.reason());
+ }
+ }
+
+ private static String frame(String content) throws Exception
+ {
+ return frameRaw(content, MAX);
+ }
+
+ private static String frameRaw(String content, int maxPayloadBytes) throws Exception
+ {
+ Path file = Files.createTempFile("neware-bts-frame", ".txt");
+ try
+ {
+ Files.write(file, content.getBytes(StandardCharsets.UTF_8));
+ try (RandomAccessFile pipe = new RandomAccessFile(file.toFile(), "r"))
+ {
+ return new String(NewareBtsPipeTransport.readFrame(pipe, maxPayloadBytes), StandardCharsets.UTF_8);
+ }
+ }
+ finally
+ {
+ Files.deleteIfExists(file);
+ }
+ }
+}
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
new file mode 100644
index 0000000..9f007be
--- /dev/null
+++ b/battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java
@@ -0,0 +1,254 @@
+package com.battery.device.bts;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.model.DeviceWorkStatus;
+import com.battery.device.api.model.RealtimeSnapshot;
+
+/**
+ * 新威接口的编解码器按**现场实测应答原文**验证。
+ *
+ * <p>这些夹具不是照协议文档写的,是从真实上位机(8.0.0.530)的管道应答里抄下来的原文,
+ * 因此它们同时是"实现必须吸收的实测差异"的回归防线:
+ * 小数秒的 {@code totaltime}、厂商拼错的 {@code donwload_resp}、五段式 {@code dev} 身份。
+ */
+class NewareBtsXmlCodecTest
+{
+ private static final String ADAPTER = "neware-f";
+ private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER, 25, 81, 1, 2);
+ private final BtsXmlCodec codec = new BtsXmlCodec(ADAPTER, ZoneId.of("Asia/Hong_Kong"));
+
+ @Test
+ void parsesRealRealtimeSnapshotWithFractionalSecondDurations()
+ {
+ String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>inquire_resp</cmd>"
+ + "<list count=\"1\"><inquire dev=\"25-81-1-2-0\" cycle_id=\"1\" step_id=\"6\" step_type=\"cc\""
+ + " workstatus=\"working\" current=\"0.6853\" voltage=\"1.7337\" capacity=\"5.3894\""
+ + " energy=\"9.3408\" totaltime=\"37737.6\" relativetime=\"28311.8\" open_or_close=\"0\""
+ + " log_code=\"0\" /></list></bts>";
+
+ RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.parse("2026-10-10T15:50:52Z"))
+ .get(CHANNEL);
+
+ assertEquals(DeviceWorkStatus.WORKING, snapshot.workStatus());
+ assertEquals(Integer.valueOf(1), snapshot.cycleNo());
+ assertEquals(Integer.valueOf(6), snapshot.stepNo());
+ assertEquals("cc", snapshot.stepType());
+ // 实测给的是小数秒;早期按整数解析会在这里判成非法取值,整次观测失败。
+ assertEquals(Long.valueOf(37738L), snapshot.totalTimeSeconds());
+ assertEquals(Long.valueOf(28312L), snapshot.relativeTimeSeconds());
+ assertEquals("0", snapshot.logCode());
+ // 五段式 dev(末段是辅助序号):身份取前四段,后段忽略。
+ assertEquals(CHANNEL, snapshot.channel());
+ }
+
+ @Test
+ void parsesDataFileStateFromRealInquiredfResponse()
+ {
+ String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>inquiredf_resp</cmd>"
+ + "<list count=\"1\"><chl devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\""
+ + " count=\"637\">false</chl></list></bts>";
+
+ BtsXmlCodec.DataFileState state = codec.parseDataFileState(xml);
+
+ assertEquals(316L, state.testId());
+ assertEquals(637L, state.count());
+ // 厂商自报"未传完":这段数据不得当完整事实(ADR-0068 未决 5)。
+ assertFalse(state.complete());
+ }
+
+ @Test
+ void parsesRealDownloadedPointsWithAbsoluteTimeAndFullPrecision()
+ {
+ String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>donwload_resp</cmd>"
+ + "<download devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" startpos=\"527\""
+ + " count=\"3\" auxid=\"0\" /><list count=\"3\">"
+ + "<data seqid=\"527\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21660000\""
+ + " atime=\"2026-10-10 23:49:28\" volt=\"1.750143\" curr=\"0.6852889\""
+ + " cap=\"4.12315481465658\" eng=\"7.12849144299129\" />"
+ + "<data seqid=\"528\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21720000\""
+ + " atime=\"2026-10-10 23:50:28\" volt=\"1.751076\" curr=\"0.6852991\""
+ + " cap=\"4.13457583333333\" eng=\"7.14849111111111\" />"
+ + "<data seqid=\"529\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21780000\""
+ + " atime=\"2026-10-10 23:51:28\" volt=\"1.750529\" curr=\"0.6852911\""
+ + " cap=\"4.14599735229492\" eng=\"7.16848481020665\" /></list></bts>";
+
+ List<CabinetRealtime> rows = codec.parseDownload(xml, Instant.parse("2026-10-10T15:51:30Z"));
+
+ assertEquals(3, rows.size());
+ CabinetRealtime first = rows.get(0);
+ assertEquals(Long.valueOf(527L), first.sequenceId());
+ // atime 是设备本地时间,按适配器时区解释成 UTC(香港 = UTC+8)。
+ assertEquals("2026-10-10T15:49:28Z", first.deviceTime().toString());
+ assertEquals("2026-10-10 23:49:28", first.deviceTimeRaw());
+ assertEquals(Integer.valueOf(6), first.stepNo());
+ // download 的 cycleid 是 0 起(与库里的 cycle 一致),而 inquire 的 cycle_id 是 1 起。
+ assertEquals(Integer.valueOf(0), first.cycleNo());
+ assertEquals("cc", first.stepType());
+ assertEquals("充电", first.stepName());
+ assertEquals(Long.valueOf(21660L), first.relativeTimeSeconds());
+ assertEquals(0.6852889, first.currentA(), 1e-12);
+ assertEquals(1.750143, first.voltageV(), 1e-12);
+ assertEquals(4.12315481465658, first.capacityAh(), 1e-12);
+ assertEquals(7.12849144299129, first.energyWh(), 1e-12);
+ // 行身份带测试号:seqid 在每个测试内从 1 重编,只按它会跨测试撞键。
+ assertEquals("316:527", first.sourceRecordId());
+ assertEquals("Asia/Hong_Kong", first.timezoneId());
+ }
+
+ @Test
+ void acceptsTheVendorsMisspelledDownloadCommandAndEmptyPages()
+ {
+ List<CabinetRealtime> rows = codec.parseDownload(
+ "<bts><cmd>donwload_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH);
+
+ assertTrue(rows.isEmpty());
+ }
+
+ @Test
+ void stillAcceptsTheCommandNameSpelledAsInTheProtocolDocument()
+ {
+ List<CabinetRealtime> rows = codec.parseDownload("<bts><cmd>download_resp</cmd><list count=\"0\" /></bts>",
+ Instant.EPOCH);
+
+ assertTrue(rows.isEmpty());
+ }
+
+ @Test
+ void rejectsADownloadResponseToAnotherCommand()
+ {
+ BtsProtocolException exception = assertThrows(BtsProtocolException.class,
+ () -> codec.parseDownload("<bts><cmd>inquire_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH));
+
+ assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, exception.reason());
+ }
+
+ @Test
+ void reportsMissingDataFileRecordInsteadOfInventingAWatermark()
+ {
+ // 客户端不支持 inquiredf 时(实测 7.6.0.1475)应答里没有 chl 记录:这是"读不到",不是"零行"。
+ BtsProtocolException exception = assertThrows(BtsProtocolException.class,
+ () -> codec.parseDataFileState("<bts><cmd>inquiredf_resp</cmd><list count=\"0\" /></bts>"));
+
+ assertEquals(BtsProtocolException.Reason.MISSING_FIELD, exception.reason());
+ }
+
+ @Test
+ void buildsRequestsWithTheVendorIdentityAndPaging()
+ {
+ String inquire = codec.inquireRequest(Set.of(CHANNEL), false);
+ assertTrue(inquire.contains("<cmd>inquire</cmd>"));
+ assertTrue(inquire.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\""));
+
+ String inquiredf = codec.inquiredfRequest(CHANNEL);
+ assertTrue(inquiredf.contains("<cmd>inquiredf</cmd>"));
+ assertTrue(inquiredf.contains("testid=\"0\""));
+
+ String download = codec.downloadRequest(CHANNEL, 0L, 528L, 2);
+ assertTrue(download.contains("<cmd>download</cmd>"));
+ assertTrue(download.contains("startpos=\"528\""));
+ assertTrue(download.contains("count=\"2\""));
+ assertTrue(download.contains("auxid=\"0\""));
+ }
+
+ @Test
+ void refusesToAddressAnotherAdaptersChannel()
+ {
+ ChannelIdentity foreign = new ChannelIdentity("neware-e", 25, 81, 1, 2);
+
+ assertThrows(IllegalArgumentException.class, () -> codec.inquiredfRequest(foreign));
+ assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(foreign, 0L, 1L, 1));
+ }
+
+ @Test
+ void refusesPagingBeyondTheProtocolLimits()
+ {
+ assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(CHANNEL, 0L, 0L, 1));
+ assertThrows(IllegalArgumentException.class,
+ () -> codec.downloadRequest(CHANNEL, 0L, 1L, BtsXmlCodec.MAX_DOWNLOAD_PAGE + 1));
+ }
+
+ @Test
+ void reportsTheConfiguredSiteTimezone()
+ {
+ assertEquals("Asia/Hong_Kong", codec.timezoneId());
+ }
+
+ @Test
+ void keepsUnknownStepTypesWithoutInventingADirectionName()
+ {
+ String xml = "<bts><cmd>donwload_resp</cmd><download testid=\"9\" startpos=\"1\" count=\"1\" />"
+ + "<list count=\"1\"><data seqid=\"1\" stepid=\"1\" cycleid=\"0\" steptype=\"--\" testtime=\"0\""
+ + " atime=\"2026-10-10 15:08:57\" volt=\"0\" curr=\"0\" cap=\"0\" eng=\"0\" /></list></bts>";
+
+ CabinetRealtime row = codec.parseDownload(xml, Instant.EPOCH).get(0);
+
+ assertNull(row.stepName());
+ }
+
+ @Test
+ void parsesRealDeviceInventoryIncludingTheCapitalisedChannelIdAttribute()
+ {
+ String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>"
+ + "<serverip count=\"1\"><server ip=\"127.0.0.1\" port=\"3306\" /></serverip>"
+ + "<middle count=\"2\">"
+ + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"1\">true</channel>"
+ + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"2\">false</channel>"
+ + "</middle></bts>";
+
+ var channels = codec.parseDeviceInfo(xml);
+
+ assertEquals(2, channels.size());
+ assertEquals(new ChannelIdentity(ADAPTER, 25, 81, 1, 1), channels.get(0).identity());
+ assertTrue(channels.get(0).available());
+ assertFalse(channels.get(1).available());
+ }
+
+ @Test
+ void keepsTheRequestedIdentityWhenTheServerRewritesTheDeviceType()
+ {
+ // 实测第四台:getdevinfo 报 devtype=27,而 inquire 的应答把 devtype 改写成 25。
+ // 按应答归集会与调用方请求的身份对不上(表现为"通道查不到"),所以归集要按请求的身份。
+ ChannelIdentity requested = new ChannelIdentity(ADAPTER, 27, 76, 1, 1);
+ String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-76-1-1-0\""
+ + " cycle_id=\"1\" step_type=\"--\" workstatus=\"finish\" current=\"0\" voltage=\"0\""
+ + " totaltime=\"0\" relativetime=\"0\" /></list></bts>";
+
+ Map<ChannelIdentity, RealtimeSnapshot> snapshots = codec.parseSnapshots(xml, Set.of(requested), Instant.EPOCH);
+
+ assertEquals(1, snapshots.size());
+ RealtimeSnapshot snapshot = snapshots.get(requested);
+ assertEquals(requested, snapshot.channel());
+ assertEquals(DeviceWorkStatus.FINISHED, snapshot.workStatus());
+ // 不带请求集合时退回按应答自己的身份归集(设备类型取应答里的 25)。
+ assertEquals(new ChannelIdentity(ADAPTER, 25, 76, 1, 1),
+ codec.parseSnapshots(xml, null, Instant.EPOCH).keySet().iterator().next());
+ }
+
+ @Test
+ void keepsOnlyAttributesOutsideTheKnownFieldSetAsDynamicMeasurements()
+ {
+ // 字段集随客户端构建不同(8.0.0.484 有 barcode/auxtemp,8.0.0.530 没有,实测三种构建互不相同)。
+ // 已知字段各走自己的字段(barcode 是值、auxtemp 按已知字段表排除),未在字段表里的按动态测量原样保留。
+ String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\""
+ + " workstatus=\"working\" barcode=\"BC1\" auxtemp=\"25.5\" T1=\"23.4\"/></list></bts>";
+
+ RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.EPOCH).get(CHANNEL);
+
+ assertEquals("BC1", snapshot.barcode());
+ assertEquals(23.4, snapshot.measurements().get("T1").value().doubleValue(), 1e-9);
+ assertFalse(snapshot.measurements().containsKey("barcode"));
+ assertFalse(snapshot.measurements().containsKey("auxtemp"));
+ }
+}
diff --git a/config/vendor-credentials.properties.example b/config/vendor-credentials.properties.example
index c928984..099f7dd 100644
--- a/config/vendor-credentials.properties.example
+++ b/config/vendor-credentials.properties.example
@@ -1,4 +1,4 @@
-# 厂商库连接口令 · 模板
+# 厂商来源凭据 · 模板
#
# 用法:把本文件复制成同目录下的 vendor-credentials.properties,填好口令即可。
# 开发期(IDE 起服务):<仓库根>/config/vendor-credentials.properties
@@ -6,12 +6,23 @@
#
# 说明:
# - vendor-credentials.properties 已被 .gitignore 忽略,不入库、不进数据库、不打包进 jar;
-# 口令的取值顺序是「本文件 → 同名环境变量 → 同名 JVM 启动参数 -D…」,三种都没有时
+# 取值顺序是「本文件 → 同名环境变量 → 同名 JVM 启动参数 -D…」,三种都没有时
# 装配会失败,错误信息会同时点名本文件的路径与该键名。
-# - 键名必须与适配器实例配置里的 passwordEnv 一致(默认 BATTERY_VENDOR_DB_PASSWORD)。
-# - 账号固定使用现场取得的服务端只读账号(visitor),不要在这里放全权限账号。
+# - 键名必须与适配器实例配置里的 passwordEnv / tokenEnv 一致。
+# - 直连厂商库那条路(NEWARE_MYSQL)固定使用现场取得的服务端只读账号(visitor),
+# 不要在这里放全权限账号。
#
-# 未放置本文件时后端仍会正常启动:该来源实例会被标记为离线,并把
-# 「缺少厂商库连接口令」写进适配器列表的最近一次连接结果里。
+# 未放置本文件时后端仍会正常启动:相关来源实例会被标记为离线,并把
+# 「缺少来源凭据」写进适配器列表的最近一次连接结果里。
+# 直连厂商 MySQL(NEWARE_MYSQL)的连接口令。
BATTERY_VENDOR_DB_PASSWORD=
+
+# 推式来源(PUSH_INGEST)的接收端点令牌:请求头 X-Device-Ingest-Token。
+BATTERY_INGEST_TOKEN=
+
+# 直连新威对外接口(NEWARE_BTS)那几台的**本机管理员账号与口令**。
+# Java 无法给命名管道传凭据,后端要先用它建一条 SMB 会话,\\<主机>\pipe\NewareBtsAPI 才打得开。
+# 账号各台相同;口令按机器分别给键(键名与实例配置里的 passwordEnv 一致),不要设缺省值。
+NEWARE_WIN_USER=
+# NEWARE_WIN_PASSWORD_<来源编码后缀>=
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java
index ffb819d..5ddd91c 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java
@@ -6,6 +6,7 @@
import org.springframework.scheduling.annotation.EnableScheduling;
import com.battery.device.api.NoopDeviceAdapterFactory;
import com.battery.device.bts.BtsDeviceAdapterFactory;
+import com.battery.device.bts.NewareBtsAdapterFactory;
import com.battery.device.api.registry.DeviceAdapterRegistry;
import com.battery.device.neware.NewareMysqlAdapterFactory;
import com.battery.business.mapper.TDeviceIngestRecordMapper;
@@ -29,6 +30,7 @@
public DeviceAdapterRegistry deviceAdapterRegistry(TDeviceIngestRecordMapper ingestRecords)
{
return new DeviceAdapterRegistry(List.of(new NoopDeviceAdapterFactory(), new BtsDeviceAdapterFactory(),
- new NewareMysqlAdapterFactory(), new PushIngestAdapterFactory(ingestRecords)));
+ new NewareBtsAdapterFactory(), new NewareMysqlAdapterFactory(),
+ new PushIngestAdapterFactory(ingestRecords)));
}
}
diff --git a/scripts/register-neware-bts-sources.sql b/scripts/register-neware-bts-sources.sql
new file mode 100644
index 0000000..d1280ab
--- /dev/null
+++ b/scripts/register-neware-bts-sources.sql
@@ -0,0 +1,53 @@
+-- 登记新威对外接口(命名管道)的直连来源实例(ADR-0070;取代 ADR-0068 §3 的"推式取数程序"形态)。
+-- 在你本地的**业务库**里执行一次即可。
+--
+-- 只登记 4 台(c/d/e/f),不是 5 台:
+-- A(26.70.208.249,7.6 客户端)**继续用 NEWARE_MYSQL**(adapter_code = neware76,已存在)。
+-- 理由有两条,缺一不可:
+-- 1. ADR-0068 §2:适配器编码是通道身份的一部分,**同一台机器只能有一个来源实例**,
+-- 否则同一物理通道会有两份身份;
+-- 2. 7.6 客户端不支持 inquiredf/download(实测空响应),接口侧拿不到逐点,
+-- 而那台的库里有 data_* —— 逐点必须走库,这条不能换。
+--
+-- port 填 445:跨机访问命名管道走的是 SMB,这个字段只作记录与识别(适配器不读它)。
+-- source_config 只放**变量名**:本机管理员口令在后端 config/vendor-credentials.properties
+-- (NEWARE_WIN_USER 与 NEWARE_WIN_PASSWORD_<后缀>),**口令值不进数据库**(ADR-0070)。
+
+-- 1) 先撤掉已废弃的推式实例(ADR-0068 §3 的方案,2026-10-10 决定改直连;这几行从未投入演示使用)。
+-- 落地表里已有的 neware-d 行(BTS_POINT/BTS_SNAPSHOT/HEARTBEAT)**保留**:那是成功接收的厂商事实,
+-- 按不变量原样留存,不随实例删除而删除。
+DELETE FROM t_device_adapter WHERE adapter_type = 'PUSH_INGEST' AND adapter_code LIKE 'neware-%';
+
+-- 2) 登记 4 台直连新威接口的来源实例。
+-- capabilities / poll_interval_ms / timezone_id / read_only 从现有实例照抄(同形态已在跑,照抄比猜取值安全);
+-- 若照抄源不存在,本脚本插入 0 行,不会写坏数据。
+INSERT INTO t_device_adapter
+ (adapter_code, adapter_name, adapter_type, host, port, poll_interval_ms, timezone_id, read_only,
+ capabilities, connection_status, status, del_flag, source_config, create_by, remark)
+SELECT v.code, v.name, 'NEWARE_BTS', v.host, 445, r.poll_interval_ms, r.timezone_id, r.read_only,
+ r.capabilities, 'OFFLINE', '0', 0, v.source_config, 'system', v.note
+FROM (
+ SELECT poll_interval_ms, timezone_id, read_only, capabilities
+ FROM t_device_adapter
+ WHERE adapter_code = 'neware76' AND del_flag = 0
+ ) r
+CROSS JOIN (
+ SELECT 'neware-c' AS code, '新威 C(26.163.107.225)' AS name, '26.163.107.225' AS host,
+ '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_C"}' AS source_config,
+ '8.0 客户端 8.0.0.484 + 7.6 服务端形态;download 逐点尚未实测' AS note
+ UNION ALL SELECT 'neware-d', '新威 D(26.154.232.92)', '26.154.232.92',
+ '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_D"}',
+ '8.0 客户端 8.0.0.530 + 8.0 服务端形态(库内无逐点,逐点走 download)'
+ UNION ALL SELECT 'neware-e', '新威 E(26.168.150.221)', '26.168.150.221',
+ '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_E"}',
+ '8.0 客户端 8.0.0.478;download 可拉历史测试逐点(已实测)'
+ UNION ALL SELECT 'neware-f', '新威 F(26.117.165.166)', '26.117.165.166',
+ '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_F"}',
+ '8.0 客户端 8.0.0.530;实测有活测试,端到端已验'
+ ) v;
+
+-- 3) 核对:应看到 4 行 NEWARE_BTS + 1 行 NEWARE_MYSQL,没有 PUSH_INGEST 的 neware-*。
+SELECT adapter_id, adapter_code, adapter_type, host, port, timezone_id, status, source_config
+FROM t_device_adapter
+WHERE adapter_code LIKE 'neware%'
+ORDER BY adapter_code;
--
Gitblit v1.7.1