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.TestDataObservationAdapter.StepRecord;
|
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<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)
|
{
|
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("<cmd>getdevinfo</cmd>");
|
}
|
|
/** 返回解释设备时间用的现场时区标识,供逐点行按来源保存时区。 */
|
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("\">");
|
for (ChannelIdentity channel : channels)
|
{
|
requireOwned(channel);
|
list.append("<inquire ip=\"127.0.0.1\" devtype=\"").append(channel.devtype())
|
.append("\" devid=\"").append(channel.devid())
|
.append("\" subdevid=\"").append(channel.subdevid())
|
.append("\" chlid=\"").append(channel.chlid())
|
.append("\" aux=\"0\" barcode=\"").append(includeBarcode ? 1 : 0).append("\">true</inquire>");
|
}
|
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) { }
|
|
/**
|
* 构造工步层下载的请求报文({@code downloadStepLayer})。
|
*
|
* <p>现场实测:只发命令与 {@code downloadStepLayer} 元素即可,文档里的 {@code V1I1}/{@code V2I2}
|
* 不填时按文档所说"用默认参数";{@code dcir="0"}"不计算 DCIR",我们只要工步事实、不做派生计算。
|
* 与 {@code download} 一样,{@code testid="0"} 表示"该通道最近一次测试",应答会回显真实测试号。
|
*/
|
public String downloadStepLayerRequest(ChannelIdentity channel)
|
{
|
requireOwned(channel);
|
return envelope("<cmd>downloadStepLayer</cmd><downloadStepLayer devtype=\"" + channel.devtype()
|
+ "\" devid=\"" + channel.devid() + "\" subdevid=\"" + channel.subdevid()
|
+ "\" chlid=\"" + channel.chlid() + "\" testid=\"0\" dcir=\"0\"/>");
|
}
|
|
/**
|
* 解析 {@code downloadStepLayer_resp} 为工步记录。
|
*
|
* <p>每条 {@code <data/>} 是一个工步:{@code startseqid}/{@code endseqid}(该工步的首末数据序号)、
|
* {@code stepindex}(测试内的工步序号)、{@code stepid}(原始工步号,循环里会重复)、
|
* {@code cycleid}、{@code steptype}、{@code steptime}(工步运行时长,**毫秒**)、{@code endatime}
|
* (工步绝对结束时间)、{@code startvolt}/{@code endvolt}、{@code startcurr}/{@code endcurr}、
|
* {@code cap}/{@code eng}、{@code dcir}(mΩ)。
|
*
|
* <p>三处按实测钉死的口径:
|
*
|
* <ul>
|
* <li><b>循环号要减一。</b>这条命令的 {@code cycleid} 是 **1 起**(第一步 {@code cycleid="1"}),
|
* 而 {@code download} 的 {@code cycleid} 与厂商库的 {@code cycle} 都是 **0 起**。
|
* 实测同一测试两处对照(工步层 {@code cycleid="1"}/逐点 {@code cycleid="0"})确认,
|
* 所以这里统一成 0 起,与库路线、逐点路线同口径。</li>
|
* <li><b>开始时间 = 结束时间 − 工步时长。</b>应答只给 {@code endatime}。按两个独立样本核对过
|
* ({@code endatime=09:09:14, steptime=23100 ms} → 09:08:50.9,该测试首行 {@code atime}=09:08:51),
|
* 所以这个减法还原的是设备事实,不是估算。缓冲里缺行时(实测有测试只覆盖约 1 天)
|
* 相邻两工步的首末不会接上,那是来源的数据缺口,不是这里算错。</li>
|
* <li><b>{@code stepindex} 才是工步身份。</b>{@code stepid} 在循环里会重复(同一步在每轮循环再跑一遍),
|
* 用它做身份会撞键。</li>
|
* </ul>
|
*
|
* <p>方向的判定沿用既有解析器;{@code cap}/{@code eng} 在接口上是**单一值**(不分充放),
|
* 只在方向已确认时给出,方向未知时留空——不按数值大小猜它属于哪一侧(ADR-0069 §3)。
|
*
|
* @param xml 应答原文
|
* @param receivedAt 服务器接收时间
|
* @return 按应答顺序的工步记录;该通道缓冲里没有测试时为空列表
|
* @throws BtsProtocolException 应答不是 {@code downloadStepLayer_resp}
|
*/
|
public List<StepRecord> parseStepLayer(String xml, Instant receivedAt)
|
{
|
Document document = parse(xml, "downloadStepLayer_resp");
|
NodeList nodes = document.getElementsByTagName("data");
|
if (nodes.getLength() == 0) return List.of();
|
Element header = optionalElement(document, "downloadStepLayer");
|
String testId = header == null ? "" : optionalAttribute(header, "testid");
|
String zoneId = deviceZone.getId();
|
List<StepRecord> steps = new ArrayList<>(nodes.getLength());
|
for (int i = 0; i < nodes.getLength(); i++)
|
{
|
Element data = (Element) nodes.item(i);
|
String stepType = optionalAttribute(data, "steptype");
|
StepDirectionResolver.Direction direction = directions.resolve(stepType);
|
String rawTime = requiredAttribute(data, "endatime");
|
Instant endedAt = parseDataTime(rawTime);
|
Long durationMillis = optionalDecimal(data, "steptime") == null ? null
|
: optionalDecimal(data, "steptime").longValue();
|
int stepIndex = integer(requiredAttribute(data, "stepindex"), "stepindex");
|
// 应答里的 cycleid 是 1 起,逐点与厂商库都是 0 起;统一成 0 起。
|
int cycleNo = requiredInteger(data, "cycleid") - 1;
|
BigDecimal capacity = optionalDecimal(data, "cap");
|
BigDecimal energy = optionalDecimal(data, "eng");
|
boolean directionKnown = direction != StepDirectionResolver.Direction.UNKNOWN;
|
steps.add(new StepRecord(testId.isBlank() ? null : "neware:" + testId + ":step:" + stepIndex, cycleNo,
|
requiredInteger(data, "stepid"), direction.displayName(), stepType,
|
durationMillis == null ? endedAt : endedAt.minusMillis(durationMillis),
|
endedAt, doubleValue(data, "endvolt"), doubleValue(data, "endcurr"),
|
directionKnown && capacity != null ? capacity.doubleValue() : null,
|
directionKnown && energy != null ? energy.doubleValue() : null,
|
rawTime, zoneId, receivedAt, stepMeasurements(data, durationMillis)));
|
}
|
return List.copyOf(steps);
|
}
|
|
/** 工步的设备事实明细;映射表按已有键名口径命名,值全部来自应答原文。 */
|
private static Map<String, BigDecimal> stepMeasurements(Element data, Long durationMillis)
|
{
|
Map<String, BigDecimal> measurements = new LinkedHashMap<>();
|
if (durationMillis != null) measurements.put("stepDuration", BigDecimal.valueOf(durationMillis).movePointLeft(3));
|
put(measurements, data, "startSeqId", "startseqid");
|
put(measurements, data, "endSeqId", "endseqid");
|
put(measurements, data, "startVoltage", "startvolt");
|
put(measurements, data, "startCurrent", "startcurr");
|
put(measurements, data, "dcir", "dcir");
|
return Map.copyOf(measurements);
|
}
|
|
private static void put(Map<String, BigDecimal> target, Element element, String key, String attribute)
|
{
|
BigDecimal value = optionalDecimal(element, attribute);
|
if (value != null) target.put(key, value);
|
}
|
|
private static int requiredInteger(Element element, String name)
|
{
|
return integer(requiredAttribute(element, name), name);
|
}
|
|
|
|
/** 解析 {@code getdevinfo_resp},按本实例的适配器编码生成检测柜通道清单。 */
|
public List<DetectedChannel> parseDeviceInfo(String xml)
|
{
|
Document document = parse(xml, "getdevinfo_resp");
|
NodeList nodes = firstElement(document, "middle").getElementsByTagName("channel");
|
List<DetectedChannel> 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} 记为服务器接收时间。
|
*
|
* <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++)
|
{
|
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<String, Measurement> dynamicMeasurements(Element element, Set<String> knownAttributes)
|
{
|
Map<String, Measurement> 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<PlannedStep> parsePlannedSteps(Element channel)
|
{
|
List<PlannedStep> 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 "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">" + body + "</bts>";
|
}
|
|
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 <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);
|
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})。
|
*
|
* <p>实测应答给的是 {@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);
|
}
|
}
|