yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
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
package com.battery.business.service.impl;
 
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.battery.device.api.DeviceAdapter;
import com.battery.device.api.DeviceAdapterException;
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.DetectedDeviceMetadata;
import com.battery.device.api.model.DeviceConnectionStatus;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.api.model.RealtimeSnapshot;
import com.battery.device.api.registry.AdapterInstanceConfig;
import com.battery.business.domain.TDeviceIngestRecord;
import com.battery.business.domain.vo.TDeviceIngestChannel;
import com.battery.business.mapper.TDeviceIngestRecordMapper;
 
/**
 * 推式来源的观测实现(存储形态 {@code PUSH_INGEST},ADR-0065):读本项目的落地表,
 * 交出与直连来源完全相同的统一观测。
 *
 * <p>这类来源的机器没有可用的网络协议(22 所:TCP 与命名管道都未启用、Windows 账号未获提供),
 * 只能在它本机跑一个只读取数程序,把厂商原始行推到接收端点。程序本身很蠢——固定查询、原样推出、
 * 不开入站端口、不在网络上传递厂商库凭据;解释(身份映射、时间换算、幂等键取值)全部在后端完成,
 * 所以这里只做投影:落地行的字段由接收端点解析好,本类不出现任何厂商列名。
 *
 * <p>三条本形态特有的事实:
 * <ul>
 *   <li><b>来源是否在线由心跳判定</b>:落地表在本地随时可读,因此"能否读到表"不能表示来源可达。
 *       取数程序每轮推送一次心跳,心跳缺失即认为来源不可达——发现路径明确失败,
 *       已登记设备随之离线、旧快照被撤下(既有路径),页面不会显示伪造的实时值。</li>
 *   <li><b>最近数据时间只看采样行</b>:没有新采样行就是没有,不按拓扑上报或心跳时间代替(ADR-0063)。</li>
 *   <li><b>通道工作状态不可观测</b>:落地表里没有设备侧状态,报"无法观测"而不由数据行推断。</li>
 * </ul>
 */
public final class PushIngestAdapter implements DeviceAdapter, TestDataObservationAdapter
{
    /** 适配器类型按存储形态划分:这个形态读本项目的落地表(ADR-0065)。 */
    public static final String ADAPTER_TYPE = "PUSH_INGEST";
    /**
     * 身份映射(后端完成):22 所的存储形态没有设备类型与单元维度,一台电脑就是登记意义上的一个柜,
     * 通道号就是厂商自己的通道号(表名 {@code Ws<N>_IV_0} 里的 N)。厂商通道号进 {@code chlid},
     * {@code subdevid} 固定为 1,使界面通道号显示为「1-26」而不是「0-26」。
     * 若甲方要求按接口再分成几个柜,那是新增通道→接口映射的新决策,不在本单。
     *
     * <p>接收端点写落地行时用的是同一组常量({@code DeviceIngestServiceImpl.base}),
     * 使"身份映射只在这个形态里定义一次"成立。
     */
    public static final int DEVICE_TYPE = 0;
    public static final int DEVICE_ID = 0;
    public static final int SUB_DEVICE_ID = 1;
    /**
     * 落地表在本地,读它不产生厂商负载,所以按展示节奏取数(控制台 5 秒一轮);
     * 只取水位之后的新行,取数更勤不会产生重复记录。厂商写库节奏仍是 60 秒,值不会来得更快。
     */
    private static final long PERSISTENCE_INTERVAL_MS = 5000L;
    private static final int SAMPLE_BATCH_LIMIT = 500;
    /** 心跳容忍窗口的下限:写库与推送都是 60 秒一轮,窗口至少覆盖三轮。 */
    private static final Duration MINIMUM_STALE_AFTER = Duration.ofSeconds(90);
 
    private final AdapterInstanceConfig config;
    private final TDeviceIngestRecordMapper records;
    private final Clock clock;
 
    public PushIngestAdapter(AdapterInstanceConfig config, TDeviceIngestRecordMapper records)
    {
        this(config, records, Clock.systemUTC());
    }
 
    public PushIngestAdapter(AdapterInstanceConfig config, TDeviceIngestRecordMapper records, Clock clock)
    {
        this.config = config;
        this.records = records;
        this.clock = clock;
    }
 
    /** 返回本实例登记的适配器编码;推送端点与通道身份都用它定位来源。 */
    @Override
    public String adapterCode()
    {
        return config.adapterCode();
    }
 
    /** 返回本实例登记的能力标记;推式来源同样只观测,不提供控制能力。 */
    @Override
    public AdapterCapabilities capabilities()
    {
        return config.capabilities();
    }
 
    /**
     * 候选来自取数程序的拓扑上报:每条候选附带落地表里该通道最近的采样行时间。
     * 不自动判定"在用",登记哪些通道由操作员决定(ADR-0063)。
     *
     * <p>候选的机器地址取实例配置的 {@code host}:这个形态里它表示**取数程序所在那台机器**,
     * 登记检测柜时会被带进 IP 字段(推送目标地址是取数程序自己的配置项,不在这边)。
     *
     * <p>心跳缺失时明确失败(而不是返回空清单):来源不可达是一个可见的失败事实,
     * 业务侧据此把已登记设备置为离线并撤下旧快照。
     */
    @Override
    public List<DetectedChannel> discoverChannels()
    {
        requireFreshHeartbeat();
        List<DetectedChannel> channels = new ArrayList<>();
        List<TDeviceIngestChannel> reported = records.selectTopologyChannels(config.adapterCode());
        for (TDeviceIngestChannel channel : reported == null ? List.<TDeviceIngestChannel>of() : reported)
        {
            Instant lastDataAt = channel.getLastDataAt() == null ? null : channel.getLastDataAt().toInstant();
            channels.add(new DetectedChannel(identity(channel.getDevid(), channel.getSubdevid(), channel.getChlid()),
                    config.host(), true, DetectedDeviceMetadata.empty(), lastDataAt));
        }
        return List.copyOf(channels);
    }
 
    /** 实时快照取自该通道最近一次采样行;没有采样行时不返回该通道,避免沿用过期观测。 */
    @Override
    public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
    {
        if (channels == null || channels.isEmpty()) return Map.of();
        Map<ChannelIdentity, RealtimeSnapshot> result = new HashMap<>();
        for (ChannelIdentity channel : channels)
        {
            CabinetRealtime latest = latestCabinet(channel);
            if (latest == null) continue;
            result.put(channel, snapshot(channel, latest));
        }
        return Map.copyOf(result);
    }
 
    /**
     * 返回该通道在落地表里最近一条采样行投影成的实时观测。
     *
     * @param channel 检测柜通道身份
     * @return 最近一条采样行的实时观测;该通道还没有采样行时为空
     */
    @Override
    public CabinetRealtime latestCabinet(ChannelIdentity channel)
    {
        TDeviceIngestRecord row = latestSample(channel);
        return row == null ? null : cabinetRealtime(row);
    }
 
    /**
     * 22 所的 IV 表里有温箱温度、压力与计数器,是现场唯一见到环境量的来源,
     * 但类目语义与量纲尚未确认。按 ADR-0064 逐来源确认可得性,确认之前不交出测量值。
     */
    @Override
    public CollectorRealtime latestCollector(String adapterCode, String collectorCode)
    {
        return null;
    }
 
    /**
     * 22 所没有独立的工步层:工步与循环号只在采样行的 {@code Step_Index} / {@code Cycle_Index} 里,
     * 从采样推导工步是另一项决策(与"工步来自设备工步层"的既有口径冲突),不在本单范围,因此返回空。
     */
    @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 PERSISTENCE_INTERVAL_MS;
    }
 
    /** 水位是落地表里该通道已存在的最新一行:设备绝对时间与协议序号。 */
    @Override
    public SampleWatermark currentWatermark(ChannelIdentity channel)
    {
        TDeviceIngestRecord row = latestSample(channel);
        return row == null || row.getDeviceTimeUtc() == null
                ? new SampleWatermark(null, 0L)
                : new SampleWatermark(row.getDeviceTimeUtc().toInstant(), row.getRecordSequence());
    }
 
    /**
     * 严格晚于水位的落地行。按 {@code (设备时间, 协议序号)} 过滤与排序:同一绝对秒内的多行各自成立,
     * 不按秒合并。水位为空表示调用方尚未取得水位,返回空列表而不把来源里的历史并入本次接收。
     *
     * <p>这里不判心跳:落地行是"我们已经收到并解释过的设备事实",
     * 来源离线只影响发现与实时观测,不影响这些已经收到的行。
     */
    @Override
    public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark)
    {
        if (watermark == null) return List.of();
        ChannelIdentity key = requireOwnChannel(channel);
        java.util.Date watermarkTime = watermark.deviceTime() == null ? null : java.util.Date.from(watermark.deviceTime());
        List<TDeviceIngestRecord> rows = records.selectSamplesSince(config.adapterCode(), key.devtype(), key.devid(),
                key.subdevid(), key.chlid(), watermarkTime, watermark.sequenceId(), SAMPLE_BATCH_LIMIT);
        List<CabinetRealtime> samples = new ArrayList<>();
        for (TDeviceIngestRecord row : rows == null ? List.<TDeviceIngestRecord>of() : rows) samples.add(cabinetRealtime(row));
        return List.copyOf(samples);
    }
 
    // ------------------------------------------------------------------ 投影与判定
 
    /** 心跳缺失即来源不可达:取数程序停了、或它自己连不上厂商库。 */
    private void requireFreshHeartbeat()
    {
        java.util.Date receivedAt = records.selectHeartbeatAt(config.adapterCode());
        if (receivedAt == null)
            throw new DeviceAdapterException("SOURCE_OFFLINE",
                    "尚未收到取数程序的推送:请确认 " + config.host()
                            + " 那台机器上的取数程序已启动,并指向本系统的接收端点", null);
        Duration staleAfter = staleAfter();
        Duration silent = Duration.between(receivedAt.toInstant(), clock.instant());
        if (silent.compareTo(staleAfter) > 0)
            throw new DeviceAdapterException("SOURCE_OFFLINE", "取数程序已停止推送:最近一次推送在 "
                    + silent.toSeconds() + " 秒前,超过容忍窗口 " + staleAfter.toSeconds() + " 秒", null);
    }
 
    /** 容忍窗口按实例配置的轮询周期算:写库与推送都是这个节奏,连丢三轮即认为离线。 */
    private Duration staleAfter()
    {
        Duration window = Duration.ofMillis(config.pollIntervalMs()).multipliedBy(3);
        return window.compareTo(MINIMUM_STALE_AFTER) < 0 ? MINIMUM_STALE_AFTER : window;
    }
 
    private TDeviceIngestRecord latestSample(ChannelIdentity channel)
    {
        ChannelIdentity key = requireOwnChannel(channel);
        return records.selectLatestSample(config.adapterCode(), key.devtype(), key.devid(), key.subdevid(),
                key.chlid());
    }
 
    private ChannelIdentity requireOwnChannel(ChannelIdentity channel)
    {
        if (channel == null || !config.adapterCode().equals(channel.adapterCode()))
            throw new DeviceAdapterException("通道身份不属于本实例:"
                    + (channel == null ? "空" : channel.adapterCode()));
        return channel;
    }
 
    private CabinetRealtime cabinetRealtime(TDeviceIngestRecord row)
    {
        // 充放电容量按电流方向取对应的一组:与直连来源同一口径(22 所的电流符号约定待现场对着上位机确认)。
        BigDecimal current = row.getCurrentA() == null ? BigDecimal.ZERO : row.getCurrentA();
        boolean charging = current.signum() >= 0;
        BigDecimal capacity = charging ? row.getChargeCapacityAh() : row.getDischargeCapacityAh();
        BigDecimal energy = charging ? row.getChargeEnergyWh() : row.getDischargeEnergyWh();
        return new CabinetRealtime(row.getRecordSequence() == null ? 0L : row.getRecordSequence(),
                row.getDeviceTimeUtc() == null ? null : row.getDeviceTimeUtc().toInstant(),
                current.doubleValue(), decimal(row.getVoltageV()), decimal(capacity), decimal(energy),
                row.getCycleNo() == null ? 0 : row.getCycleNo(), row.getStepNo() == null ? 0 : row.getStepNo(),
                null, null, row.getReceivedAtUtc() == null ? null : row.getReceivedAtUtc().toInstant(),
                row.getDeviceTimeRaw(), row.getTimezoneId(),
                row.getRelativeTimeSeconds() == null ? null : row.getRelativeTimeSeconds().longValue(),
                "push:" + row.getRecordKey());
    }
 
    private RealtimeSnapshot snapshot(ChannelIdentity channel, CabinetRealtime latest)
    {
        // 读落地表路线下通道工作状态不可观测(ADR-0063):不按数据行推断,也不沿用上一次状态。
        return new RealtimeSnapshot(channel, DeviceWorkStatus.UNOBSERVABLE, latest.cycleNo(), latest.stepNo(),
                latest.stepType(), null, BigDecimal.valueOf(latest.currentA()), BigDecimal.valueOf(latest.voltageV()),
                BigDecimal.valueOf(latest.capacityAh()), BigDecimal.valueOf(latest.energyWh()), null,
                latest.relativeTimeSeconds(), null, latest.serverReceivedAt(), Map.of(),
                DeviceConnectionStatus.ONLINE, latest.deviceTime(), List.of(), null);
    }
 
    private ChannelIdentity identity(Integer devid, Integer subdevid, Integer chlid)
    {
        return new ChannelIdentity(config.adapterCode(), DEVICE_TYPE,
                devid == null ? DEVICE_ID : devid, subdevid == null ? SUB_DEVICE_ID : subdevid,
                chlid == null ? 0 : chlid);
    }
 
    private static double decimal(BigDecimal value)
    {
        return value == null ? 0d : value.doubleValue();
    }
}