yeg
4 天以前 99fd4ccdf5d410e78a3785375036c34a2132ee04
新威对外接口改为后端直连管道,撤销机器端取数程序

ADR-0068 §3 原以为命名管道仅本机可达,于是把取数形态定成"每台机器上跑一个
只读取数程序"。实测该前提不成立:管道可跨机访问(\<主机>\pipe\NewareBtsAPI),
先建一条 SMB 会话即可,而这条会话后端自己能建。ADR-0070 据此把形态改为后端直连,
并取代 ADR-0069(那套推式载荷契约随之撤销)。

- 新增适配器类型 NEWARE_BTS 与 NewareBtsPipeTransport:首次使用某主机时用
net.exe use 建 SMB 会话,之后每次请求新建连接、按块读到 \n\n、收到应答即关
(实测一条连接只服务一条请求,复用会永久挂住)。
- 读取放在单线程执行器里带超时等待:RandomAccessFile 没有读超时,超时从另一个
线程关掉在途句柄把读打断,使"挂住"变成可分类的失败。
- 只读白名单收敛到 NewareBtsReadOnlyCommands 一处,在写出之前拒绝控制命令。
- 按实测修协议实现:totaltime/relativetime 是小数秒;download 的应答被厂商拼成
donwload_resp;atime 用空格分隔;inquire_resp 的 dev 是五段。
- 归集按请求的通道身份而不是应答里的:实测服务端会改写应答的 devtype
(getdevinfo 报 27-76,inquire 答 25-76-1-1-0),按应答归集会让通道查不到实时值。
- 交出实时值、通道工作状态与逐点(全精度 + 绝对时间);工步层、循环汇总、辅助测量
与计划方案如实为空,不用空值或推算冒充已知。
- 登记 neware-c/d/e/f 四个直连实例、撤掉五个推式实例;机器 A(7.6 客户端不支持
inquiredf/download)继续用 NEWARE_MYSQL,一台机器只留一个来源实例。
- 凭据沿用"本地凭据文件 → 环境变量 → 启动参数",source_config 只放变量名;
本机管理员口令按机器分别给键、不设缺省值。

后端全量测试 248 项通过;D 与 F 两台实机用生产代码端到端验过(发现、实时、水位、
增量逐点、控制命令被拒)。
9个文件已添加
7个文件已修改
1853 ■■■■■ 已修改文件
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/BtsDeviceAdapter.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/BtsTransportException.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java 236 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapterFactory.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsPipeTransport.java 394 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsReadOnlyCommands.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java 294 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterFactoryContractTest.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsPipeTransportTest.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
config/vendor-credentials.properties.example 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/register-neware-bts-sources.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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;
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;
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());
    }
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;
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
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapter.java
New file
@@ -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();
    }
}
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsAdapterFactory.java
New file
@@ -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;
    }
}
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsPipeTransport.java
New file
@@ -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);
        }
    }
}
battery-device-bts/src/main/java/com/battery/device/bts/NewareBtsReadOnlyCommands.java
New file
@@ -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;
    }
}
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java
New file
@@ -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;
        }
    }
}
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterFactoryContractTest.java
New file
@@ -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);
    }
}
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsPipeTransportTest.java
New file
@@ -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);
        }
    }
}
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsXmlCodecTest.java
New file
@@ -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"));
    }
}
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_<来源编码后缀>=
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)));
    }
}
scripts/register-neware-bts-sources.sql
New file
@@ -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;