| | |
| | | |
| | | 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 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; |
| | |
| | | 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) |
| | | { |
| | |
| | | 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("\">"); |
| | |
| | | .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"); |
| | | // 单项容量能量只在**充放电方向**给出:接口的 cap/eng 是单一值,实测它对搁置步给的是 |
| | | // 跨步过渡值(C 那台实测搁置步给 +0.290039804444444,等于上一步充电的容量;还有一次取负 |
| | | // −0.178807573333333),并不是该步自身累计。照抄会写出"搁置步容量 0.29 Ah"这种不成立的事实; |
| | | // 而该步自身累计为 0 这件事设备没给,所以留空而不是补零(ADR-0069 §3)。 |
| | | boolean singleValued = direction == StepDirectionResolver.Direction.CHARGE |
| | | || direction == StepDirectionResolver.Direction.DISCHARGE; |
| | | 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"), |
| | | singleValued && capacity != null ? capacity.doubleValue() : null, |
| | | singleValued && energy != null ? energy.doubleValue() : null, |
| | | rawTime, zoneId, receivedAt, stepMeasurements(data, durationMillis, capacity, energy))); |
| | | } |
| | | return List.copyOf(steps); |
| | | } |
| | | |
| | | /** |
| | | * 工步的设备事实明细;映射表按已有键名口径命名,值全部来自应答原文。 |
| | | * |
| | | * <p>应答给的 {@code cap}/{@code eng} 在搁置步是跨步过渡值(见上),所以明细里也按方向归属: |
| | | * 充放步留 {@code cap}/{@code eng} 原文,其余方向不留——明细与单项列不得互相矛盾。 |
| | | */ |
| | | private static Map<String, BigDecimal> stepMeasurements(Element data, Long durationMillis, |
| | | BigDecimal capacity, BigDecimal energy) |
| | | { |
| | | 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"); |
| | | put(measurements, data, "endVoltage", "endvolt"); |
| | | put(measurements, data, "endCurrent", "endcurr"); |
| | | if (capacity != null) measurements.put("capacity", capacity); |
| | | if (energy != null) measurements.put("energy", energy); |
| | | 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); |
| | | } |
| | | |
| | | |
| | |
| | | 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++) |
| | | { |
| | |
| | | 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 |
| | | { |
| | |
| | | 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) |
| | |
| | | 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); |
| | |
| | | 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) |
| | | { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 解析逐点行的设备绝对时间。{@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 |