新威对外接口改为后端直连管道,撤销机器端取数程序
ADR-0068 §3 原以为命名管道仅本机可达,于是把取数形态定成"每台机器上跑一个
只读取数程序"。实测该前提不成立:管道可跨机访问(\<主机>\pipe\NewareBtsAPI),
先建一条 SMB 会话即可,而这条会话后端自己能建。ADR-0070 据此把形态改为后端直连,
并取代 ADR-0069(那套推式载荷契约随之撤销)。
- 新增适配器类型 NEWARE_BTS 与 NewareBtsPipeTransport:首次使用某主机时用
net.exe use 建 SMB 会话,之后每次请求新建连接、按块读到 \n\n、收到应答即关
(实测一条连接只服务一条请求,复用会永久挂住)。
- 读取放在单线程执行器里带超时等待:RandomAccessFile 没有读超时,超时从另一个
线程关掉在途句柄把读打断,使"挂住"变成可分类的失败。
- 只读白名单收敛到 NewareBtsReadOnlyCommands 一处,在写出之前拒绝控制命令。
- 按实测修协议实现:totaltime/relativetime 是小数秒;download 的应答被厂商拼成
donwload_resp;atime 用空格分隔;inquire_resp 的 dev 是五段。
- 归集按请求的通道身份而不是应答里的:实测服务端会改写应答的 devtype
(getdevinfo 报 27-76,inquire 答 25-76-1-1-0),按应答归集会让通道查不到实时值。
- 交出实时值、通道工作状态与逐点(全精度 + 绝对时间);工步层、循环汇总、辅助测量
与计划方案如实为空,不用空值或推算冒充已知。
- 登记 neware-c/d/e/f 四个直连实例、撤掉五个推式实例;机器 A(7.6 客户端不支持
inquiredf/download)继续用 NEWARE_MYSQL,一台机器只留一个来源实例。
- 凭据沿用"本地凭据文件 → 环境变量 → 启动参数",source_config 只放变量名;
本机管理员口令按机器分别给键、不设缺省值。
后端全量测试 248 项通过;D 与 F 两台实机用生产代码端到端验过(发现、实时、水位、
增量逐点、控制命令被拒)。
| | |
| | | * 适配器类型按存储形态划分;BTS 保留给开发模拟器与将来的协议路线(ADR-0065)。 |
| | | * `NEWARE_MYSQL` 只能写字面量:battery-business 不依赖 battery-device-neware, |
| | | * 拿不到那边的常量;推式来源的实现就在本模块,所以用它的常量。 |
| | | * `NEWARE_BTS` 是直连新威对外接口(命名管道)的形态,常量同样取不到,写字面量。 |
| | | */ |
| | | private static final List<String> SUPPORTED_TYPES = List.of("BTS", "NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE); |
| | | private static final List<String> SUPPORTED_TYPES = |
| | | List.of("BTS", "NEWARE_BTS", "NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE); |
| | | /** |
| | | * 通道清单长期存在的来源,候选不按时间过期:直连厂商存储的来源读的是厂商的登记表, |
| | | * 推式来源读的是取数程序上报的拓扑——两者都不是"轮询发现"的结果。 |
| | | * 通道清单长期存在的来源,候选不按时间过期:直连厂商存储与直连厂商接口的来源读的都是厂商的登记事实, |
| | | * 推式来源读的是取数程序上报的拓扑——三者都不是"轮询发现"的结果。 |
| | | */ |
| | | private static final List<String> PERSISTENT_CANDIDATE_TYPES = |
| | | List.of("NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE); |
| | | List.of("NEWARE_BTS", "NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE); |
| | | |
| | | private final TDeviceAdapterMapper mapper; |
| | | private final DeviceAdapterRegistry registry; |
| | |
| | | case "TIMEOUT" -> ObservationFailureReason.TIMEOUT; |
| | | case "DISCONNECTED" -> ObservationFailureReason.DISCONNECTED; |
| | | case "UNSUPPORTED" -> ObservationFailureReason.UNSUPPORTED; |
| | | // 只读白名单拒绝(ADR-0068)不是"传输坏了",而是这条请求本就不该发:归为不支持。 |
| | | case "READ_ONLY_REJECTED" -> ObservationFailureReason.UNSUPPORTED; |
| | | case "MALFORMED_XML", "MISSING_FIELD", "INVALID_VALUE", "IDENTITY_MISMATCH", |
| | | "UNEXPECTED_COMMAND" -> ObservationFailureReason.PARSE_FAILED; |
| | | default -> ObservationFailureReason.TRANSPORT_FAILED; |
| | |
| | | public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels) |
| | | { |
| | | if (channels == null || channels.isEmpty()) return Map.of(); |
| | | return codec.parseSnapshots(transport.exchange(codec.inquireRequest(channels, false)), Instant.now()); |
| | | // 带上请求集合:服务端会改写应答里的 devtype,按请求的身份归集才拿得到自己要的通道。 |
| | | return codec.parseSnapshots(transport.exchange(codec.inquireRequest(channels, false)), channels, |
| | | Instant.now()); |
| | | } |
| | | |
| | | |
| | |
| | | DISCONNECTED, |
| | | RESPONSE_TOO_LARGE, |
| | | INVALID_UTF8, |
| | | IO_FAILURE |
| | | IO_FAILURE, |
| | | /** 请求不在只读命令白名单内,在代码层被拒绝发出(ADR-0068)。 */ |
| | | READ_ONLY_REJECTED |
| | | } |
| | | |
| | | private final Reason reason; |
| | |
| | | |
| | | 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.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("\">"); |
| | |
| | | } |
| | | 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) { } |
| | | |
| | | |
| | | |
| | |
| | | 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 |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.Instant; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.StepDirectionResolver; |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DetectedChannel; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | | |
| | | /** |
| | | * 新威 BTS 对外接口的只读适配器:走远程命名管道,一台上位机一个实例(ADR-0068)。 |
| | | * |
| | | * <p>它同时实现观测契约与测试数据观测契约,使这条路线直接接上既有的接收与展示路径, |
| | | * 不需要在业务层为新威另开一条路。各命令的分工是现场实测定下来的: |
| | | * |
| | | * <ul> |
| | | * <li>{@code getdevinfo} → 通道登记事实(发现);</li> |
| | | * <li>{@code inquire} → 实时值与**通道工作状态**(读库路线拿不到工作状态,这是本路线的主要收获);</li> |
| | | * <li>{@code inquiredf} → 当前测试号与已落盘行数,后者是逐点接收的增量水位;</li> |
| | | * <li>{@code download} → 逐点行。它给全精度测量值**和绝对时间**,所以测量值与时间轴取它, |
| | | * 而不取 {@code inquire}(后者是 4 位小数、且没有绝对时间)。</li> |
| | | * </ul> |
| | | * |
| | | * <p><b>不交出的三类事实</b>(都不猜、不用空值冒充已知): |
| | | * |
| | | * <ul> |
| | | * <li><b>工步层与循环汇总</b>:协议里读工步层要 {@code downloadStepLayer},该命令**未验证**, |
| | | * 所以这边为空;它们属于该机厂商库那条路(ADR-0068 §5、未决 1)。</li> |
| | | * <li><b>辅助通道 / 采集器测量</b>:ADR-0068 已复核不可得(该机 {@code aux_data_*} 全空、 |
| | | * API 的 {@code auxtemp}/{@code auxvol} 为 0),所以 {@link #latestCollector} 返回空值。</li> |
| | | * <li><b>计划方案</b>:协议里没有读方案的命令(现场实测 {@code inquire_resp} 不带计划), |
| | | * 方案来自方案文件与厂商库表,因此这里不提供。</li> |
| | | * </ul> |
| | | * |
| | | * <p><b>空闲通道的读数不进本适配器的持久化路径</b>:实测空闲通道的 {@code inquire} 电压是悬空负值 |
| | | * (≈ −2.4 / −3.4 V),而该通道库内从无负值。逐点行({@code download})只在测试进行中有意义, |
| | | * 所以按行接收天然只覆盖测试期;实时值本身仍可用于展示。 |
| | | */ |
| | | public final class NewareBtsAdapter implements DeviceAdapter, TestDataObservationAdapter |
| | | { |
| | | /** 该存储形态的适配器类型标识,登记实例时用它匹配本工厂。 */ |
| | | public static final String ADAPTER_TYPE = "NEWARE_BTS"; |
| | | /** 厂商采样周期实测为 60 秒;实时持久化槽位按它对齐,取数不必更快。 */ |
| | | public static final long SAMPLE_INTERVAL_MS = 60_000L; |
| | | |
| | | private final String adapterCode; |
| | | private final BtsTransport transport; |
| | | private final BtsXmlCodec codec; |
| | | private final StepDirectionResolver directions = new BtsStepDirectionResolver(); |
| | | private final AdapterCapabilities capabilities; |
| | | |
| | | public NewareBtsAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec) |
| | | { |
| | | this(adapterCode, transport, codec, new AdapterCapabilities(true, true, true, false)); |
| | | } |
| | | |
| | | public NewareBtsAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec, |
| | | AdapterCapabilities capabilities) |
| | | { |
| | | this.adapterCode = adapterCode; |
| | | this.transport = transport; |
| | | this.codec = codec; |
| | | this.capabilities = capabilities; |
| | | } |
| | | |
| | | /** 返回本实例登记的适配器编码。 */ |
| | | @Override |
| | | public String adapterCode() |
| | | { |
| | | return adapterCode; |
| | | } |
| | | |
| | | /** 返回本实例登记的能力;本路线可发现、可观测实时值、可下载历史逐点,且始终不控制。 */ |
| | | @Override |
| | | public AdapterCapabilities capabilities() |
| | | { |
| | | return capabilities; |
| | | } |
| | | |
| | | /** 通过 {@code getdevinfo} 读取通道登记事实,作为发现候选返回。 */ |
| | | @Override |
| | | public List<DetectedChannel> discoverChannels() |
| | | { |
| | | return codec.parseDeviceInfo(transport.exchange(codec.getDeviceInfoRequest())); |
| | | } |
| | | |
| | | /** 按给定通道集合请求一次实时状态;通道为空时不发请求。 */ |
| | | @Override |
| | | public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels) |
| | | { |
| | | if (channels == null || channels.isEmpty()) return Map.of(); |
| | | // 带上请求集合:实测服务端会改写应答里的 devtype,按请求的身份归集才拿得到自己要的通道。 |
| | | return codec.parseSnapshots(transport.exchange(codec.inquireRequest(channels, false)), channels, |
| | | Instant.now()); |
| | | } |
| | | |
| | | /** |
| | | * 返回该通道最近一次实时观测。 |
| | | * |
| | | * <p>{@code inquire} 不带绝对时间与协议序号,所以这里的时间口径是**服务器接收时间**, |
| | | * 序号为 0;需要设备时间与真实序号的场合取 {@link #samplesSince}。 |
| | | */ |
| | | @Override |
| | | public CabinetRealtime latestCabinet(ChannelIdentity channel) |
| | | { |
| | | RealtimeSnapshot snapshot = inquire(Set.of(channel)).get(channel); |
| | | if (snapshot == null) return null; |
| | | return new CabinetRealtime(0L, snapshot.deviceTime(), value(snapshot.currentA()), value(snapshot.voltageV()), |
| | | value(snapshot.capacityAh()), value(snapshot.energyWh()), |
| | | snapshot.cycleNo() == null ? 0 : snapshot.cycleNo(), |
| | | snapshot.stepNo() == null ? 0 : snapshot.stepNo(), |
| | | directions.resolve(snapshot.stepType()).displayName(), snapshot.stepType(), snapshot.receivedAt(), |
| | | null, codec.timezoneId(), snapshot.relativeTimeSeconds(), null); |
| | | } |
| | | |
| | | /** 本路线的辅助测量不可得(ADR-0068 已复核),返回空值而不是零值。 */ |
| | | @Override |
| | | public CollectorRealtime latestCollector(String adapterCode, String collectorCode) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | /** 本路线的工步层需要未验证的 {@code downloadStepLayer},因此不交出工步统计。 */ |
| | | @Override |
| | | public List<StepRecord> stepRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | } |
| | | |
| | | /** 本路线的循环汇总同上:不交出,也不用实时行推导来源没有的汇总。 */ |
| | | @Override |
| | | public List<CycleRecord> cycleRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | } |
| | | |
| | | /** 返回厂商采样周期作为实时持久化槽位间隔。 */ |
| | | @Override |
| | | public long persistenceIntervalMillis(ChannelIdentity channel) |
| | | { |
| | | return SAMPLE_INTERVAL_MS; |
| | | } |
| | | |
| | | /** |
| | | * 读取该通道当前的接收水位:{@code inquiredf} 给测试号与已落盘行数, |
| | | * 再用 {@code download} 取最后一行拿到它的设备时间。 |
| | | * |
| | | * <p>行数就是最后一行的 {@code seqid}(实测一致),所以水位是"行数那一行"。 |
| | | * 该通道当前没有行时水位为 {@code (空时间, 0)}:契约规定空时间表示"这一刻来源里没有任何可用行, |
| | | * 之后出现的行全部属于本次接收"。 |
| | | * |
| | | * <p>客户端不提供 {@code inquiredf} 时(实测 7.6.0.1475 无此命令)返回空值, |
| | | * 表示本来源不按设备数据行接收,调用方回落到轮询槽位——这不是失败,是该构建的真实能力。 |
| | | */ |
| | | @Override |
| | | public SampleWatermark currentWatermark(ChannelIdentity channel) |
| | | { |
| | | BtsXmlCodec.DataFileState state; |
| | | try |
| | | { |
| | | state = dataFileState(channel); |
| | | } |
| | | catch (BtsProtocolException unsupported) |
| | | { |
| | | return null; |
| | | } |
| | | if (state.count() < 1) return new SampleWatermark(null, 0L); |
| | | List<CabinetRealtime> tail = download(channel, state.testId(), state.count(), 1); |
| | | if (tail.isEmpty()) return new SampleWatermark(null, 0L); |
| | | CabinetRealtime last = tail.get(0); |
| | | return new SampleWatermark(last.deviceTime(), last.sequenceId()); |
| | | } |
| | | |
| | | /** |
| | | * 返回严格晚于水位的逐点行,按 {@code startpos} 分页取到已落盘行数为止。 |
| | | * |
| | | * <p>{@code seqid} 在每个测试内从 1 重编:水位比现有行数还大说明该通道换了测试, |
| | | * 此时从第 1 行重读,并按设备时间把旧测试的行过掉(新测试的行时间必然晚于旧水位)。 |
| | | * |
| | | * @param channel 检测柜通道身份 |
| | | * @param watermark 上次取得的水位;为空表示尚未取水位,返回空列表而不并入设备历史 |
| | | */ |
| | | @Override |
| | | public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark) |
| | | { |
| | | if (watermark == null) return List.of(); |
| | | BtsXmlCodec.DataFileState state = dataFileState(channel); |
| | | if (state.count() < 1) return List.of(); |
| | | long next = watermark.sequenceId() >= state.count() ? 1L : watermark.sequenceId() + 1L; |
| | | List<CabinetRealtime> rows = new ArrayList<>(); |
| | | while (next <= state.count()) |
| | | { |
| | | int page = (int) Math.min(BtsXmlCodec.MAX_DOWNLOAD_PAGE, state.count() - next + 1); |
| | | List<CabinetRealtime> fetched = download(channel, state.testId(), next, page); |
| | | if (fetched.isEmpty()) break; |
| | | rows.addAll(fetched); |
| | | if (fetched.size() < page) break; |
| | | next = fetched.get(fetched.size() - 1).sequenceId() + 1L; |
| | | } |
| | | return rows.stream().filter(row -> isAfter(watermark, row)).toList(); |
| | | } |
| | | |
| | | /** 关闭底层传输连接。 */ |
| | | @Override |
| | | public void close() |
| | | { |
| | | transport.close(); |
| | | } |
| | | |
| | | private BtsXmlCodec.DataFileState dataFileState(ChannelIdentity channel) |
| | | { |
| | | return codec.parseDataFileState(transport.exchange(codec.inquiredfRequest(channel))); |
| | | } |
| | | |
| | | private List<CabinetRealtime> download(ChannelIdentity channel, long testId, long startPosition, int count) |
| | | { |
| | | return codec.parseDownload(transport.exchange(codec.downloadRequest(channel, testId, startPosition, count)), |
| | | Instant.now()); |
| | | } |
| | | |
| | | /** 行的序号比水位大就是要的行;序号反而更小说明换了测试,此时按设备时间判定。 */ |
| | | private static boolean isAfter(SampleWatermark watermark, CabinetRealtime row) |
| | | { |
| | | if (row.sequenceId() > watermark.sequenceId()) return true; |
| | | return watermark.deviceTime() != null && row.deviceTime() != null |
| | | && row.deviceTime().isAfter(watermark.deviceTime()); |
| | | } |
| | | |
| | | private static double value(BigDecimal source) |
| | | { |
| | | return source == null ? 0d : source.doubleValue(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import java.nio.file.Path; |
| | | import java.time.Duration; |
| | | import java.time.ZoneId; |
| | | import java.util.Map; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.DeviceAdapterException; |
| | | import com.battery.device.api.credentials.LocalCredentials; |
| | | import com.battery.device.api.registry.AdapterInstanceConfig; |
| | | import com.battery.device.api.registry.DeviceAdapterFactory; |
| | | |
| | | /** |
| | | * 按存储形态 {@code NEWARE_BTS} 构造**直连新威对外接口**的只读适配器实例(ADR-0068)。 |
| | | * |
| | | * <p>与 {@code NEWARE_MYSQL}(直连厂商库)并列:这台机器能不能远程取数,取决于它的上位机形态, |
| | | * 而不是厂商。8.0 形态的库内没有逐点数据,所以它的逐点只能从接口的 {@code download} 取。 |
| | | * |
| | | * <p><b>为什么口令不进数据库</b>:这里要的是那台机器的**本地管理员口令**——它和厂商库口令一样是来源凭据, |
| | | * 因此共用同一套取值顺序(本地凭据文件 → 同名环境变量 → 同名 JVM 启动参数,见 |
| | | * {@link LocalCredentials}),{@code source_config} 里只放变量名。三者都没有时装配失败, |
| | | * 错误信息同时点名文件路径与变量名,使"忘了放口令"是可见的失败而不是沉默的缺失。 |
| | | * |
| | | * <p>本类不缓存实例:实例由 {@code DeviceAdapterRegistry} 持有并按实例串行执行。 |
| | | */ |
| | | public final class NewareBtsAdapterFactory implements DeviceAdapterFactory |
| | | { |
| | | /** 实例配置里承载"本机管理员账号变量名"的键。 */ |
| | | public static final String USER_ENV_OPTION = "userEnv"; |
| | | /** 实例配置里承载"本机管理员口令变量名"的键。 */ |
| | | public static final String PASSWORD_ENV_OPTION = "passwordEnv"; |
| | | public static final String DEFAULT_USER_ENV = "NEWARE_WIN_USER"; |
| | | /** 建 SMB 会话是本地进程操作,超时给短一些:卡住也没有数据可取。 */ |
| | | private static final Duration SESSION_TIMEOUT = Duration.ofSeconds(10); |
| | | /** 读应答超时;{@code download} 一页上千行在 SMB 上会慢一些,但远不到这个量级。 */ |
| | | private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(60); |
| | | /** 凭据文件相对进程工作目录定位,使开发(仓库根)与交付(应用目录)用同一个位置。 */ |
| | | static final Path CREDENTIALS_FILE = LocalCredentials.DEFAULT_FILE; |
| | | |
| | | /** 返回本形态的适配器类型标识 {@code NEWARE_BTS}。 */ |
| | | @Override |
| | | public String adapterType() |
| | | { |
| | | return NewareBtsAdapter.ADAPTER_TYPE; |
| | | } |
| | | |
| | | /** 从实例配置取账号与口令来源,装配远程命名管道传输与报文编解码器。 */ |
| | | @Override |
| | | public DeviceAdapter create(AdapterInstanceConfig config) |
| | | { |
| | | Map<String, String> sourceConfig = config.sourceConfig(); |
| | | String user = LocalCredentials.resolve(option(sourceConfig, USER_ENV_OPTION, DEFAULT_USER_ENV), |
| | | CREDENTIALS_FILE.toAbsolutePath(), System.getenv(), System::getProperty); |
| | | String password = LocalCredentials.resolve(required(sourceConfig, PASSWORD_ENV_OPTION), |
| | | CREDENTIALS_FILE.toAbsolutePath(), System.getenv(), System::getProperty); |
| | | NewareBtsPipeTransport transport = new NewareBtsPipeTransport(config.host(), user, password, |
| | | SESSION_TIMEOUT, RESPONSE_TIMEOUT); |
| | | return new NewareBtsAdapter(config.adapterCode(), transport, |
| | | new BtsXmlCodec(config.adapterCode(), ZoneId.of(config.timezoneId())), config.capabilities()); |
| | | } |
| | | |
| | | private static String option(Map<String, String> sourceConfig, String key, String fallback) |
| | | { |
| | | String value = sourceConfig.get(key); |
| | | return value == null || value.isBlank() ? fallback : value.trim(); |
| | | } |
| | | |
| | | /** 口令变量名没有缺省值:五台机器的口令实测有两种,猜一个就会连错机器。 */ |
| | | private static String required(Map<String, String> sourceConfig, String key) |
| | | { |
| | | String value = option(sourceConfig, key, null); |
| | | if (value == null) throw new DeviceAdapterException("新威 BTS 实例缺少配置项 " + key |
| | | + "(本机管理员口令的变量名):口令有两种取值,不能有缺省"); |
| | | return value; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import java.io.ByteArrayOutputStream; |
| | | import java.io.FileNotFoundException; |
| | | import java.io.IOException; |
| | | import java.io.RandomAccessFile; |
| | | import java.nio.ByteBuffer; |
| | | import java.nio.charset.CharacterCodingException; |
| | | import java.nio.charset.CodingErrorAction; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.time.Duration; |
| | | import java.util.Locale; |
| | | import java.util.Objects; |
| | | import java.util.Set; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | import java.util.concurrent.ExecutionException; |
| | | import java.util.concurrent.ExecutorService; |
| | | import java.util.concurrent.Executors; |
| | | import java.util.concurrent.Future; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.concurrent.TimeoutException; |
| | | import java.util.concurrent.locks.ReentrantLock; |
| | | |
| | | /** |
| | | * 新威 BTS 对外接口的**远程命名管道**传输(ADR-0068)。 |
| | | * |
| | | * <p>现场实测的三条关键事实决定了这个类的形状: |
| | | * |
| | | * <ol> |
| | | * <li><b>接口是命名管道,不是 TCP。</b>协议文档的 TCP 小节在现场没有对应物; |
| | | * 可达的是服务端命名管道 {@code \\.\pipe\NewareBtsAPI}。跨机访问它要走 SMB, |
| | | * 所以这里用 {@code \\<主机>\pipe\NewareBtsAPI} 这个 Win32 路径, |
| | | * 并在打开管道前先建一条 SMB 会话。</li> |
| | | * <li><b>一条连接只服务一条请求。</b>实测在同一条连接上发第二条命令会永久挂住 |
| | | * (不报错、不返回)。所以每次请求都新建连接、收到应答即关闭,不做长连接复用。</li> |
| | | * <li><b>报文结束符是纯 {@code \n\n}。</b>文档里的 {@code #\r\n} 与 {@code #\r\n} 变体实测均无效 |
| | | * (后者零响应)。这里按实测取 {@code \n\n}。</li> |
| | | * </ol> |
| | | * |
| | | * <p><b>权限</b>:调用方需管理员身份,且 SMB 会话用的是那台机器的本地管理员账号—— |
| | | * 口令从本地凭据文件取({@link com.battery.device.api.credentials.LocalCredentials}),不进数据库、不进仓库。 |
| | | * 会话由本类用 {@code net.exe use} 建立:Java 无法给命名管道传凭据, |
| | | * 只有先有一条已认证的会话,路径才打得开。会话按"主机 + 账号 + 口令"缓存,进程内每条只建一次。 |
| | | * |
| | | * <p><b>只读边界</b>:出站请求先过 {@link NewareBtsReadOnlyCommands} 的白名单, |
| | | * 非只读命令在写进管道之前就被拒绝。 |
| | | * |
| | | * <p><b>读超时</b>:{@code RandomAccessFile} 没有读超时(管道挂住就是永久阻塞), |
| | | * 因此读取放在单线程执行器里、由调用方带超时等待;超时从另一个线程关掉在途句柄把读打断, |
| | | * 使"挂住"变成一次可分类的失败,而不是把调用线程钉死。 |
| | | */ |
| | | public final class NewareBtsPipeTransport implements BtsTransport |
| | | { |
| | | /** 服务端命名管道的名字;跨机时路径为 {@code \\<主机>\pipe\<名字>}。 */ |
| | | private static final String PIPE_NAME = "NewareBtsAPI"; |
| | | /** 实测的报文结束符(协议文档的 {@code #\r\n} 在本接口上无效)。 */ |
| | | private static final byte[] FRAME_END = "\n\n".getBytes(StandardCharsets.UTF_8); |
| | | private static final int DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; |
| | | private static final int MAX_SESSION_TIMEOUT_MS = 30_000; |
| | | private static final int MAX_RESPONSE_TIMEOUT_MS = 120_000; |
| | | private static final int READ_BUFFER_BYTES = 64 * 1024; |
| | | /** 已建立的 SMB 会话;键为「主机 + 账号 + 口令」的摘要,进程内复用。 */ |
| | | private static final Set<String> ESTABLISHED_SESSIONS = ConcurrentHashMap.newKeySet(); |
| | | |
| | | private final String host; |
| | | private final String user; |
| | | private final String password; |
| | | private final String pipePath; |
| | | private final String sessionKey; |
| | | private final int sessionTimeoutMs; |
| | | private final int responseTimeoutMs; |
| | | private final int maxResponseBytes; |
| | | private final ReentrantLock requestLock = new ReentrantLock(true); |
| | | /** 读取放在这里跑,使调用方能带超时等待;线程是守护线程,不拖住进程退出。 */ |
| | | private final ExecutorService reader = Executors.newSingleThreadExecutor(runnable -> { |
| | | Thread thread = new Thread(runnable, "neware-bts-pipe-reader"); |
| | | thread.setDaemon(true); |
| | | return thread; |
| | | }); |
| | | /** 当前在途的管道句柄:读超时时由调用线程关掉它,把阻塞中的读打断。 */ |
| | | private volatile RandomAccessFile inFlight; |
| | | |
| | | public NewareBtsPipeTransport(String host, String user, String password, |
| | | Duration sessionTimeout, Duration responseTimeout) |
| | | { |
| | | this(host, user, password, sessionTimeout, responseTimeout, DEFAULT_MAX_RESPONSE_BYTES); |
| | | } |
| | | |
| | | NewareBtsPipeTransport(String host, String user, String password, |
| | | Duration sessionTimeout, Duration responseTimeout, int maxResponseBytes) |
| | | { |
| | | this.host = requireText(host, "host"); |
| | | this.user = requireText(user, "user"); |
| | | this.password = Objects.requireNonNull(password, "password"); |
| | | this.sessionTimeoutMs = timeout(sessionTimeout, MAX_SESSION_TIMEOUT_MS, "session"); |
| | | this.responseTimeoutMs = timeout(responseTimeout, MAX_RESPONSE_TIMEOUT_MS, "response"); |
| | | this.maxResponseBytes = maxResponseBytes; |
| | | if (maxResponseBytes < 1) throw new IllegalArgumentException("maxResponseBytes must be positive"); |
| | | this.pipePath = "\\\\" + this.host + "\\pipe\\" + PIPE_NAME; |
| | | this.sessionKey = this.host + "|" + this.user + "|" + Integer.toHexString(this.password.hashCode()); |
| | | } |
| | | |
| | | /** |
| | | * 发出一条只读请求并返回原始应答。 |
| | | * |
| | | * <p>前置条件:请求只含只读白名单内的命令。并发调用由锁串行化(本接口一条连接只服务一条请求)。 |
| | | * SMB 会话首次使用时建立;管道打开或读取失败时重建会话并重试一次,仍失败则归类抛出。 |
| | | * |
| | | * @param request 完整请求报文(不含结束符) |
| | | * @return 应答原文(已去掉 {@code \n\n}) |
| | | * @throws BtsTransportException 请求越权,或会话、管道、读取失败 |
| | | */ |
| | | @Override |
| | | public String exchange(String request) |
| | | { |
| | | NewareBtsReadOnlyCommands.requireAllowed(request); |
| | | requestLock.lock(); |
| | | try |
| | | { |
| | | ensureSession(false); |
| | | try |
| | | { |
| | | return perform(request); |
| | | } |
| | | catch (BtsTransportException failure) |
| | | { |
| | | // 会话可能已被对端或系统回收(长时间空闲、重启上位机):重建一次再试,只试一次。 |
| | | if (!isSessionRecoverable(failure.reason())) throw failure; |
| | | ensureSession(true); |
| | | return perform(request); |
| | | } |
| | | } |
| | | finally |
| | | { |
| | | requestLock.unlock(); |
| | | } |
| | | } |
| | | |
| | | private String perform(String request) |
| | | { |
| | | Future<String> pending = reader.submit(() -> openAndExchange(request)); |
| | | try |
| | | { |
| | | return pending.get(responseTimeoutMs, TimeUnit.MILLISECONDS); |
| | | } |
| | | catch (TimeoutException ex) |
| | | { |
| | | closeInFlight(); |
| | | pending.cancel(true); |
| | | throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT, |
| | | "BTS 管道应答超时(" + responseTimeoutMs + " ms):" + pipePath, ex); |
| | | } |
| | | catch (InterruptedException ex) |
| | | { |
| | | closeInFlight(); |
| | | pending.cancel(true); |
| | | Thread.currentThread().interrupt(); |
| | | throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE, |
| | | "BTS 管道读取被中断:" + pipePath, ex); |
| | | } |
| | | catch (ExecutionException ex) |
| | | { |
| | | Throwable cause = ex.getCause(); |
| | | if (cause instanceof BtsTransportException failure) throw failure; |
| | | throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE, |
| | | "BTS 管道交换失败:" + pipePath, cause); |
| | | } |
| | | } |
| | | |
| | | /** 每次请求新建一条连接:实测同一连接上的第二条命令不会返回。 */ |
| | | private String openAndExchange(String request) throws IOException |
| | | { |
| | | RandomAccessFile pipe; |
| | | try |
| | | { |
| | | pipe = new RandomAccessFile(pipePath, "rw"); |
| | | } |
| | | catch (FileNotFoundException ex) |
| | | { |
| | | throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED, |
| | | "无法打开新威 BTS 管道(接口未启用?未激活?无管理员凭据?):" + pipePath, ex); |
| | | } |
| | | inFlight = pipe; |
| | | try |
| | | { |
| | | pipe.write((request + new String(FRAME_END, StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8)); |
| | | return decode(readFrame(pipe, maxResponseBytes)); |
| | | } |
| | | catch (java.io.EOFException ex) |
| | | { |
| | | throw new BtsTransportException(BtsTransportException.Reason.DISCONNECTED, |
| | | "新威 BTS 管道在应答结束前关闭:" + pipePath, ex); |
| | | } |
| | | catch (FrameTooLargeException ex) |
| | | { |
| | | throw new BtsTransportException(BtsTransportException.Reason.RESPONSE_TOO_LARGE, |
| | | "新威 BTS 应答超过 " + maxResponseBytes + " 字节:" + pipePath, ex); |
| | | } |
| | | catch (InvalidUtf8Exception ex) |
| | | { |
| | | throw new BtsTransportException(BtsTransportException.Reason.INVALID_UTF8, |
| | | "新威 BTS 应答不是合法 UTF-8:" + pipePath, ex); |
| | | } |
| | | finally |
| | | { |
| | | inFlight = null; |
| | | try { pipe.close(); } catch (IOException ignored) { } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 按块读取并扫描 {@code \n\n},返回去掉结束符的应答字节。逐字节读在 SMB 上是一次读一次往返, |
| | | * 而 {@code download} 一页最多上千行,所以必须成块读。 |
| | | */ |
| | | static byte[] readFrame(RandomAccessFile pipe, int maxPayloadBytes) throws IOException |
| | | { |
| | | byte[] chunk = new byte[READ_BUFFER_BYTES]; |
| | | ByteArrayOutputStream received = new ByteArrayOutputStream(); |
| | | int matched = 0; |
| | | while (true) |
| | | { |
| | | int read = pipe.read(chunk); |
| | | if (read < 0) throw new java.io.EOFException("新威 BTS 管道在应答结束前关闭"); |
| | | for (int i = 0; i < read; i++) |
| | | { |
| | | byte value = chunk[i]; |
| | | if (value == FRAME_END[matched]) |
| | | { |
| | | matched++; |
| | | if (matched == FRAME_END.length) return received.toByteArray(); |
| | | continue; |
| | | } |
| | | if (matched > 0) |
| | | { |
| | | // 已匹配到的结束符前缀是正文的一部分:原样补回,再按当前字节重判。 |
| | | for (int prefix = 0; prefix < matched; prefix++) append(received, FRAME_END[prefix], maxPayloadBytes); |
| | | matched = 0; |
| | | } |
| | | if (value == FRAME_END[0]) |
| | | { |
| | | matched = 1; |
| | | continue; |
| | | } |
| | | append(received, value, maxPayloadBytes); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private static void append(ByteArrayOutputStream buffer, byte value, int maxPayloadBytes) throws FrameTooLargeException |
| | | { |
| | | if (buffer.size() >= maxPayloadBytes) throw new FrameTooLargeException(); |
| | | buffer.write(value); |
| | | } |
| | | |
| | | private static String decode(byte[] bytes) throws InvalidUtf8Exception |
| | | { |
| | | try |
| | | { |
| | | return StandardCharsets.UTF_8.newDecoder() |
| | | .onMalformedInput(CodingErrorAction.REPORT) |
| | | .onUnmappableCharacter(CodingErrorAction.REPORT) |
| | | .decode(ByteBuffer.wrap(bytes)).toString(); |
| | | } |
| | | catch (CharacterCodingException ex) |
| | | { |
| | | throw new InvalidUtf8Exception(ex); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 建立 SMB 会话。Java 无法向命名管道传凭据,所以只能先让系统用 {@code net.exe use} 认证一次, |
| | | * 之后管道路径才打得开;这是"把人工那步 net use 收进后端"的落点。 |
| | | * |
| | | * @param force true 表示先撤掉旧会话再建(用于重试路径) |
| | | */ |
| | | private void ensureSession(boolean force) |
| | | { |
| | | if (force) |
| | | { |
| | | runNet("use", "\\\\" + host + "\\ipc$", "/delete", "/y"); |
| | | ESTABLISHED_SESSIONS.remove(sessionKey); |
| | | } |
| | | if (!force && ESTABLISHED_SESSIONS.contains(sessionKey)) return; |
| | | String output = runNet("use", "\\\\" + host + "\\ipc$", "/user:" + host + "\\" + user, password); |
| | | if (output == null) throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED, |
| | | "无法建立到 " + host + " 的 SMB 会话", null); |
| | | ESTABLISHED_SESSIONS.add(sessionKey); |
| | | } |
| | | |
| | | /** |
| | | * 执行一条 net.exe 命令。 |
| | | * |
| | | * <p>返回值:成功返回输出;失败时若输出表明"该主机的会话已存在"(错误 1219/多重连接) |
| | | * 也按成功处理——会话已经在了,管道照常可开。其余失败返回空值。 |
| | | * 建立与撤销都失败不抛出,交给后续管道打开去报错,避免凭据问题被当成会话问题。 |
| | | */ |
| | | private String runNet(String... arguments) |
| | | { |
| | | String[] command = new String[arguments.length + 1]; |
| | | command[0] = netExecutable(); |
| | | System.arraycopy(arguments, 0, command, 1, arguments.length); |
| | | try |
| | | { |
| | | Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); |
| | | String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); |
| | | if (!process.waitFor(sessionTimeoutMs, TimeUnit.MILLISECONDS)) |
| | | { |
| | | process.destroyForcibly(); |
| | | return null; |
| | | } |
| | | if (process.exitValue() == 0) return output; |
| | | String normalized = output.toLowerCase(Locale.ROOT); |
| | | if (normalized.contains("1219") || normalized.contains("多重连接") || normalized.contains("multiple connections")) |
| | | return output; |
| | | return null; |
| | | } |
| | | catch (IOException ex) |
| | | { |
| | | return null; |
| | | } |
| | | catch (InterruptedException ex) |
| | | { |
| | | Thread.currentThread().interrupt(); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private static String netExecutable() |
| | | { |
| | | String systemRoot = System.getenv("SystemRoot"); |
| | | return (systemRoot == null || systemRoot.isBlank() ? "C:\\Windows" : systemRoot) + "\\System32\\net.exe"; |
| | | } |
| | | |
| | | /** 会话或连接类失败值得重建会话再试一次;协议与内容类失败重试没有意义。 */ |
| | | private static boolean isSessionRecoverable(BtsTransportException.Reason reason) |
| | | { |
| | | return reason == BtsTransportException.Reason.CONNECTION_FAILED |
| | | || reason == BtsTransportException.Reason.DISCONNECTED |
| | | || reason == BtsTransportException.Reason.IO_FAILURE; |
| | | } |
| | | |
| | | /** 关掉在途句柄:这是唯一能打断阻塞中 ReadFile 的手段。 */ |
| | | private void closeInFlight() |
| | | { |
| | | RandomAccessFile pipe = inFlight; |
| | | if (pipe == null) return; |
| | | try { pipe.close(); } catch (IOException ignored) { } |
| | | } |
| | | |
| | | @Override |
| | | public void close() |
| | | { |
| | | requestLock.lock(); |
| | | try |
| | | { |
| | | closeInFlight(); |
| | | } |
| | | finally |
| | | { |
| | | requestLock.unlock(); |
| | | } |
| | | reader.shutdownNow(); |
| | | } |
| | | |
| | | private static String requireText(String value, String field) |
| | | { |
| | | String normalized = Objects.requireNonNull(value, field).trim(); |
| | | if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); |
| | | return normalized; |
| | | } |
| | | |
| | | private static int timeout(Duration value, int maximumMs, String field) |
| | | { |
| | | int millis = Math.toIntExact(Objects.requireNonNull(value, field).toMillis()); |
| | | if (millis < 1) throw new IllegalArgumentException(field + " timeout must be positive"); |
| | | if (millis > maximumMs) throw new IllegalArgumentException(field + " timeout must not exceed " + maximumMs + " ms"); |
| | | return millis; |
| | | } |
| | | |
| | | private static final class FrameTooLargeException extends IOException |
| | | { |
| | | private static final long serialVersionUID = 1L; |
| | | } |
| | | |
| | | private static final class InvalidUtf8Exception extends IOException |
| | | { |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | private InvalidUtf8Exception(Throwable cause) |
| | | { |
| | | super(cause); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import java.util.Locale; |
| | | import java.util.Set; |
| | | import java.util.regex.Matcher; |
| | | import java.util.regex.Pattern; |
| | | |
| | | /** |
| | | * 新威 BTS 对外接口的**只读命令白名单**(ADR-0068 §1)。 |
| | | * |
| | | * <p>启用厂商对外接口等于同时打开了控制通道:厂商协议里 {@code start}/{@code stop}/{@code reset}/ |
| | | * {@code broadcaststop} 与只读命令走同一条管道、同一套报文格式,服务端不做区分。 |
| | | * 因此"只观测不控制"这条不变量在新威这条路上**不是服务端保证,而是代码纪律**, |
| | | * 落点就是这里:每条出站请求在发出前按 {@code <cmd>} 取值比对白名单,不在白名单内的一律拒绝。 |
| | | * |
| | | * <p>为什么拦在传输层而不是各调用处:调用处会随需求增加,白名单只有一处才可能被审计。 |
| | | * 独立成类而不写在传输类里,是为了让这条边界能被单独测到(不需要真的有一条管道)。 |
| | | */ |
| | | final class NewareBtsReadOnlyCommands |
| | | { |
| | | /** 允许发送的命令,全部是只读命令;取值来自《BTS API技术协议》与现场实测(ADR-0068)。 */ |
| | | private static final Set<String> ALLOWED = Set.of( |
| | | "getdevinfo", "getchlstatus", "inquire", "inquiredf", "download", |
| | | "downloadsteplayer", "downloadlog", "getparallel", "getpause"); |
| | | |
| | | private static final Pattern COMMAND = Pattern.compile("<cmd>\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*</cmd>"); |
| | | |
| | | private NewareBtsReadOnlyCommands() |
| | | { |
| | | } |
| | | |
| | | /** |
| | | * 校验请求只含白名单内的命令,返回解析出的命令名(小写)。 |
| | | * |
| | | * <p>没有 {@code <cmd>} 的报文不是本协议的请求,同样拒绝——放行一个解析不出命令的报文, |
| | | * 等于把"这道门"的判定权交给厂商服务端。 |
| | | * |
| | | * @param request 完整请求报文 |
| | | * @return 请求里的命令名(小写) |
| | | * @throws BtsTransportException 缺少命令,或命令不在只读白名单内 |
| | | */ |
| | | static String requireAllowed(String request) |
| | | { |
| | | Matcher matcher = request == null ? null : COMMAND.matcher(request); |
| | | if (matcher == null || !matcher.find()) |
| | | throw new BtsTransportException(BtsTransportException.Reason.READ_ONLY_REJECTED, |
| | | "BTS 请求缺少 <cmd>,拒绝发出", null); |
| | | String command = matcher.group(1).toLowerCase(Locale.ROOT); |
| | | if (!ALLOWED.contains(command)) |
| | | throw new BtsTransportException(BtsTransportException.Reason.READ_ONLY_REJECTED, |
| | | "BTS 只读白名单外的命令被拒绝:" + command, null); |
| | | return command; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import java.time.Instant; |
| | | import java.time.ZoneId; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import java.util.regex.Matcher; |
| | | import java.util.regex.Pattern; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | import com.battery.device.api.TestDataObservationAdapter.SampleWatermark; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DeviceControlCommand; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | |
| | | /** |
| | | * 直连新威接口的适配器契约:命令分工、水位与增量逐点、以及三处"不猜"的边界。 |
| | | * |
| | | * <p>传输用脚本化的假管道替代(真实管道只在现场那台机器上存在), |
| | | * 但应答原文取自现场实测,所以这里验的是"适配器怎么用这些命令",不是"命令能不能通"。 |
| | | */ |
| | | class NewareBtsAdapterContractTest |
| | | { |
| | | private static final String ADAPTER = "neware-f"; |
| | | private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER, 25, 81, 1, 2); |
| | | |
| | | @Test |
| | | void discoversChannelsAndObservesRealtimeThroughThePipe() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe() |
| | | .on("getdevinfo", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">" |
| | | + "<cmd>getdevinfo_resp</cmd><middle count=\"2\">" |
| | | + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"1\">true</channel>" |
| | | + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"2\">true</channel>" |
| | | + "</middle></bts>") |
| | | .on("inquire", "<bts><cmd>inquire_resp</cmd><list count=\"1\">" |
| | | + "<inquire dev=\"25-81-1-2-0\" cycle_id=\"1\" step_id=\"6\" step_type=\"cc\"" |
| | | + " workstatus=\"working\" current=\"0.6853\" voltage=\"1.7337\" capacity=\"5.3894\"" |
| | | + " energy=\"9.3408\" totaltime=\"37737.6\" relativetime=\"28311.8\" /></list></bts>"); |
| | | try (DeviceAdapter adapter = adapter(pipe)) |
| | | { |
| | | assertEquals(2, adapter.discoverChannels().size()); |
| | | |
| | | var observation = adapter.observe(Set.of(CHANNEL)).get(CHANNEL); |
| | | |
| | | assertTrue(observation.online()); |
| | | // 读库路线拿不到通道工作状态,这正是本路线的主要收获。 |
| | | assertEquals(DeviceWorkStatus.WORKING, observation.workStatus()); |
| | | assertEquals(List.of("getdevinfo", "inquire"), pipe.commands()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void latestCabinetCarriesRealtimeValuesAndTheServerReceiptTime() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe().on("inquire", |
| | | "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\" cycle_id=\"1\"" |
| | | + " step_id=\"6\" step_type=\"cc\" workstatus=\"working\" current=\"0.6853\"" |
| | | + " voltage=\"1.7337\" capacity=\"5.3894\" energy=\"9.3408\" relativetime=\"28311.8\" />" |
| | | + "</list></bts>"); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | var latest = adapter.latestCabinet(CHANNEL); |
| | | |
| | | assertEquals(0.6853, latest.currentA(), 1e-9); |
| | | assertEquals(1.7337, latest.voltageV(), 1e-9); |
| | | assertEquals(1, latest.cycleNo()); |
| | | assertEquals("cc", latest.stepType()); |
| | | assertEquals("充电", latest.stepName()); |
| | | // inquire 不带绝对时间:这里的时间口径是服务器接收时间,序号留 0。 |
| | | assertNull(latest.deviceTime()); |
| | | assertEquals(0L, latest.sequenceId()); |
| | | assertEquals(Long.valueOf(28312L), latest.relativeTimeSeconds()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void watermarkIsTheLastPersistedRowAndSamplesAreStrictlyAfterIt() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe().rows(1, 5); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | SampleWatermark watermark = adapter.currentWatermark(CHANNEL); |
| | | |
| | | // 水位 = 已落盘行数那一行本身;行数就是最后一行的 seqid(实测一致)。 |
| | | assertEquals(5L, watermark.sequenceId()); |
| | | assertEquals(rowTime(5), watermark.deviceTime()); |
| | | assertEquals(List.of("inquiredf", "download:5:1"), pipe.commands()); |
| | | |
| | | pipe.reset(); |
| | | var rows = adapter.samplesSince(CHANNEL, new SampleWatermark(rowTime(3), 3L)); |
| | | |
| | | assertEquals(2, rows.size()); |
| | | assertEquals(4L, rows.get(0).sequenceId()); |
| | | assertEquals("316:4", rows.get(0).sourceRecordId()); |
| | | // 分页从「水位 + 1」开始,只取到已落盘行数为止。 |
| | | assertEquals(List.of("inquiredf", "download:4:2"), pipe.commands()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void samplesSinceReadsNothingWithoutAWatermark() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe().rows(1, 5); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | assertTrue(adapter.samplesSince(CHANNEL, null).isEmpty()); |
| | | // 没有水位就不该去读来源:来源里的设备历史不得并入本次接收。 |
| | | assertTrue(pipe.commands().isEmpty()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void treatsAClientWithoutInquiredfAsNotRowBasedInsteadOfFailing() |
| | | { |
| | | // 实测 7.6.0.1475 不支持 inquiredf(应答里没有 chl 记录)。这不是失败,是该构建的真实能力。 |
| | | ScriptedPipe pipe = new ScriptedPipe().on("inquiredf", |
| | | "<bts><cmd>inquiredf_resp</cmd><list count=\"0\" /></bts>"); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | assertNull(adapter.currentWatermark(CHANNEL)); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void aChannelWithNoTestHasAZeroWatermarkAndNoSamples() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe().on("inquiredf", |
| | | "<bts><cmd>inquiredf_resp</cmd><list count=\"1\"><chl devtype=\"25\" devid=\"81\" subdevid=\"1\"" |
| | | + " chlid=\"2\" testid=\"316\" count=\"0\">true</chl></list></bts>"); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | SampleWatermark watermark = adapter.currentWatermark(CHANNEL); |
| | | |
| | | // 空时间表示"这一刻来源里没有任何可用行":之后出现的行全部属于本次接收。 |
| | | assertNull(watermark.deviceTime()); |
| | | assertEquals(0L, watermark.sequenceId()); |
| | | assertTrue(adapter.samplesSince(CHANNEL, watermark).isEmpty()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void doesNotInventStepCyclesCollectorOrPlanFacts() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe(); |
| | | try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe)) |
| | | { |
| | | // 工步层要未验证的 downloadStepLayer;采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。 |
| | | assertTrue(adapter.stepRecords(CHANNEL).isEmpty()); |
| | | assertTrue(adapter.cycleRecords(CHANNEL).isEmpty()); |
| | | assertNull(adapter.latestCollector(ADAPTER, "collector-1")); |
| | | assertNull(adapter.testPlan(CHANNEL)); |
| | | assertEquals(NewareBtsAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL)); |
| | | assertTrue(pipe.commands().isEmpty()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void readOnlySurfaceStaysClosedForEveryControlCommand() |
| | | { |
| | | ScriptedPipe pipe = new ScriptedPipe(); |
| | | try (DeviceAdapter adapter = adapter(pipe)) |
| | | { |
| | | assertTrue(adapter.capabilities().discovery()); |
| | | assertTrue(adapter.capabilities().realtimeObservation()); |
| | | assertTrue(adapter.capabilities().historyDownload()); |
| | | assertFalse(adapter.capabilities().control()); |
| | | |
| | | for (DeviceControlCommand command : DeviceControlCommand.values()) |
| | | assertThrows(UnsupportedOperationException.class, () -> adapter.control(CHANNEL, command)); |
| | | assertTrue(pipe.commands().isEmpty()); |
| | | } |
| | | } |
| | | |
| | | private static DeviceAdapter adapter(ScriptedPipe pipe) |
| | | { |
| | | return new NewareBtsAdapter(ADAPTER, pipe, new BtsXmlCodec(ADAPTER, ZoneId.of(DEVICE_ZONE))); |
| | | } |
| | | |
| | | /** 假管道里第 {@code sequenceId} 行对应的设备绝对时间(每行间隔 60 秒)。 */ |
| | | private static Instant rowTime(int sequenceId) |
| | | { |
| | | return Instant.parse("2026-10-10T15:00:00Z").plusSeconds(sequenceId * 60L); |
| | | } |
| | | |
| | | /** 设备本地时间文本(厂商格式):与适配器时区一致,编解码器按它解释回 UTC。 */ |
| | | private static final java.time.format.DateTimeFormatter DEVICE_TIME_TEXT = |
| | | java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); |
| | | |
| | | private static final String DEVICE_ZONE = "Asia/Hong_Kong"; |
| | | |
| | | private static String rowTimeText(int sequenceId) |
| | | { |
| | | return DEVICE_TIME_TEXT.withZone(ZoneId.of(DEVICE_ZONE)).format(rowTime(sequenceId)); |
| | | } |
| | | |
| | | /** |
| | | * 脚本化的假管道:按 {@code <cmd>} 返回固定应答,{@code download}/{@code inquiredf} 按内存里的行集动态生成。 |
| | | * |
| | | * <p>它也把只读白名单当断言用:任何越权命令在到达"对端"之前就被拒绝,测试会因此失败。 |
| | | */ |
| | | private static final class ScriptedPipe implements BtsTransport |
| | | { |
| | | private static final Pattern COMMAND = Pattern.compile("<cmd>\\s*([A-Za-z_]+)\\s*</cmd>"); |
| | | private static final Pattern START_POSITION = Pattern.compile("startpos=\"(\\d+)\""); |
| | | private static final Pattern COUNT = Pattern.compile("count=\"(\\d+)\""); |
| | | private static final long TEST_ID = 316L; |
| | | |
| | | private final Map<String, String> responses = new java.util.LinkedHashMap<>(); |
| | | private final List<String> commands = new ArrayList<>(); |
| | | private int firstRow = 0; |
| | | private int lastRow = 0; |
| | | |
| | | ScriptedPipe on(String command, String response) |
| | | { |
| | | responses.put(command, response); |
| | | return this; |
| | | } |
| | | |
| | | /** 生成 {@code [first, last]} 的逐点行集,供 download/inquiredf 按窗口回答。 */ |
| | | ScriptedPipe rows(int first, int last) |
| | | { |
| | | firstRow = first; |
| | | lastRow = last; |
| | | return this; |
| | | } |
| | | |
| | | void reset() |
| | | { |
| | | commands.clear(); |
| | | } |
| | | |
| | | List<String> commands() |
| | | { |
| | | return List.copyOf(commands); |
| | | } |
| | | |
| | | @Override |
| | | public String exchange(String request) |
| | | { |
| | | NewareBtsReadOnlyCommands.requireAllowed(request); |
| | | Matcher matcher = COMMAND.matcher(request); |
| | | if (!matcher.find()) throw new IllegalStateException("请求缺少命令:" + request); |
| | | String command = matcher.group(1).toLowerCase(java.util.Locale.ROOT); |
| | | if ("inquiredf".equals(command)) |
| | | { |
| | | commands.add("inquiredf"); |
| | | if (responses.containsKey("inquiredf")) return responses.get("inquiredf"); |
| | | return "<bts><cmd>inquiredf_resp</cmd><list count=\"1\"><chl devtype=\"25\" devid=\"81\"" |
| | | + " subdevid=\"1\" chlid=\"2\" testid=\"" + TEST_ID + "\" count=\"" + lastRow |
| | | + "\">false</chl></list></bts>"; |
| | | } |
| | | if ("download".equals(command)) |
| | | { |
| | | long from = Long.parseLong(value(START_POSITION, request, "1")); |
| | | int count = Integer.parseInt(value(COUNT, request, "1")); |
| | | commands.add("download:" + from + ":" + count); |
| | | return downloadResponse(from, count); |
| | | } |
| | | commands.add(command); |
| | | String response = responses.get(command); |
| | | if (response == null) throw new IllegalStateException("测试脚本没有为命令准备应答:" + command); |
| | | return response; |
| | | } |
| | | |
| | | private String downloadResponse(long from, int count) |
| | | { |
| | | StringBuilder rows = new StringBuilder(); |
| | | int returned = 0; |
| | | for (int sequenceId = (int) from; sequenceId <= lastRow && returned < count; sequenceId++, returned++) |
| | | rows.append("<data seqid=\"").append(sequenceId).append("\" stepid=\"6\" cycleid=\"0\"" |
| | | + " steptype=\"cc\" testtime=\"").append(sequenceId * 60000L).append("\"" |
| | | + " atime=\"").append(rowTimeText(sequenceId)).append("\"") |
| | | .append(" volt=\"1.75\" curr=\"0.685\" cap=\"4.1\" eng=\"7.1\" />"); |
| | | return "<bts><cmd>donwload_resp</cmd><download devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\"" |
| | | + " testid=\"" + TEST_ID + "\" startpos=\"" + from + "\" count=\"" + returned |
| | | + "\" auxid=\"0\" /><list count=\"" + returned + "\">" + rows + "</list></bts>"; |
| | | } |
| | | |
| | | private static String value(Pattern pattern, String request, String fallback) |
| | | { |
| | | Matcher matcher = pattern.matcher(request); |
| | | return matcher.find() ? matcher.group(1) : fallback; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import org.junit.jupiter.api.AfterEach; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.DeviceAdapterException; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.registry.AdapterInstanceConfig; |
| | | import com.battery.device.api.registry.DeviceAdapterRegistry; |
| | | import com.battery.device.api.registry.DeviceAdapterFactory; |
| | | |
| | | /** |
| | | * 直连新威接口的类型装配:类型标识、口令来源与只读能力。 |
| | | * |
| | | * <p>口令走"本地凭据文件 → 环境变量 → 启动参数",测试用启动参数这一层注入只在本测试里存在的键, |
| | | * 以免碰到真实凭据文件。 |
| | | */ |
| | | class NewareBtsAdapterFactoryContractTest |
| | | { |
| | | private static final String PASSWORD_KEY = "TEST_NEWARE_WIN_PASSWORD_FF"; |
| | | |
| | | @BeforeEach |
| | | void provideAdminAccount() |
| | | { |
| | | System.setProperty(NewareBtsAdapterFactory.DEFAULT_USER_ENV, "Administrator"); |
| | | } |
| | | |
| | | @AfterEach |
| | | void clearAccount() |
| | | { |
| | | System.clearProperty(NewareBtsAdapterFactory.DEFAULT_USER_ENV); |
| | | System.clearProperty(PASSWORD_KEY); |
| | | } |
| | | |
| | | @Test |
| | | void declaresItsOwnStorageShapeType() |
| | | { |
| | | assertEquals("NEWARE_BTS", new NewareBtsAdapterFactory().adapterType()); |
| | | assertEquals("NEWARE_BTS", NewareBtsAdapter.ADAPTER_TYPE); |
| | | } |
| | | |
| | | @Test |
| | | void buildsAReadOnlyAdapterForAConfiguredHost() |
| | | { |
| | | System.setProperty(PASSWORD_KEY, "159357"); |
| | | DeviceAdapterFactory factory = new NewareBtsAdapterFactory(); |
| | | |
| | | DeviceAdapter adapter = factory.create(config(Map.of(NewareBtsAdapterFactory.PASSWORD_ENV_OPTION, PASSWORD_KEY))); |
| | | |
| | | assertEquals("neware-f", adapter.adapterCode()); |
| | | assertEquals(true, adapter.capabilities().historyDownload()); |
| | | assertFalse(adapter.capabilities().control()); |
| | | } |
| | | |
| | | @Test |
| | | void refusesToGuessTheMachinePassword() |
| | | { |
| | | DeviceAdapterFactory factory = new NewareBtsAdapterFactory(); |
| | | |
| | | DeviceAdapterException exception = assertThrows(DeviceAdapterException.class, |
| | | () -> factory.create(config(Map.of()))); |
| | | |
| | | // 五台机器的口令实测有两种,猜一个就会连错机器,所以这个配置项没有缺省值。 |
| | | assertEquals(true, exception.getMessage().contains(NewareBtsAdapterFactory.PASSWORD_ENV_OPTION)); |
| | | } |
| | | |
| | | @Test |
| | | void registryRejectsAnInstanceWhoseCredentialsAreMissingWithoutEnablingIt() |
| | | { |
| | | try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(new NewareBtsAdapterFactory()))) |
| | | { |
| | | assertThrows(DeviceAdapterException.class, () -> registry.synchronize(config(Map.of()))); |
| | | |
| | | assertFalse(registry.contains("neware-f")); |
| | | } |
| | | } |
| | | |
| | | private static AdapterInstanceConfig config(Map<String, String> sourceConfig) |
| | | { |
| | | return new AdapterInstanceConfig("neware-f", "NEWARE_BTS", "26.117.165.166", 445, 60000, |
| | | "Asia/Hong_Kong", true, new AdapterCapabilities(true, true, true, false), sourceConfig); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import java.io.EOFException; |
| | | import java.io.IOException; |
| | | import java.io.RandomAccessFile; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.time.Duration; |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | /** |
| | | * 远程命名管道传输的行为:分帧按实测的 {@code \n\n}、只读白名单在发出前生效。 |
| | | * 分帧用普通文件驱动 {@link NewareBtsPipeTransport#readFrame} —— 生产方法读写的就是 {@code RandomAccessFile}, |
| | | * 换掉的是对端是不是管道,帧扫描这一段完全一致。 |
| | | */ |
| | | class NewareBtsPipeTransportTest |
| | | { |
| | | private static final int MAX = 1024; |
| | | |
| | | @Test |
| | | void returnsPayloadUpToThePlainDoubleNewlineTerminator() throws Exception |
| | | { |
| | | assertEquals("<bts><cmd>getdevinfo_resp</cmd></bts>", frame("<bts><cmd>getdevinfo_resp</cmd></bts>\n\n")); |
| | | } |
| | | |
| | | @Test |
| | | void ignoresBytesAfterTheTerminator() throws Exception |
| | | { |
| | | assertEquals("first", frame("first\n\nsecond\n\n")); |
| | | } |
| | | |
| | | @Test |
| | | void keepsTerminatorPrefixThatBelongsToThePayload() throws Exception |
| | | { |
| | | // 正文里出现结束符的前缀时要原样补回,不能把它吃掉。 |
| | | assertEquals("a\n#b", frame("a\n#b\n\n")); |
| | | } |
| | | |
| | | @Test |
| | | void doesNotTreatDocumentedHashTerminatorAsAnEnd() throws Exception |
| | | { |
| | | // 协议文档给的 #\r\n 现场实测无效;照它写就永远读不到帧尾,这里以"读到尾也没结束"表示这一点。 |
| | | assertThrows(EOFException.class, () -> frame("<bts/>#\r\n")); |
| | | } |
| | | |
| | | @Test |
| | | void rejectsPayloadBeyondTheLimit() throws Exception |
| | | { |
| | | assertThrows(IOException.class, () -> frameRaw("123456789\n\n", 8)); |
| | | } |
| | | |
| | | @Test |
| | | void allowsPayloadExactlyAtTheLimit() throws Exception |
| | | { |
| | | assertEquals("12345678", frameRaw("12345678\n\n", 8)); |
| | | } |
| | | |
| | | @Test |
| | | void rejectsEveryCommandOutsideTheReadOnlyWhitelistBeforeAnyTransportWork() |
| | | { |
| | | try (NewareBtsPipeTransport transport = new NewareBtsPipeTransport("203.0.113.1", "Administrator", "secret", |
| | | Duration.ofSeconds(1), Duration.ofSeconds(1))) |
| | | { |
| | | // 控制命令一律不得发出:这里没有任何会话与管道,若白名单没生效就会变成连接失败而不是被拒绝。 |
| | | for (String command : new String[] {"start", "stop", "reset", "broadcaststop", "setpause", |
| | | "cancelpause", "goto", "chl_ctrl", "parallel", "light", "continue", "clearflag"}) |
| | | { |
| | | BtsTransportException exception = assertThrows(BtsTransportException.class, |
| | | () -> transport.exchange("<bts><cmd>" + command + "</cmd></bts>")); |
| | | assertEquals(BtsTransportException.Reason.READ_ONLY_REJECTED, exception.reason(), command); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void acceptsTheReadOnlyWhitelistAndRejectsRequestsWithoutACommand() |
| | | { |
| | | try (NewareBtsPipeTransport transport = new NewareBtsPipeTransport("203.0.113.1", "Administrator", "secret", |
| | | Duration.ofSeconds(1), Duration.ofSeconds(1))) |
| | | { |
| | | for (String command : new String[] {"getdevinfo", "getchlstatus", "inquire", "inquiredf", "download", |
| | | "downloadStepLayer", "downloadlog", "getparallel", "getpause"}) |
| | | assertEquals(command.toLowerCase(java.util.Locale.ROOT), |
| | | NewareBtsReadOnlyCommands.requireAllowed("<bts><cmd>" + command + "</cmd></bts>")); |
| | | |
| | | BtsTransportException exception = assertThrows(BtsTransportException.class, |
| | | () -> NewareBtsReadOnlyCommands.requireAllowed("<bts><note>没有命令</note></bts>")); |
| | | assertEquals(BtsTransportException.Reason.READ_ONLY_REJECTED, exception.reason()); |
| | | } |
| | | } |
| | | |
| | | private static String frame(String content) throws Exception |
| | | { |
| | | return frameRaw(content, MAX); |
| | | } |
| | | |
| | | private static String frameRaw(String content, int maxPayloadBytes) throws Exception |
| | | { |
| | | Path file = Files.createTempFile("neware-bts-frame", ".txt"); |
| | | try |
| | | { |
| | | Files.write(file, content.getBytes(StandardCharsets.UTF_8)); |
| | | try (RandomAccessFile pipe = new RandomAccessFile(file.toFile(), "r")) |
| | | { |
| | | return new String(NewareBtsPipeTransport.readFrame(pipe, maxPayloadBytes), StandardCharsets.UTF_8); |
| | | } |
| | | } |
| | | finally |
| | | { |
| | | Files.deleteIfExists(file); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import java.time.Instant; |
| | | import java.time.ZoneId; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | | |
| | | /** |
| | | * 新威接口的编解码器按**现场实测应答原文**验证。 |
| | | * |
| | | * <p>这些夹具不是照协议文档写的,是从真实上位机(8.0.0.530)的管道应答里抄下来的原文, |
| | | * 因此它们同时是"实现必须吸收的实测差异"的回归防线: |
| | | * 小数秒的 {@code totaltime}、厂商拼错的 {@code donwload_resp}、五段式 {@code dev} 身份。 |
| | | */ |
| | | class NewareBtsXmlCodecTest |
| | | { |
| | | private static final String ADAPTER = "neware-f"; |
| | | private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER, 25, 81, 1, 2); |
| | | private final BtsXmlCodec codec = new BtsXmlCodec(ADAPTER, ZoneId.of("Asia/Hong_Kong")); |
| | | |
| | | @Test |
| | | void parsesRealRealtimeSnapshotWithFractionalSecondDurations() |
| | | { |
| | | String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>inquire_resp</cmd>" |
| | | + "<list count=\"1\"><inquire dev=\"25-81-1-2-0\" cycle_id=\"1\" step_id=\"6\" step_type=\"cc\"" |
| | | + " workstatus=\"working\" current=\"0.6853\" voltage=\"1.7337\" capacity=\"5.3894\"" |
| | | + " energy=\"9.3408\" totaltime=\"37737.6\" relativetime=\"28311.8\" open_or_close=\"0\"" |
| | | + " log_code=\"0\" /></list></bts>"; |
| | | |
| | | RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.parse("2026-10-10T15:50:52Z")) |
| | | .get(CHANNEL); |
| | | |
| | | assertEquals(DeviceWorkStatus.WORKING, snapshot.workStatus()); |
| | | assertEquals(Integer.valueOf(1), snapshot.cycleNo()); |
| | | assertEquals(Integer.valueOf(6), snapshot.stepNo()); |
| | | assertEquals("cc", snapshot.stepType()); |
| | | // 实测给的是小数秒;早期按整数解析会在这里判成非法取值,整次观测失败。 |
| | | assertEquals(Long.valueOf(37738L), snapshot.totalTimeSeconds()); |
| | | assertEquals(Long.valueOf(28312L), snapshot.relativeTimeSeconds()); |
| | | assertEquals("0", snapshot.logCode()); |
| | | // 五段式 dev(末段是辅助序号):身份取前四段,后段忽略。 |
| | | assertEquals(CHANNEL, snapshot.channel()); |
| | | } |
| | | |
| | | @Test |
| | | void parsesDataFileStateFromRealInquiredfResponse() |
| | | { |
| | | String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>inquiredf_resp</cmd>" |
| | | + "<list count=\"1\"><chl devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\"" |
| | | + " count=\"637\">false</chl></list></bts>"; |
| | | |
| | | BtsXmlCodec.DataFileState state = codec.parseDataFileState(xml); |
| | | |
| | | assertEquals(316L, state.testId()); |
| | | assertEquals(637L, state.count()); |
| | | // 厂商自报"未传完":这段数据不得当完整事实(ADR-0068 未决 5)。 |
| | | assertFalse(state.complete()); |
| | | } |
| | | |
| | | @Test |
| | | void parsesRealDownloadedPointsWithAbsoluteTimeAndFullPrecision() |
| | | { |
| | | String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>donwload_resp</cmd>" |
| | | + "<download devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" startpos=\"527\"" |
| | | + " count=\"3\" auxid=\"0\" /><list count=\"3\">" |
| | | + "<data seqid=\"527\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21660000\"" |
| | | + " atime=\"2026-10-10 23:49:28\" volt=\"1.750143\" curr=\"0.6852889\"" |
| | | + " cap=\"4.12315481465658\" eng=\"7.12849144299129\" />" |
| | | + "<data seqid=\"528\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21720000\"" |
| | | + " atime=\"2026-10-10 23:50:28\" volt=\"1.751076\" curr=\"0.6852991\"" |
| | | + " cap=\"4.13457583333333\" eng=\"7.14849111111111\" />" |
| | | + "<data seqid=\"529\" stepid=\"6\" cycleid=\"0\" steptype=\"cc\" testtime=\"21780000\"" |
| | | + " atime=\"2026-10-10 23:51:28\" volt=\"1.750529\" curr=\"0.6852911\"" |
| | | + " cap=\"4.14599735229492\" eng=\"7.16848481020665\" /></list></bts>"; |
| | | |
| | | List<CabinetRealtime> rows = codec.parseDownload(xml, Instant.parse("2026-10-10T15:51:30Z")); |
| | | |
| | | assertEquals(3, rows.size()); |
| | | CabinetRealtime first = rows.get(0); |
| | | assertEquals(Long.valueOf(527L), first.sequenceId()); |
| | | // atime 是设备本地时间,按适配器时区解释成 UTC(香港 = UTC+8)。 |
| | | assertEquals("2026-10-10T15:49:28Z", first.deviceTime().toString()); |
| | | assertEquals("2026-10-10 23:49:28", first.deviceTimeRaw()); |
| | | assertEquals(Integer.valueOf(6), first.stepNo()); |
| | | // download 的 cycleid 是 0 起(与库里的 cycle 一致),而 inquire 的 cycle_id 是 1 起。 |
| | | assertEquals(Integer.valueOf(0), first.cycleNo()); |
| | | assertEquals("cc", first.stepType()); |
| | | assertEquals("充电", first.stepName()); |
| | | assertEquals(Long.valueOf(21660L), first.relativeTimeSeconds()); |
| | | assertEquals(0.6852889, first.currentA(), 1e-12); |
| | | assertEquals(1.750143, first.voltageV(), 1e-12); |
| | | assertEquals(4.12315481465658, first.capacityAh(), 1e-12); |
| | | assertEquals(7.12849144299129, first.energyWh(), 1e-12); |
| | | // 行身份带测试号:seqid 在每个测试内从 1 重编,只按它会跨测试撞键。 |
| | | assertEquals("316:527", first.sourceRecordId()); |
| | | assertEquals("Asia/Hong_Kong", first.timezoneId()); |
| | | } |
| | | |
| | | @Test |
| | | void acceptsTheVendorsMisspelledDownloadCommandAndEmptyPages() |
| | | { |
| | | List<CabinetRealtime> rows = codec.parseDownload( |
| | | "<bts><cmd>donwload_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH); |
| | | |
| | | assertTrue(rows.isEmpty()); |
| | | } |
| | | |
| | | @Test |
| | | void stillAcceptsTheCommandNameSpelledAsInTheProtocolDocument() |
| | | { |
| | | List<CabinetRealtime> rows = codec.parseDownload("<bts><cmd>download_resp</cmd><list count=\"0\" /></bts>", |
| | | Instant.EPOCH); |
| | | |
| | | assertTrue(rows.isEmpty()); |
| | | } |
| | | |
| | | @Test |
| | | void rejectsADownloadResponseToAnotherCommand() |
| | | { |
| | | BtsProtocolException exception = assertThrows(BtsProtocolException.class, |
| | | () -> codec.parseDownload("<bts><cmd>inquire_resp</cmd><list count=\"0\" /></bts>", Instant.EPOCH)); |
| | | |
| | | assertEquals(BtsProtocolException.Reason.UNEXPECTED_COMMAND, exception.reason()); |
| | | } |
| | | |
| | | @Test |
| | | void reportsMissingDataFileRecordInsteadOfInventingAWatermark() |
| | | { |
| | | // 客户端不支持 inquiredf 时(实测 7.6.0.1475)应答里没有 chl 记录:这是"读不到",不是"零行"。 |
| | | BtsProtocolException exception = assertThrows(BtsProtocolException.class, |
| | | () -> codec.parseDataFileState("<bts><cmd>inquiredf_resp</cmd><list count=\"0\" /></bts>")); |
| | | |
| | | assertEquals(BtsProtocolException.Reason.MISSING_FIELD, exception.reason()); |
| | | } |
| | | |
| | | @Test |
| | | void buildsRequestsWithTheVendorIdentityAndPaging() |
| | | { |
| | | String inquire = codec.inquireRequest(Set.of(CHANNEL), false); |
| | | assertTrue(inquire.contains("<cmd>inquire</cmd>")); |
| | | assertTrue(inquire.contains("devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\"")); |
| | | |
| | | String inquiredf = codec.inquiredfRequest(CHANNEL); |
| | | assertTrue(inquiredf.contains("<cmd>inquiredf</cmd>")); |
| | | assertTrue(inquiredf.contains("testid=\"0\"")); |
| | | |
| | | String download = codec.downloadRequest(CHANNEL, 0L, 528L, 2); |
| | | assertTrue(download.contains("<cmd>download</cmd>")); |
| | | assertTrue(download.contains("startpos=\"528\"")); |
| | | assertTrue(download.contains("count=\"2\"")); |
| | | assertTrue(download.contains("auxid=\"0\"")); |
| | | } |
| | | |
| | | @Test |
| | | void refusesToAddressAnotherAdaptersChannel() |
| | | { |
| | | ChannelIdentity foreign = new ChannelIdentity("neware-e", 25, 81, 1, 2); |
| | | |
| | | assertThrows(IllegalArgumentException.class, () -> codec.inquiredfRequest(foreign)); |
| | | assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(foreign, 0L, 1L, 1)); |
| | | } |
| | | |
| | | @Test |
| | | void refusesPagingBeyondTheProtocolLimits() |
| | | { |
| | | assertThrows(IllegalArgumentException.class, () -> codec.downloadRequest(CHANNEL, 0L, 0L, 1)); |
| | | assertThrows(IllegalArgumentException.class, |
| | | () -> codec.downloadRequest(CHANNEL, 0L, 1L, BtsXmlCodec.MAX_DOWNLOAD_PAGE + 1)); |
| | | } |
| | | |
| | | @Test |
| | | void reportsTheConfiguredSiteTimezone() |
| | | { |
| | | assertEquals("Asia/Hong_Kong", codec.timezoneId()); |
| | | } |
| | | |
| | | @Test |
| | | void keepsUnknownStepTypesWithoutInventingADirectionName() |
| | | { |
| | | String xml = "<bts><cmd>donwload_resp</cmd><download testid=\"9\" startpos=\"1\" count=\"1\" />" |
| | | + "<list count=\"1\"><data seqid=\"1\" stepid=\"1\" cycleid=\"0\" steptype=\"--\" testtime=\"0\"" |
| | | + " atime=\"2026-10-10 15:08:57\" volt=\"0\" curr=\"0\" cap=\"0\" eng=\"0\" /></list></bts>"; |
| | | |
| | | CabinetRealtime row = codec.parseDownload(xml, Instant.EPOCH).get(0); |
| | | |
| | | assertNull(row.stepName()); |
| | | } |
| | | |
| | | @Test |
| | | void parsesRealDeviceInventoryIncludingTheCapitalisedChannelIdAttribute() |
| | | { |
| | | String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>" |
| | | + "<serverip count=\"1\"><server ip=\"127.0.0.1\" port=\"3306\" /></serverip>" |
| | | + "<middle count=\"2\">" |
| | | + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"1\">true</channel>" |
| | | + "<channel ip=\"127.0.0.1\" devtype=\"25\" devid=\"81\" subdevid=\"1\" Channelid=\"2\">false</channel>" |
| | | + "</middle></bts>"; |
| | | |
| | | var channels = codec.parseDeviceInfo(xml); |
| | | |
| | | assertEquals(2, channels.size()); |
| | | assertEquals(new ChannelIdentity(ADAPTER, 25, 81, 1, 1), channels.get(0).identity()); |
| | | assertTrue(channels.get(0).available()); |
| | | assertFalse(channels.get(1).available()); |
| | | } |
| | | |
| | | @Test |
| | | void keepsTheRequestedIdentityWhenTheServerRewritesTheDeviceType() |
| | | { |
| | | // 实测第四台:getdevinfo 报 devtype=27,而 inquire 的应答把 devtype 改写成 25。 |
| | | // 按应答归集会与调用方请求的身份对不上(表现为"通道查不到"),所以归集要按请求的身份。 |
| | | ChannelIdentity requested = new ChannelIdentity(ADAPTER, 27, 76, 1, 1); |
| | | String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-76-1-1-0\"" |
| | | + " cycle_id=\"1\" step_type=\"--\" workstatus=\"finish\" current=\"0\" voltage=\"0\"" |
| | | + " totaltime=\"0\" relativetime=\"0\" /></list></bts>"; |
| | | |
| | | Map<ChannelIdentity, RealtimeSnapshot> snapshots = codec.parseSnapshots(xml, Set.of(requested), Instant.EPOCH); |
| | | |
| | | assertEquals(1, snapshots.size()); |
| | | RealtimeSnapshot snapshot = snapshots.get(requested); |
| | | assertEquals(requested, snapshot.channel()); |
| | | assertEquals(DeviceWorkStatus.FINISHED, snapshot.workStatus()); |
| | | // 不带请求集合时退回按应答自己的身份归集(设备类型取应答里的 25)。 |
| | | assertEquals(new ChannelIdentity(ADAPTER, 25, 76, 1, 1), |
| | | codec.parseSnapshots(xml, null, Instant.EPOCH).keySet().iterator().next()); |
| | | } |
| | | |
| | | @Test |
| | | void keepsOnlyAttributesOutsideTheKnownFieldSetAsDynamicMeasurements() |
| | | { |
| | | // 字段集随客户端构建不同(8.0.0.484 有 barcode/auxtemp,8.0.0.530 没有,实测三种构建互不相同)。 |
| | | // 已知字段各走自己的字段(barcode 是值、auxtemp 按已知字段表排除),未在字段表里的按动态测量原样保留。 |
| | | String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\"" |
| | | + " workstatus=\"working\" barcode=\"BC1\" auxtemp=\"25.5\" T1=\"23.4\"/></list></bts>"; |
| | | |
| | | RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.EPOCH).get(CHANNEL); |
| | | |
| | | assertEquals("BC1", snapshot.barcode()); |
| | | assertEquals(23.4, snapshot.measurements().get("T1").value().doubleValue(), 1e-9); |
| | | assertFalse(snapshot.measurements().containsKey("barcode")); |
| | | assertFalse(snapshot.measurements().containsKey("auxtemp")); |
| | | } |
| | | } |
| | |
| | | # 厂商库连接口令 · 模板 |
| | | # 厂商来源凭据 · 模板 |
| | | # |
| | | # 用法:把本文件复制成同目录下的 vendor-credentials.properties,填好口令即可。 |
| | | # 开发期(IDE 起服务):<仓库根>/config/vendor-credentials.properties |
| | |
| | | # |
| | | # 说明: |
| | | # - vendor-credentials.properties 已被 .gitignore 忽略,不入库、不进数据库、不打包进 jar; |
| | | # 口令的取值顺序是「本文件 → 同名环境变量 → 同名 JVM 启动参数 -D…」,三种都没有时 |
| | | # 取值顺序是「本文件 → 同名环境变量 → 同名 JVM 启动参数 -D…」,三种都没有时 |
| | | # 装配会失败,错误信息会同时点名本文件的路径与该键名。 |
| | | # - 键名必须与适配器实例配置里的 passwordEnv 一致(默认 BATTERY_VENDOR_DB_PASSWORD)。 |
| | | # - 账号固定使用现场取得的服务端只读账号(visitor),不要在这里放全权限账号。 |
| | | # - 键名必须与适配器实例配置里的 passwordEnv / tokenEnv 一致。 |
| | | # - 直连厂商库那条路(NEWARE_MYSQL)固定使用现场取得的服务端只读账号(visitor), |
| | | # 不要在这里放全权限账号。 |
| | | # |
| | | # 未放置本文件时后端仍会正常启动:该来源实例会被标记为离线,并把 |
| | | # 「缺少厂商库连接口令」写进适配器列表的最近一次连接结果里。 |
| | | # 未放置本文件时后端仍会正常启动:相关来源实例会被标记为离线,并把 |
| | | # 「缺少来源凭据」写进适配器列表的最近一次连接结果里。 |
| | | |
| | | # 直连厂商 MySQL(NEWARE_MYSQL)的连接口令。 |
| | | BATTERY_VENDOR_DB_PASSWORD= |
| | | |
| | | # 推式来源(PUSH_INGEST)的接收端点令牌:请求头 X-Device-Ingest-Token。 |
| | | BATTERY_INGEST_TOKEN= |
| | | |
| | | # 直连新威对外接口(NEWARE_BTS)那几台的**本机管理员账号与口令**。 |
| | | # Java 无法给命名管道传凭据,后端要先用它建一条 SMB 会话,\\<主机>\pipe\NewareBtsAPI 才打得开。 |
| | | # 账号各台相同;口令按机器分别给键(键名与实例配置里的 passwordEnv 一致),不要设缺省值。 |
| | | NEWARE_WIN_USER= |
| | | # NEWARE_WIN_PASSWORD_<来源编码后缀>= |
| | |
| | | import org.springframework.scheduling.annotation.EnableScheduling; |
| | | import com.battery.device.api.NoopDeviceAdapterFactory; |
| | | import com.battery.device.bts.BtsDeviceAdapterFactory; |
| | | import com.battery.device.bts.NewareBtsAdapterFactory; |
| | | import com.battery.device.api.registry.DeviceAdapterRegistry; |
| | | import com.battery.device.neware.NewareMysqlAdapterFactory; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | |
| | | public DeviceAdapterRegistry deviceAdapterRegistry(TDeviceIngestRecordMapper ingestRecords) |
| | | { |
| | | return new DeviceAdapterRegistry(List.of(new NoopDeviceAdapterFactory(), new BtsDeviceAdapterFactory(), |
| | | new NewareMysqlAdapterFactory(), new PushIngestAdapterFactory(ingestRecords))); |
| | | new NewareBtsAdapterFactory(), new NewareMysqlAdapterFactory(), |
| | | new PushIngestAdapterFactory(ingestRecords))); |
| | | } |
| | | } |
| New file |
| | |
| | | -- 登记新威对外接口(命名管道)的直连来源实例(ADR-0070;取代 ADR-0068 §3 的"推式取数程序"形态)。 |
| | | -- 在你本地的**业务库**里执行一次即可。 |
| | | -- |
| | | -- 只登记 4 台(c/d/e/f),不是 5 台: |
| | | -- A(26.70.208.249,7.6 客户端)**继续用 NEWARE_MYSQL**(adapter_code = neware76,已存在)。 |
| | | -- 理由有两条,缺一不可: |
| | | -- 1. ADR-0068 §2:适配器编码是通道身份的一部分,**同一台机器只能有一个来源实例**, |
| | | -- 否则同一物理通道会有两份身份; |
| | | -- 2. 7.6 客户端不支持 inquiredf/download(实测空响应),接口侧拿不到逐点, |
| | | -- 而那台的库里有 data_* —— 逐点必须走库,这条不能换。 |
| | | -- |
| | | -- port 填 445:跨机访问命名管道走的是 SMB,这个字段只作记录与识别(适配器不读它)。 |
| | | -- source_config 只放**变量名**:本机管理员口令在后端 config/vendor-credentials.properties |
| | | -- (NEWARE_WIN_USER 与 NEWARE_WIN_PASSWORD_<后缀>),**口令值不进数据库**(ADR-0070)。 |
| | | |
| | | -- 1) 先撤掉已废弃的推式实例(ADR-0068 §3 的方案,2026-10-10 决定改直连;这几行从未投入演示使用)。 |
| | | -- 落地表里已有的 neware-d 行(BTS_POINT/BTS_SNAPSHOT/HEARTBEAT)**保留**:那是成功接收的厂商事实, |
| | | -- 按不变量原样留存,不随实例删除而删除。 |
| | | DELETE FROM t_device_adapter WHERE adapter_type = 'PUSH_INGEST' AND adapter_code LIKE 'neware-%'; |
| | | |
| | | -- 2) 登记 4 台直连新威接口的来源实例。 |
| | | -- capabilities / poll_interval_ms / timezone_id / read_only 从现有实例照抄(同形态已在跑,照抄比猜取值安全); |
| | | -- 若照抄源不存在,本脚本插入 0 行,不会写坏数据。 |
| | | INSERT INTO t_device_adapter |
| | | (adapter_code, adapter_name, adapter_type, host, port, poll_interval_ms, timezone_id, read_only, |
| | | capabilities, connection_status, status, del_flag, source_config, create_by, remark) |
| | | SELECT v.code, v.name, 'NEWARE_BTS', v.host, 445, r.poll_interval_ms, r.timezone_id, r.read_only, |
| | | r.capabilities, 'OFFLINE', '0', 0, v.source_config, 'system', v.note |
| | | FROM ( |
| | | SELECT poll_interval_ms, timezone_id, read_only, capabilities |
| | | FROM t_device_adapter |
| | | WHERE adapter_code = 'neware76' AND del_flag = 0 |
| | | ) r |
| | | CROSS JOIN ( |
| | | SELECT 'neware-c' AS code, '新威 C(26.163.107.225)' AS name, '26.163.107.225' AS host, |
| | | '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_C"}' AS source_config, |
| | | '8.0 客户端 8.0.0.484 + 7.6 服务端形态;download 逐点尚未实测' AS note |
| | | UNION ALL SELECT 'neware-d', '新威 D(26.154.232.92)', '26.154.232.92', |
| | | '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_D"}', |
| | | '8.0 客户端 8.0.0.530 + 8.0 服务端形态(库内无逐点,逐点走 download)' |
| | | UNION ALL SELECT 'neware-e', '新威 E(26.168.150.221)', '26.168.150.221', |
| | | '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_E"}', |
| | | '8.0 客户端 8.0.0.478;download 可拉历史测试逐点(已实测)' |
| | | UNION ALL SELECT 'neware-f', '新威 F(26.117.165.166)', '26.117.165.166', |
| | | '{"userEnv":"NEWARE_WIN_USER","passwordEnv":"NEWARE_WIN_PASSWORD_F"}', |
| | | '8.0 客户端 8.0.0.530;实测有活测试,端到端已验' |
| | | ) v; |
| | | |
| | | -- 3) 核对:应看到 4 行 NEWARE_BTS + 1 行 NEWARE_MYSQL,没有 PUSH_INGEST 的 neware-*。 |
| | | SELECT adapter_id, adapter_code, adapter_type, host, port, timezone_id, status, source_config |
| | | FROM t_device_adapter |
| | | WHERE adapter_code LIKE 'neware%' |
| | | ORDER BY adapter_code; |