1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
package com.battery.device.bts;
 
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
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.DeviceWorkStatus;
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>
 *   <li>{@code downloadStepLayer} → 工步层;循环汇总由这些工步按循环聚合(见 {@link #cycleRecords})。</li>
 * </ul>
 *
 * <p><b>不交出的两类事实</b>(都不猜、不用空值冒充已知):
 *
 * <ul>
 *   <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 static final long STEP_LAYER_CACHE_NANOS = 5_000_000_000L;
 
    private final String adapterCode;
    private final BtsTransport transport;
    private final BtsXmlCodec codec;
    private final StepDirectionResolver directions = new BtsStepDirectionResolver();
    private final AdapterCapabilities capabilities;
    /** 每通道最近一次工步层读取;见 {@link #stepLayer}。 */
    private final Map<String, StepLayer> stepLayers = new ConcurrentHashMap<>();
    /**
     * 该客户端有没有数据文件命令({@code inquiredf} / {@code download} / {@code downloadStepLayer})。
     * 未知为 null;探测一次后固定下来——见 {@link #supportsDataFileCommands}。
     */
    private volatile Boolean dataFileCommandsSupported;
 
    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}(现场实测可用)。
     *
     * <p>这条命令**会把正在进行的那一步也交出来**,而且它的 {@code endseqid} 恰好等于
     * {@code inquiredf} 报的已落盘行数(实测四个测试都是相等)——所以**行数比不出结束没有**,
     * 判据是通道的工作状态:仍在运行、暂停或保护中的测试,最后一步没有结束。
     * 交出未结束的段会让它的结束时间每轮都在变,同一条记录反复以不同内容到达,既留下一条错的结束时间,
     * 又不断触发"内容冲突"告警,所以这里把它连同它所在的循环一起按住,等它结束再交出。
     */
    @Override
    public List<StepRecord> stepRecords(ChannelIdentity channel)
    {
        return stepLayer(channel).steps();
    }
 
    /**
     * 返回该通道的循环汇总,由已结束的工步按循环聚合({@link CycleRecord#SOURCE_AGGREGATED})。
     *
     * <p>接口没有循环汇总行,聚合口径与库路线一致:只把方向已确认的工步容量能量加进对应一侧,
     * 搁置与未知方向不进任何一侧(按数值猜方向就是编事实);效率 = 放电容量 / 充电容量 × 100,
     * 没有可用充电容量时留空。含未结束工步的那个循环整轮不交出——那几项合计还在长。
     */
    @Override
    public List<CycleRecord> cycleRecords(ChannelIdentity channel)
    {
        StepLayer layer = stepLayer(channel);
        return aggregateCycles(layer);
    }
 
    /** 返回厂商采样周期作为实时持久化槽位间隔。 */
    @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)
        {
            // 客户端不提供 inquiredf(实测 7.6.0.1475):本来源不按设备数据行接收,逐点回落到轮询槽位。
            return null;
        }
        catch (BtsTransportException unanswered)
        {
            // 探测说支持、发出去却不作答(已登记不再试):同样回落到轮询槽位,不把来源不可达说成"不按行接收"。
            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();
    }
 
    /**
     * 取该通道的工步层,并按"最后一步行不行"切出已结束的部分。
     *
     * <p>结果在 {@link #STEP_LAYER_CACHE_NANOS} 内复用:工步与循环必须来自**同一次读取**,
     * 否则一个工步边界落在两次读取之间时,循环的合计会包含工步列表里没有的那一步,
     * 使循环记录的内容每轮都不同(同一条循环反复以不同内容到达)。
     */
    private StepLayer stepLayer(ChannelIdentity channel)
    {
        String key = channel.vendorKey();
        StepLayer cached = stepLayers.get(key);
        if (cached != null && System.nanoTime() - cached.fetchedAtNanos() < STEP_LAYER_CACHE_NANOS) return cached;
        StepLayer fetched = fetchStepLayer(channel);
        stepLayers.put(key, fetched);
        return fetched;
    }
 
    private StepLayer fetchStepLayer(ChannelIdentity channel)
    {
        long fetchedAtNanos = System.nanoTime();
        // 客户端没有这条命令时一条都不发:见 supportsDataFileCommands。
        if (!supportsDataFileCommands(channel)) return new StepLayer(List.of(), null, fetchedAtNanos);
        Instant receivedAt = Instant.now();
        List<StepRecord> layers;
        try
        {
            layers = codec.parseStepLayer(transport.exchange(codec.downloadStepLayerRequest(channel)), receivedAt);
        }
        catch (BtsTransportException unanswered)
        {
            rememberUnanswered(unanswered);
            return new StepLayer(List.of(), null, fetchedAtNanos);
        }
        if (layers.isEmpty()) return new StepLayer(List.of(), null, fetchedAtNanos);
        // 通道仍在动(运行、暂停、保护中)时最后一步没结束:它和它所在的循环都先按住。
        if (!isStillRunning(channel)) return new StepLayer(layers, null, fetchedAtNanos);
        StepRecord running = layers.get(layers.size() - 1);
        return new StepLayer(layers.subList(0, layers.size() - 1), running.cycleNo(), fetchedAtNanos);
    }
 
    /**
     * 通道工作状态是否说明测试仍在进行。
     *
     * <p>只有读到"测试进行中"的状态才按住最后一步;其余状态(含读不到该通道)按已结束处理。
     * **读取失败不吞**:{@code inquire} 抛错时让它传出去,调用方本来就有"来源读不到就本轮放弃、
     * 下一轮重试"的约定,这样一次传输抖动不会变成"把一个还在跑的工步交出去"。
     */
    private boolean isStillRunning(ChannelIdentity channel)
    {
        RealtimeSnapshot snapshot = inquire(Set.of(channel)).get(channel);
        if (snapshot == null) return false;
        DeviceWorkStatus status = snapshot.workStatus();
        return status == DeviceWorkStatus.WORKING || status == DeviceWorkStatus.PAUSED
                || status == DeviceWorkStatus.PROTECTED || status == DeviceWorkStatus.SYNCHRONIZING;
    }
 
    /** 把已结束的工步按循环聚合;含未结束工步的那个循环不交出。 */
    private List<CycleRecord> aggregateCycles(StepLayer layer)
    {
        Map<Integer, List<StepRecord>> byCycle = new LinkedHashMap<>();
        for (StepRecord step : layer.steps())
        {
            if (layer.unfinishedCycleNo() != null && layer.unfinishedCycleNo() == step.cycleNo()) continue;
            byCycle.computeIfAbsent(step.cycleNo(), ignored -> new ArrayList<>()).add(step);
        }
        List<CycleRecord> cycles = new ArrayList<>(byCycle.size());
        byCycle.forEach((cycleNo, group) -> {
            BigDecimal chargeCapacity = BigDecimal.ZERO, dischargeCapacity = BigDecimal.ZERO;
            BigDecimal chargeEnergy = BigDecimal.ZERO, dischargeEnergy = BigDecimal.ZERO;
            for (StepRecord step : group)
            {
                if (step.capacityAh() == null || step.energyWh() == null) continue;
                BigDecimal capacity = BigDecimal.valueOf(step.capacityAh());
                BigDecimal energy = BigDecimal.valueOf(step.energyWh());
                switch (directions.resolve(step.stepType()))
                {
                    case CHARGE ->
                    {
                        chargeCapacity = chargeCapacity.add(capacity);
                        chargeEnergy = chargeEnergy.add(energy);
                    }
                    case DISCHARGE ->
                    {
                        dischargeCapacity = dischargeCapacity.add(capacity);
                        dischargeEnergy = dischargeEnergy.add(energy);
                    }
                    // 搁置与未知方向不进任何一侧:把容量算进某一侧就是凭数值猜方向。
                    default -> { }
                }
            }
            StepRecord first = group.get(0), last = group.get(group.size() - 1);
            cycles.add(new CycleRecord("neware:" + testIdOf(first) + ":cycle:" + cycleNo, cycleNo,
                    first.startedAt(), last.endedAt(), chargeCapacity.doubleValue(), dischargeCapacity.doubleValue(),
                    chargeEnergy.doubleValue(), dischargeEnergy.doubleValue(),
                    chargeCapacity.signum() == 0 ? null : dischargeCapacity.multiply(BigDecimal.valueOf(100))
                            .divide(chargeCapacity, 4, RoundingMode.HALF_UP).doubleValue(),
                    last.deviceTimeRaw(), codec.timezoneId(), last.serverReceivedAt(), CycleRecord.SOURCE_AGGREGATED));
        });
        return List.copyOf(cycles);
    }
 
    /** 从工步的来源行身份里取回测试号(形如 {@code neware:<测试号>:step:<序号>})。 */
    private static String testIdOf(StepRecord step)
    {
        String identity = step.sourceRecordId();
        if (identity == null) return "unknown";
        String[] parts = identity.split(":");
        return parts.length >= 2 ? parts[1] : "unknown";
    }
 
    /**
     * 一次工步层读取的结果:已结束的工步、被按住的未结束工步所属循环(没有则为空)。
     */
    private record StepLayer(List<StepRecord> steps, Integer unfinishedCycleNo, long fetchedAtNanos) { }
 
    private BtsXmlCodec.DataFileState dataFileState(ChannelIdentity channel)
    {
        if (!supportsDataFileCommands(channel))
            throw new BtsProtocolException(BtsProtocolException.Reason.MISSING_FIELD,
                    "该客户端不提供 inquiredf");
        try
        {
            return codec.parseDataFileState(transport.exchange(codec.inquiredfRequest(channel)));
        }
        catch (BtsTransportException unanswered)
        {
            rememberUnanswered(unanswered);
            throw unanswered;
        }
    }
 
    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());
    }
 
    /**
     * 这台客户端有没有数据文件命令({@code inquiredf}/{@code download}/{@code downloadStepLayer})。
     *
     * <p>为什么必须先问一句再发:**对不支持这些命令的客户端发出去,代价不只是超时**。2026-10-12 实测
     * (客户端 7.6.0.1475):发一条 {@code inquiredf} 之后,该接口的 {@code inquire} 连续 8 分钟无应答
     * ({@code getdevinfo} 仍正常)——那等于干扰了那台上位机的对外接口。所以不支持的客户端
     * **一条都不发**,逐点回落到轮询槽位、工步层为空。
     *
     * <p>探测依据是 {@code inquire} 应答的字段:7.6 的应答不带 {@code cycle_id},8.0 的带;
     * 实测三个构建(7.6.0.1475 / 8.0.0.484 / 8.0.0.530)里"有没有 {@code cycle_id}"与
     * "有没有这三条数据文件命令"同向。探测本身读不到应答时**抛出**(不吞):那说明来源不可达,
     * 按契约要抛异常,不能被当成"该来源不按数据行接收"。
     */
    private boolean supportsDataFileCommands(ChannelIdentity channel)
    {
        Boolean known = dataFileCommandsSupported;
        if (known != null) return known;
        RealtimeSnapshot snapshot = inquire(Set.of(channel)).get(channel);
        boolean supported = snapshot != null && snapshot.cycleNo() != null;
        dataFileCommandsSupported = supported;
        return supported;
    }
 
    /** 探测说支持、真发出去却不作答:记下来不再反复试——反复发一条无应答的命令会持续干扰那台接口。 */
    private void rememberUnanswered(BtsTransportException failure)
    {
        if (failure.reason() == BtsTransportException.Reason.TIMEOUT) dataFileCommandsSupported = false;
    }
 
    /** 行的序号比水位大就是要的行;序号反而更小说明换了测试,此时按设备时间判定。 */
    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();
    }
}