package com.battery.device.bts; import java.io.StringReader; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; 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; import com.battery.device.api.model.DetectedDeviceMetadata; import com.battery.device.api.model.DeviceWorkStatus; import com.battery.device.api.model.Measurement; import com.battery.device.api.model.PlannedStep; import com.battery.device.api.model.RealtimeSnapshot; 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 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 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) { if (adapterCode == null || adapterCode.isBlank()) throw new IllegalArgumentException("adapterCode must not be blank"); this.adapterCode = adapterCode.trim(); this.deviceZone = deviceZone; } /** 构造通道发现的请求报文({@code getdevinfo})。 */ public String getDeviceInfoRequest() { return envelope("getdevinfo"); } /** 返回解释设备时间用的现场时区标识,供逐点行按来源保存时区。 */ public String timezoneId() { return deviceZone.getId(); } /** * 构造按通道批量查询实时快照的请求报文({@code inquire});只接受属于本实例的通道身份。 */ public String inquireRequest(Set channels, boolean includeBarcode) { StringBuilder list = new StringBuilder("inquire"); for (ChannelIdentity channel : channels) { requireOwned(channel); list.append("true"); } return envelope(list.append("").toString()); } /** * 构造通道数据文件状态的请求报文({@code inquiredf}):返回该通道当前测试号与已落盘行数。 * *

{@code testid="0"} 表示"该通道最近一次测试",实测应答会把真实测试号回显回来。 * 行数随测试进行实时增长,因此它是逐点接收的增量水位。 */ public String inquiredfRequest(ChannelIdentity channel) { requireOwned(channel); return envelope("inquiredf"); } /** * 构造逐点下载的请求报文({@code download}),分页取 {@code [startPosition, …]} 起 {@code count} 行。 * *

{@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("download"); } /** * 解析 {@code inquiredf_resp},取出该通道当前测试号与已落盘行数。 * *

现场实测应答形如 * {@code false}。 * 末尾的布尔值是厂商自报的"该测试数据是否已传完";{@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})为逐点行。 * *

每条 {@code } 是设备采样行原文:{@code seqid}/{@code stepid}/{@code cycleid}/ * {@code steptype}/{@code testtime}/{@code atime}/{@code volt}/{@code curr}/{@code cap}/{@code eng}。 * 与 {@code inquire} 相比,这里的测量值是**全精度**、且带**绝对时间**,所以测量值与时间轴取这里。 * *

约定与实测口径:{@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 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 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) { } /** 解析 {@code getdevinfo_resp},按本实例的适配器编码生成检测柜通道清单。 */ public List parseDeviceInfo(String xml) { Document document = parse(xml, "getdevinfo_resp"); NodeList nodes = firstElement(document, "middle").getElementsByTagName("channel"); List channels = new ArrayList<>(nodes.getLength()); for (int i = 0; i < nodes.getLength(); i++) { Element element = (Element) nodes.item(i); int chlid = integerAttribute(element, element.hasAttribute("Channelid") ? "Channelid" : "chlid"); ChannelIdentity identity = new ChannelIdentity(adapterCode, integerAttribute(element, "devtype"), integerAttribute(element, "devid"), integerAttribute(element, "subdevid"), chlid); channels.add(new DetectedChannel(identity, requiredAttribute(element, "ip"), booleanText(element.getTextContent(), "available"), new DetectedDeviceMetadata(optionalAttribute(element, "name"), optionalAttribute(element, "manufacturer"), optionalAttribute(element, "model"), optionalAttribute(element, "mac")))); } return List.copyOf(channels); } /** * 解析 {@code inquire_resp} 并按通道身份归集实时快照;{@code receivedAt} 记为服务器接收时间。 * *

身份取应答里的 {@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 parseSnapshots(String xml, Instant receivedAt) { return parseSnapshots(xml, null, receivedAt); } /** * 解析 {@code inquire_resp},并把结果归到**请求时用的通道身份**上。 * *

归集按「{@code devid} + {@code subdevid} + {@code chlid}」匹配:服务端会改写 {@code devtype} * (设备类型是服务端侧的登记事实,与它回报的取值不总一致),而单元与通道号在应答里是稳的。 * 这使调用方用自己登记的身份就能取回快照,不会因为服务端改写设备类型而拿到空值。 * * @param xml 应答原文 * @param requested 本次请求的通道集合;为空时按应答自己的身份归集 * @param receivedAt 服务器接收时间 * @return 按通道身份归集的实时快照 */ public Map parseSnapshots(String xml, Set requested, Instant receivedAt) { Document document = parse(xml, "inquire_resp"); NodeList nodes = firstElement(document, "list").getElementsByTagName("inquire"); Map requestedByVendorKey = new HashMap<>(); if (requested != null) requested.forEach(channel -> requestedByVendorKey.put(vendorKey(channel), channel)); Map snapshots = new LinkedHashMap<>(); for (int i = 0; i < nodes.getLength(); i++) { Element element = (Element) nodes.item(i); String[] device = requiredAttribute(element, "dev").split("-"); if (device.length < 4) throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid BTS dev identity: " + element.getAttribute("dev")); 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"), 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... expectedCommands) { try { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); DocumentBuilder builder = factory.newDocumentBuilder(); builder.setErrorHandler(new DefaultHandler()); Document document = builder.parse(new InputSource(new StringReader(xml))); String command = firstElement(document, "cmd").getTextContent().trim(); if (!Set.of(expectedCommands).contains(command)) throw new BtsProtocolException(BtsProtocolException.Reason.UNEXPECTED_COMMAND, "Expected BTS command " + List.of(expectedCommands) + " but received " + command); return document; } catch (BtsProtocolException ex) { throw ex; } catch (Exception ex) { throw new BtsProtocolException(BtsProtocolException.Reason.MALFORMED_XML, "Invalid BTS XML response", ex); } } private Map dynamicMeasurements(Element element, Set knownAttributes) { Map measurements = new HashMap<>(); NamedNodeMap attributes = element.getAttributes(); for (int i = 0; i < attributes.getLength(); i++) { Node attribute = attributes.item(i); if (!knownAttributes.contains(attribute.getNodeName())) { BigDecimal value = decimal(attribute.getNodeValue(), attribute.getNodeName()); measurements.put(attribute.getNodeName(), new Measurement(attribute.getNodeName(), value, null, "", "AUX")); } } NodeList dbcNodes = element.getElementsByTagName("DBC"); for (int i = 0; i < dbcNodes.getLength(); i++) { Element dbc = (Element) dbcNodes.item(i); String code = "DBC:" + requiredAttribute(dbc, "message_name") + ":" + requiredAttribute(dbc, "signal_name"); measurements.put(code, new Measurement(code, decimal(requiredAttribute(dbc, "value"), code), null, optionalAttribute(dbc, "unit"), "DBC")); } return Map.copyOf(measurements); } private Instant parseDeviceTime(String value) { try { // BTS 时间不带时区,按适配器配置的现场时区解释后统一转换为 UTC 时间点。 try { return Instant.parse(value); } catch (DateTimeParseException ignored) { return LocalDateTime.parse(value, BTS_TIME).atZone(deviceZone).toInstant(); } } catch (DateTimeParseException ex) { throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid BTS device time: " + value, ex); } } private Instant optionalDeviceTime(Element element) { for (String name : List.of("device_time", "deviceTime", "time", "atime", "testtime")) { String value = optionalAttribute(element, name); if (value != null) return parseDeviceTime(value); } return null; } private List parsePlannedSteps(Element channel) { List steps = new ArrayList<>(); NodeList nodes = channel.getElementsByTagName("plannedStep"); if (nodes.getLength() == 0) nodes = channel.getElementsByTagName("planStep"); if (nodes.getLength() == 0) nodes = channel.getElementsByTagName("step"); for (int i = 0; i < nodes.getLength(); i++) { Element step = (Element) nodes.item(i); String number = firstOptionalAttribute(step, "step_no", "stepNo", "step_id", "stepid", "id"); String type = firstOptionalAttribute(step, "step_type", "stepType", "steptype", "type"); if (number == null || type == null) continue; steps.add(new PlannedStep(integer(number, "planned step number"), type, firstOptionalAttribute(step, "name", "step_name", "stepName"), optionalDecimalFromNames(step, "target_voltage", "targetVoltage", "voltage"), optionalDecimalFromNames(step, "target_current", "targetCurrent", "current"), optionalLongFromNames(step, "duration_seconds", "durationSeconds", "duration"))); } return List.copyOf(steps); } private static String firstOptionalAttribute(Element element, String... names) { for (String name : names) { String value = optionalAttribute(element, name); if (value != null) return value; } return null; } private static BigDecimal optionalDecimalFromNames(Element element, String... names) { String value = firstOptionalAttribute(element, names); return value == null ? null : decimal(value, names[0]); } private static Long optionalLongFromNames(Element element, String... names) { String value = firstOptionalAttribute(element, names); if (value == null) return null; try { return Long.parseLong(value); } catch (NumberFormatException ex) { throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid planned step duration: " + value, ex); } } private void requireOwned(ChannelIdentity channel) { if (!adapterCode.equals(channel.adapterCode())) throw new IllegalArgumentException("Channel belongs to another adapter"); } private static String envelope(String body) { return "" + body + ""; } private static String escape(String value) { return value.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"); } private static Element firstElement(Document document, String tag) { NodeList elements = document.getElementsByTagName(tag); if (elements.getLength() == 0) throw new BtsProtocolException(BtsProtocolException.Reason.MISSING_FIELD, "Missing BTS element: " + tag); return (Element) elements.item(0); } /** 可缺省的元素:{@code download} 在空应答里不出现(只有 {@code })。 */ 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); if (value == null || value.isBlank()) throw new BtsProtocolException(BtsProtocolException.Reason.MISSING_FIELD, "Missing BTS attribute: " + name); return value.trim(); } private static String optionalAttribute(Element element, String name) { String value = element.getAttribute(name); return value == null || value.isBlank() ? null : value.trim(); } private static int integerAttribute(Element element, String name) { return integer(requiredAttribute(element, name), name); } private static Integer optionalInteger(Element element, String name) { String value = optionalAttribute(element, name); return value == null ? null : integer(value, name); } /** * 解析以**秒**为单位、但可能带小数的属性({@code totaltime}/{@code relativetime})。 * *

实测应答给的是 {@code totaltime="37737.6"} 这种小数秒,早期实现按整数解析会在这里 * 直接判成非法取值而使整次观测失败——所以这里按十进制读,四舍五入到秒。 */ private static Long optionalSeconds(Element element, String name) { 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.trim()); } catch (NumberFormatException ex) { throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid integer BTS attribute: " + name, ex); } } /** * 解析逐点行的设备绝对时间。{@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 { return Integer.parseInt(value); } catch (NumberFormatException ex) { throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid integer BTS attribute: " + name, ex); } } private static BigDecimal optionalDecimal(Element element, String name) { String value = optionalAttribute(element, name); return value == null ? null : decimal(value, name); } private static BigDecimal decimal(String value, String name) { try { return new BigDecimal(value); } catch (NumberFormatException ex) { throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid decimal BTS attribute: " + name, ex); } } private static boolean booleanText(String value, String name) { String normalized = value == null ? "" : value.trim(); if ("true".equalsIgnoreCase(normalized)) return true; if ("false".equalsIgnoreCase(normalized)) return false; throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid boolean BTS value: " + name); } }