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