package com.battery.device.bts; import java.io.StringReader; import java.math.BigDecimal; 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.model.ChannelIdentity; import com.battery.device.model.DetectedChannel; import com.battery.device.model.DetectedDeviceMetadata; import com.battery.device.model.DeviceWorkStatus; import com.battery.device.model.HistoryAvailability; import com.battery.device.model.HistoryPage; import com.battery.device.model.HistorySample; import com.battery.device.model.Measurement; import com.battery.device.model.RealtimeSnapshot; public final class BtsXmlCodec { private static final DateTimeFormatter BTS_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"); private static final Set HISTORY_ATTRIBUTES = Set.of( "seqid", "stepid", "cycleid", "steptype", "testtime", "atime", "volt", "curr", "cap", "eng"); private final String adapterCode; private final ZoneId deviceZone; 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; } public String getDeviceInfoRequest() { return envelope("getdevinfo"); } 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()); } /** * 查询各通道当前 BTS 测试的历史数据上传进度。 */ public String inquireHistoryRequest(Set channels) { StringBuilder list = new StringBuilder("inquiredf"); for (ChannelIdentity channel : channels) { requireOwned(channel); list.append(""); } return envelope(list.append("").toString()); } public String downloadRequest(ChannelIdentity channel, String btsTestId, long startPosition, int limit) { requireOwned(channel); if (btsTestId == null || btsTestId.isBlank()) throw new IllegalArgumentException("btsTestId must not be blank"); if (startPosition < 1) throw new IllegalArgumentException("startPosition must be positive"); if (limit < 1 || limit > 1000) throw new IllegalArgumentException("BTS history limit must be between 1 and 1000"); return envelope("download"); } 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); } public Map parseSnapshots(String xml, Instant receivedAt) { Document document = parse(xml, "inquire_resp"); NodeList nodes = firstElement(document, "list").getElementsByTagName("inquire"); 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 identity = new ChannelIdentity(adapterCode, integer(device[0], "devtype"), integer(device[1], "devid"), integer(device[2], "subdevid"), integer(device[3], "chlid")); 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"), optionalAttribute(element, "log_code"), receivedAt, dynamicMeasurements(element, SNAPSHOT_ATTRIBUTES))); } return Map.copyOf(snapshots); } /** * 将 BTS 历史清单映射为厂商无关的通道历史可用性。 */ public List parseHistoryAvailability(String xml) { Document document = parse(xml, "inquiredf_resp"); NodeList nodes = firstElement(document, "list").getElementsByTagName("chl"); List availability = new ArrayList<>(nodes.getLength()); for (int i = 0; i < nodes.getLength(); i++) { Element element = (Element) nodes.item(i); ChannelIdentity channel = new ChannelIdentity(adapterCode, integerAttribute(element, "devtype"), integerAttribute(element, "devid"), integerAttribute(element, "subdevid"), integerAttribute(element, "chlid")); availability.add(new HistoryAvailability(channel, requiredAttribute(element, "testid"), longAttribute(element, "count"), booleanText(element.getTextContent(), "uploadComplete"))); } return List.copyOf(availability); } public HistoryPage parseHistory(String xml, ChannelIdentity channel, String requestedTestId, long requestedStart) { Document document = parse(xml, "download_resp"); Element download = firstElement(document, "download"); String btsTestId = requiredAttribute(download, "testid"); // download 是单通道响应,身份不一致时拒绝结果,避免历史事实被归入错误测试。 if (integerAttribute(download, "devtype") != channel.devtype() || integerAttribute(download, "devid") != channel.devid() || integerAttribute(download, "subdevid") != channel.subdevid() || integerAttribute(download, "chlid") != channel.chlid() || !btsTestId.equals(requestedTestId)) throw new BtsProtocolException(BtsProtocolException.Reason.IDENTITY_MISMATCH, "BTS history response identity does not match the request"); NodeList nodes = firstElement(document, "list").getElementsByTagName("data"); List samples = new ArrayList<>(nodes.getLength()); long next = requestedStart; for (int i = 0; i < nodes.getLength(); i++) { Element element = (Element) nodes.item(i); long sequence = longAttribute(element, "seqid"); String rawTime = requiredAttribute(element, "atime"); samples.add(new HistorySample(sequence, integerAttribute(element, "stepid"), integerAttribute(element, "cycleid"), requiredAttribute(element, "steptype"), optionalLong(element, "testtime"), parseDeviceTime(rawTime), rawTime, optionalDecimal(element, "volt"), optionalDecimal(element, "curr"), optionalDecimal(element, "cap"), optionalDecimal(element, "eng"), dynamicMeasurements(element, HISTORY_ATTRIBUTES))); // 解析层保留重复和乱序事实;业务层只展示并记录这些设备数据事实。 next = Math.max(next, sequence + 1); } return new HistoryPage(channel, btsTestId, requestedStart, next, samples); } private Document parse(String xml, String expectedCommand) { 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 (!expectedCommand.equals(command)) throw new BtsProtocolException(BtsProtocolException.Reason.UNEXPECTED_COMMAND, "Expected BTS command " + expectedCommand + " 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 时间点。 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 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); } 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 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) { String value = optionalAttribute(element, name); if (value == null) return null; try { return Long.parseLong(value); } catch (NumberFormatException ex) { throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, "Invalid integer BTS attribute: " + name, 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); } }