yeg
4 天以前 99fd4ccdf5d410e78a3785375036c34a2132ee04
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