package com.ruoyi.device.bts; import java.io.IOException; import java.io.StringReader; import java.math.BigDecimal; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamReader; import com.ruoyi.device.model.DeviceWorkStatus; import com.ruoyi.device.simulator.BtsSimulatorStateStore; import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelTombstone; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorChannelState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile; import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord; import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsPlaybackState; import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest; import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState; import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore; import com.ruoyi.device.simulator.DevelopmentCollectorSimulation; import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorAdvance; import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage; import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot; /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */ public final class BtsDevelopmentSimulator implements AutoCloseable { public static final String HOST = "127.0.0.1"; public static final int DEFAULT_PORT = 15020; public static final String ADAPTER_CODE = "dev-sim"; public static final String ADAPTER_NAME = "dev-sim BTS 模拟器"; public static final int POLL_INTERVAL_MS = 5000; public static final String NEWARE_CT_4008T_5V6A_S1_F = "CT-4008T-5V6A-S1-F"; public static final String NEWARE_CT_4008TN_5V12A_S1_F = "CT-4008Tn-5V12A-S1-F"; private static final int MAX_CHANNEL_COUNT = 99; private static final int MAX_RECENT_REQUESTS = 20; private static final int TOMBSTONE_DISCOVERY_COUNT = 3; private final ScriptedBtsTcpSimulator simulator; private final BtsSimulatorStateStore stateStore; private SimulatorState state; private boolean running = true; private long observationSequence; private final java.util.Map> btsHistory = new java.util.LinkedHashMap<>(); private static final long SIMULATED_RECORD_INTERVAL_SECONDS = 60L; private static final DateTimeFormatter BTS_HISTORY_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss") .withZone(ZoneId.of("Asia/Hong_Kong")); /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */ public BtsDevelopmentSimulator(int port) throws IOException { this(port, new InMemoryBtsSimulatorStateStore()); } /** 从长期状态恢复后再启动 TCP 服务,避免服务先暴露默认拓扑。 */ public BtsDevelopmentSimulator(int port, BtsSimulatorStateStore stateStore) throws IOException { this.stateStore = stateStore; state = stateStore.loadOrInitialize(defaultState()); // 仅清理 Ticket13 之前嵌在主状态中的旧演示历史;独立历史由存储实现保留并继续补采。 state = stateStore.resetHistoryForDemoStartup(state); restoreBtsHistory(); validateRestoredState(); stateStore.completeValidatedRestore(state); simulator = ScriptedBtsTcpSimulator.responding(port, this::respond); } public int port() { return simulator.port(); } /** 新增一台具有独立虚拟 IP 和通道数的开发设备。 */ public synchronized int addDevice(String ip, int channelCount) { validateDeviceConfiguration(ip, channelCount, null); int deviceId = state.nextDeviceId(); List devices = new ArrayList<>(state.devices()); devices.add(newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount))); persist(new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, devices, state.collectors(), deviceId + 1, state.nextCollectorId(), state.tombstones())); return deviceId; } /** 修改开发设备的虚拟地址、通道数和在线状态。 */ public synchronized void updateDevice(int deviceId, String ip, int channelCount, boolean online) { DeviceState current = requireDevice(deviceId); validateDeviceConfiguration(ip, channelCount, deviceId); List channels = new ArrayList<>(channelCount); for (int channelId = 1; channelId <= channelCount; channelId++) { channels.add(channelId <= current.channels().size() ? current.channels().get(channelId - 1) : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); } List devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels, resizeProfile(current.profile(), channelCount))); List tombstones = new ArrayList<>(state.tombstones()); for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++) upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId); tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() <= channelCount); persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(), state.nextCollectorId(), tombstones)); } /** 使用现场确认的新威检测柜 profile 新增设备,型号之外的控制语义仍保持只读。 */ public synchronized int addNewareDevice(String ip, String model) { BtsDeviceProfile profile = newareProfile(model); validateDeviceConfiguration(ip, profile.channelCount(), null); int deviceId = state.nextDeviceId(); List devices = new ArrayList<>(state.devices()); devices.add(newDevice(deviceId, ip, profile)); persist(new SimulatorState(state.version(), devices, state.collectors(), deviceId + 1, state.nextCollectorId(), state.tombstones())); return deviceId; } /** 将已有模拟设备切换为新威 profile,并按 profile 通道数调整拓扑。 */ public synchronized void configureNewareProfile(int deviceId, String model) { DeviceState current = requireDevice(deviceId); BtsDeviceProfile profile = newareProfile(model); updateDeviceWithProfile(current, profile); } /** 删除指定开发设备;后续发现响应不再返回它。 */ public synchronized void deleteDevice(int deviceId) { DeviceState removed = requireDevice(deviceId); List devices = new ArrayList<>(state.devices()); devices.removeIf(item -> item.deviceId() == deviceId); List tombstones = new ArrayList<>(state.tombstones()); BtsDeviceProfile removedProfile = profileFor(removed); for (ChannelState channel : removed.channels()) { upsertTombstone(tombstones, removed.deviceId(), removed.ip(), channel.channelId()); String key = historyKey(removedProfile.devtype(), removed.deviceId(), removedProfile.subDeviceId(), channel.channelId()); stateStore.deleteBtsHistory(key); stateStore.deleteBtsPlaybackState(key); btsHistory.remove(key); } persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(), state.nextCollectorId(), tombstones)); } /** 修改指定开发设备的单个通道状态。 */ public synchronized void updateChannel(int deviceId, int channelId, boolean online, DeviceWorkStatus workStatus) { requireControllableStatus(workStatus); DeviceState device = requireDevice(deviceId); if (channelId < 1 || channelId > device.channels().size()) throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size()); List channels = new ArrayList<>(device.channels()); channels.set(channelId - 1, new ChannelState(channelId, online, workStatus)); persist(new SimulatorState(state.version(), replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(), state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); } /** 新增一台与 BTS 检测柜拓扑隔离的开发采集器。 */ public synchronized int addCollector(String ip, int channelCount) { validateCollectorConfiguration(ip, channelCount, null); int collectorId = state.nextCollectorId(); List collectors = new ArrayList<>(state.collectors()); collectors.add(newCollector(collectorId, ip, channelCount)); persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), collectorId + 1, state.tombstones())); return collectorId; } /** 修改开发采集器的虚拟地址、通道数和在线状态。 */ public synchronized void updateCollector(int collectorId, String ip, int channelCount, boolean online) { validateCollectorConfiguration(ip, channelCount, collectorId); CollectorState current = requireCollector(collectorId); List collectors = new ArrayList<>(state.collectors()); collectors.replaceAll(item -> item.collectorId() == collectorId ? DevelopmentCollectorSimulation.resize(current, ip, channelCount, online, Instant.now()) : item); persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); } /** 删除指定开发采集器,不影响检测柜 TCP 发现结果。 */ public synchronized void deleteCollector(int collectorId) { CollectorState collector = requireCollector(collectorId); List collectors = new ArrayList<>(state.collectors()); collectors.removeIf(item -> item.collectorId() == collectorId); SimulatorState next = new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), state.nextCollectorId(), state.tombstones()); stateStore.saveAndDeleteCollectorHistory(next, collector.deviceCode()); this.state = next; } /** 配置采集器独立采样频率和历史下载故障场景。 */ public synchronized void configureCollectorSampling(int collectorId, int sampleIntervalMs, CollectorHistoryFaults historyFaults) { CollectorState current = requireCollector(collectorId); replaceCollector(DevelopmentCollectorSimulation.configureSampling(current, sampleIntervalMs, historyFaults)); } /** 配置一个稳定 S 通道的固定或动态演示指标。 */ public synchronized void configureCollectorChannel(int collectorId, String channelCode, List metrics) { CollectorState current = requireCollector(collectorId); replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics)); } /** * 模拟现场人工统一开关机:关机停止采样但保留历史,开机后检测柜通道保持待机。 * 该操作只改变模拟设备事实,不代表系统具备真实设备控制能力。 */ public synchronized void setAllDevicesPoweredOn(boolean poweredOn) { List devices = state.devices().stream() .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn, device.channels().stream() .map(channel -> new ChannelState(channel.channelId(), poweredOn, DeviceWorkStatus.STOPPED)) .toList(), device.profile())) .toList(); Instant changedAt = Instant.now(); List collectors = state.collectors().stream() .map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(), collector.channelCount(), poweredOn, changedAt)) .toList(); persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); } /** 按每台采集器自己的频率推进实时和历史采样。 */ public synchronized void advanceCollectors(Instant now) { boolean hasOnlineCollector = false; for (CollectorState collector : state.collectors()) { if (!collector.online()) continue; hasOnlineCollector = true; break; } if (!hasOnlineCollector) return; List collectors = new ArrayList<>(state.collectors().size()); List historyAppends = new ArrayList<>(); for (CollectorState collector : state.collectors()) { CollectorAdvance advance = DevelopmentCollectorSimulation.advance(collector, now); collectors.add(advance.state()); if (!advance.appendedRecords().isEmpty()) historyAppends.add(new CollectorHistoryAppend(collector.deviceCode(), advance.appendedRecords())); } if (!historyAppends.isEmpty()) persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), state.nextCollectorId(), state.tombstones()), historyAppends); } /** 按墙上时间推进检测柜模拟播放;设备状态和测试接收状态互不影响。 */ public synchronized void advanceBts(Instant now) { for (DeviceState device : state.devices()) { if (!device.online()) continue; BtsDeviceProfile profile = profileFor(device); for (ChannelState channel : device.channels()) { if (!channel.online() || channel.workStatus() != DeviceWorkStatus.WORKING) continue; String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId()); BtsPlaybackState playback = playbackState(key, now); advanceBtsPlayback(key, profile, playback, profile.currentMaxA().doubleValue() * 0.2d, 3.7d, now); } } } /** 设置检测柜通道演示倍速,只接受 1x、60x 和 600x。 */ public synchronized void setBtsSpeed(int deviceId, int channelId, int speedMultiplier) { DeviceState device = requireDevice(deviceId); requireChannel(device, channelId); String key = btsKey(device, channelId); BtsPlaybackState current = playbackState(key, Instant.now()); stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(current.playing(), speedMultiplier, current.cycleNo(), current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(), Instant.now(), current.nextSequence(), current.naturallyFinished())); } /** 开始播放指定检测柜通道的演示时间线。 */ public synchronized void playBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); } /** 暂停模拟器时间线,不改变检测柜通道工作事实。 */ public synchronized void pauseBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, false); } /** 从暂停状态恢复模拟器时间线。 */ public synchronized void resumeBts(int deviceId, int channelId) { setBtsPlaying(deviceId, channelId, true); } private void setBtsPlaying(int deviceId, int channelId, boolean playing) { DeviceState device = requireDevice(deviceId); requireChannel(device, channelId); String key = btsKey(device, channelId); Instant now = Instant.now(); BtsPlaybackState current = playbackState(key, now); if (current.naturallyFinished() && playing) return; stateStore.saveBtsPlaybackState(key, new BtsPlaybackState(playing, current.speedMultiplier(), current.cycleNo(), current.stepNo(), current.stepName(), current.stepType(), current.simulatedAt(), now, current.nextSequence(), current.naturallyFinished())); } /** 跳转到下一实际工步;不向设备发送控制报文。 */ public synchronized void nextBtsStep(int deviceId, int channelId) { DeviceState device = requireDevice(deviceId); requireChannel(device, channelId); String key = btsKey(device, channelId); BtsPlaybackState current = playbackState(key, Instant.now()); BtsExcelFixture.FixtureRecord next = nextFixtureStep(current.cycleNo(), current.stepNo()); if (next == null) { BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount()); savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(), last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(), BtsExcelFixture.recordCount() + 1L, true)); return; } savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(), next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false)); } /** 跳转到下一循环的首工步;不补齐当前循环缺失的首尾工步。 */ public synchronized void nextBtsCycle(int deviceId, int channelId) { DeviceState device = requireDevice(deviceId); requireChannel(device, channelId); String key = btsKey(device, channelId); BtsPlaybackState current = playbackState(key, Instant.now()); BtsExcelFixture.FixtureRecord next = BtsExcelFixture.records().stream() .filter(item -> item.cycleNo() > current.cycleNo()).findFirst().orElse(null); if (next == null) { BtsExcelFixture.FixtureRecord last = BtsExcelFixture.record(BtsExcelFixture.recordCount()); savePlayback(key, new BtsPlaybackState(false, current.speedMultiplier(), last.cycleNo(), last.stepNo(), last.stepName(), last.stepType(), current.simulatedAt(), Instant.now(), BtsExcelFixture.recordCount() + 1L, true)); return; } savePlayback(key, new BtsPlaybackState(current.playing(), current.speedMultiplier(), next.cycleNo(), next.stepNo(), next.stepName(), next.stepType(), current.simulatedAt(), Instant.now(), next.sequenceId(), false)); } /** 清空单通道播放和原始数据,恢复到首循环首工步。 */ public synchronized void resetBts(int deviceId, int channelId) { DeviceState device = requireDevice(deviceId); requireChannel(device, channelId); String key = btsKey(device, channelId); btsHistory.remove(key); stateStore.deleteBtsHistory(key); stateStore.deleteBtsPlaybackState(key); savePlayback(key, defaultPlayback(Instant.now())); } /** 返回播放状态及当前通道的 record、step、cycle 数据。 */ public synchronized PlaybackSnapshot btsPlayback(int deviceId, int channelId) { DeviceState device = requireDevice(deviceId); requireChannel(device, channelId); String key = btsKey(device, channelId); BtsPlaybackState state = playbackState(key, Instant.now()); return new PlaybackSnapshot(deviceId, channelId, state, btsRecords(deviceId, channelId), btsSteps(deviceId, channelId), btsCycles(deviceId, channelId)); } /** 返回只追加的检测柜原始 record。 */ public synchronized List btsRecords(int deviceId, int channelId) { DeviceState device = requireDevice(deviceId); requireChannel(device, channelId); return List.copyOf(btsHistoryFor(btsKey(device, channelId))); } /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */ public synchronized List btsSteps(int deviceId, int channelId) { java.util.Map grouped = new java.util.LinkedHashMap<>(); for (BtsHistoryRecord item : btsRecords(deviceId, channelId)) { String key = item.cycleNo() + ":" + item.stepNo(); grouped.computeIfAbsent(key, ignored -> new BtsStepAccumulator(item)).add(item); } return grouped.values().stream().map(BtsStepAccumulator::finish).toList(); } /** 按实际出现的循环汇总 record,不增加测试结束统计行。 */ public synchronized List btsCycles(int deviceId, int channelId) { java.util.Map grouped = new java.util.LinkedHashMap<>(); for (BtsHistoryRecord item : btsRecords(deviceId, channelId)) grouped.computeIfAbsent(item.cycleNo(), ignored -> new BtsCycleAccumulator(item)).add(item); return grouped.entrySet().stream().map(entry -> { BtsCycleRecord calculated = entry.getValue().finish(); BtsExcelFixture.CycleSummary expected = BtsExcelFixture.cycle(entry.getKey()); String recordNo = "CYCLE-" + deviceId + "-" + channelId + "-" + entry.getKey(); if (expected == null || entry.getValue().count < expected.recordCount()) return new BtsCycleRecord(recordNo, calculated.cycleNo(), calculated.recordCount(), calculated.startedAt(), calculated.endedAt(), calculated.chargeCapacityAh(), calculated.dischargeCapacityAh(), calculated.chargeEnergyWh(), calculated.dischargeEnergyWh(), calculated.efficiency()); return new BtsCycleRecord(recordNo, calculated.cycleNo(), expected.recordCount(), calculated.startedAt(), calculated.endedAt(), expected.chargeCapacityAh(), expected.dischargeCapacityAh(), expected.chargeEnergyWh(), expected.dischargeEnergyWh(), efficiency(expected.chargeCapacityAh(), expected.dischargeCapacityAh())); }).toList(); } /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */ public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId) { return DevelopmentCollectorSimulation.realtime(requireCollector(collectorId)); } /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */ public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit) { CollectorState collector = requireCollector(collectorId); return DevelopmentCollectorSimulation.history(collector, stateStore, offset, limit); } /** 返回控制页需要的运行状态快照。 */ public synchronized StateSnapshot snapshot() { List deviceSnapshots = state.devices().stream() .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), device.profile(), device.channels().stream() .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(), channel.workStatus())) .toList())) .toList(); List collectorSnapshots = state.collectors().stream() .map(collector -> new CollectorSnapshot(collector.collectorId(), collector.deviceCode(), collector.ip(), collector.mac(), collector.name(), collector.manufacturer(), collector.model(), collector.channelCount(), collector.online(), collector.sampleIntervalMs(), collector.channels(), collector.historyFaults(), collector.historyRecordCount(), DevelopmentCollectorSimulation.realtime(collector).measurements())) .toList(); return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots, stateStore.loadRecentRequests()); } /** 人工恢复默认拓扑;这是正常运行中唯一清空长期模拟状态的入口。 */ public synchronized void reset() { SimulatorState defaultState = defaultState(); stateStore.reset(defaultState); btsHistory.clear(); state = defaultState; } private SimulatorState defaultState() { return new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, List.of(newDevice(1, "127.0.0.2", 4)), List.of(newCollector(1, "127.0.1.2", 4)), 2, 2, List.of()); } private DeviceState newDevice(int deviceId, String ip, int channelCount) { return newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount)); } private DeviceState newDevice(int deviceId, String ip, BtsDeviceProfile profile) { List channels = new ArrayList<>(profile.channelCount()); for (int channelId = 1; channelId <= profile.channelCount(); channelId++) channels.add(new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); return new DeviceState(deviceId, ip.trim(), true, channels, profile); } private CollectorState newCollector(int collectorId, String ip, int channelCount) { return new CollectorState(collectorId, "dev-sim-collector-" + collectorId, ip.trim(), simulatorCollectorMac(collectorId), "dev-sim 采集器 " + collectorId, "开发模拟器", "Virtual Collector", channelCount, true); } private void replaceCollector(CollectorState replacement) { List collectors = new ArrayList<>(state.collectors()); collectors.replaceAll(item -> item.collectorId() == replacement.collectorId() ? replacement : item); persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); } private String respond(String request) { String command = readCommand(request); recordRequest(command, request); return switch (command) { case "getdevinfo" -> deviceInfoResponse(); case "inquire" -> observationResponse(request); case "inquiredf" -> historyAvailabilityResponse(request); case "download" -> historyResponse(request); default -> "error"; }; } private synchronized void recordRequest(String command, String request) { List recentRequests = new ArrayList<>(stateStore.loadRecentRequests()); recentRequests.add(0, new RecentRequest(Instant.now(), command, request)); if (recentRequests.size() > MAX_RECENT_REQUESTS) recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS)); stateStore.saveRecentRequests(recentRequests); } private String readCommand(String request) { try { XMLInputFactory factory = XMLInputFactory.newFactory(); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request)); while (reader.hasNext()) { if (reader.next() == XMLStreamConstants.START_ELEMENT && "cmd".equals(reader.getLocalName())) return reader.getElementText().trim(); } } catch (Exception exception) { throw new IllegalArgumentException("Invalid BTS request XML", exception); } throw new IllegalArgumentException("BTS request does not contain cmd"); } private synchronized String deviceInfoResponse() { int channelCount = state.devices().stream().mapToInt(device -> device.channels().size()).sum() + state.tombstones().size(); StringBuilder response = new StringBuilder("getdevinfo_resp") .append(""); for (DeviceState device : state.devices()) { for (ChannelState channel : device.channels()) { boolean available = device.online() && channel.online(); appendDiscoveredChannel(response, device.deviceId(), device.ip(), channel.channelId(), available); } } List remaining = new ArrayList<>(); for (ChannelTombstone tombstone : state.tombstones()) { appendDiscoveredChannel(response, tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), false); if (tombstone.remainingDiscoveries() > 1) remaining.add(new ChannelTombstone(tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), tombstone.remainingDiscoveries() - 1)); } if (!remaining.equals(state.tombstones())) persist(new SimulatorState(state.version(), state.devices(), state.collectors(), state.nextDeviceId(), state.nextCollectorId(), remaining)); return response.append("").toString(); } private void appendDiscoveredChannel(StringBuilder response, int deviceId, String ip, int channelId, boolean available) { // 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。 DeviceState device = state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst().orElse(null); BtsDeviceProfile profile = device == null ? BtsDeviceProfile.virtual(channelId) : profileFor(device); response.append("") .append(available).append(""); } private synchronized String observationResponse(String request) { long tick = ++observationSequence; java.util.Set requested = requestedChannels(request); List available = new ArrayList<>(); for (DeviceState device : state.devices()) for (ChannelState channel : device.channels()) if (device.online() && channel.online() && (requested.isEmpty() || requested.contains(device.deviceId() + ":" + channel.channelId()))) available.add(new ObservedChannel(device.deviceId(), channel)); StringBuilder response = new StringBuilder("inquire_resp"); for (ObservedChannel observed : available) { ChannelState channel = observed.channel(); DeviceState device = requireDevice(observed.deviceId()); BtsDeviceProfile profile = profileFor(device); boolean working = channel.workStatus() == DeviceWorkStatus.WORKING; double wave = Math.sin((tick + channel.channelId()) * 0.7d); double current = working ? clamp(profile.currentMaxA().doubleValue() * (0.18d + wave * 0.02d), profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d; double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(), profile.voltageMaxV().doubleValue()); String key = historyKey(profile.devtype(), observed.deviceId(), profile.subDeviceId(), channel.channelId()); BtsPlaybackState playback = playbackState(key, Instant.now()); double capacity = working ? latestCapacity(key) : 0d; double energy = capacity * voltage; if (working) { playback = advanceBtsPlayback(key, profile, playback, current, voltage); BtsHistoryRecord latest = latestRecord(key); if (latest != null) { capacity = latest.capacityAh(); energy = latest.energyWh(); } } int cycleNo = playback.cycleNo(); int stepNo = playback.stepNo(); String stepType = playback.stepType(); response.append(""); } return response.append("").toString(); } private synchronized String historyAvailabilityResponse(String request) { java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("]*)/?>").matcher(request); StringBuilder response = new StringBuilder("inquiredf_resp"); while (matcher.find()) { String attrs = matcher.group(1); int devtype = Integer.parseInt(attribute(attrs, "devtype")); int devid = Integer.parseInt(attribute(attrs, "devid")); int subdevid = Integer.parseInt(attribute(attrs, "subdevid")); int chlid = Integer.parseInt(attribute(attrs, "chlid")); int count = btsHistoryFor(historyKey(devtype, devid, subdevid, chlid)).size(); response.append("true"); } return response.append("").toString(); } private synchronized String historyResponse(String request) { int devtype = Integer.parseInt(attribute(request, "devtype")); int devid = Integer.parseInt(attribute(request, "devid")); int subdevid = Integer.parseInt(attribute(request, "subdevid")); int chlid = Integer.parseInt(attribute(request, "chlid")); String testId = attribute(request, "testid"); long start = Long.parseLong(attribute(request, "startpos")); int count = Integer.parseInt(attribute(request, "count")); List history = btsHistoryFor(historyKey(devtype, devid, subdevid, chlid)); int from = (int) Math.min(Math.max(0L, start - 1L), history.size()); int to = Math.min(history.size(), from + Math.max(1, count)); StringBuilder response = new StringBuilder("download_resp"); for (BtsHistoryRecord item : history.subList(from, to)) response.append(""); return response.append("").toString(); } private String attribute(String xml, String name) { java.util.regex.Matcher matcher = java.util.regex.Pattern.compile(name + "=\\\"([^\\\"]+)\\\"").matcher(xml); if (!matcher.find()) throw new IllegalArgumentException("Missing simulator attribute: " + name); return matcher.group(1); } private String historyKey(int devtype, int devid, int subdevid, int chlid) { return devtype + ":" + devid + ":" + subdevid + ":" + chlid; } private void restoreBtsHistory() { for (DeviceState device : state.devices()) { BtsDeviceProfile profile = profileFor(device); for (ChannelState channel : device.channels()) { String key = historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channel.channelId()); ArrayList restored = new ArrayList<>(); long offset = 0; while (true) { var page = stateStore.loadBtsHistoryPage(key, offset, BtsSimulatorStateStore.MAX_HISTORY_BATCH_SIZE); restored.addAll(page.records().stream().map(this::normalizeHistoryRecord).toList()); if (!page.hasMore()) break; if (page.nextOffset() <= offset) throw new IllegalStateException("Invalid BTS history page"); offset = page.nextOffset(); } if (!restored.isEmpty()) btsHistory.put(key, restored); } } } private List btsHistoryFor(String key) { return btsHistory.getOrDefault(key, List.of()); } private BtsHistoryRecord normalizeHistoryRecord(BtsHistoryRecord item) { return item.stepName() == null || item.stepName().isBlank() ? new BtsHistoryRecord(item.sequenceId(), item.deviceTime(), item.currentA(), item.voltageV(), item.capacityAh(), item.energyWh()) : item; } private String btsKey(DeviceState device, int channelId) { BtsDeviceProfile profile = profileFor(device); return historyKey(profile.devtype(), device.deviceId(), profile.subDeviceId(), channelId); } private void requireChannel(DeviceState device, int channelId) { if (channelId < 1 || channelId > device.channels().size()) throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size()); } private BtsPlaybackState playbackState(String key, Instant now) { BtsPlaybackState stored = stateStore.loadBtsPlaybackState(key); if (stored != null) return stored; List records = btsHistoryFor(key); long next = records.stream().mapToLong(BtsHistoryRecord::sequenceId).max().orElse(0L) + 1L; BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1); BtsPlaybackState created = new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(), now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, next, false); stateStore.saveBtsPlaybackState(key, created); return created; } private BtsHistoryRecord latestRecord(String key) { List records = btsHistoryFor(key); return records.isEmpty() ? null : records.get(records.size() - 1); } private double latestCapacity(String key) { BtsHistoryRecord latest = latestRecord(key); return latest == null ? 0d : latest.capacityAh(); } private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current, double currentA, double voltageV) { return advanceBtsPlayback(key, profile, current, currentA, voltageV, Instant.now()); } private BtsPlaybackState advanceBtsPlayback(String key, BtsDeviceProfile profile, BtsPlaybackState current, double currentA, double voltageV, Instant now) { if (!current.playing() || current.naturallyFinished()) return current; long elapsedMillis = Math.max(0L, java.time.Duration.between(current.wallClockAt(), now).toMillis()) * current.speedMultiplier(); List history = new ArrayList<>(btsHistoryFor(key)); List appended = new ArrayList<>(); Instant simulated = current.simulatedAt(); long sequence = current.nextSequence(); if (sequence < 1) sequence = 1; if (sequence > BtsExcelFixture.recordCount() + 1L) sequence = BtsExcelFixture.recordCount() + 1L; long lastOffset = sequence == 1 ? -SIMULATED_RECORD_INTERVAL_SECONDS : BtsExcelFixture.record(sequence - 1).offsetSeconds(); long budgetMillis = elapsedMillis; while (sequence <= BtsExcelFixture.recordCount()) { BtsExcelFixture.FixtureRecord fixture = BtsExcelFixture.record(sequence); long deltaSeconds = fixture.offsetSeconds() - lastOffset; long deltaMillis = Math.max(1L, deltaSeconds * 1000L); boolean firstRecord = history.isEmpty() && sequence == 1L; if (!firstRecord && budgetMillis < deltaMillis) break; budgetMillis = Math.max(0L, budgetMillis - deltaMillis); simulated = simulated.plusSeconds(deltaSeconds); BtsHistoryRecord record = new BtsHistoryRecord(fixture.sequenceId(), simulated, fixture.currentA(), fixture.voltageV(), fixture.capacityAh(), fixture.energyWh(), fixture.cycleNo(), fixture.stepNo(), fixture.stepName(), fixture.stepType()); history.add(record); appended.add(record); lastOffset = fixture.offsetSeconds(); sequence++; } if (appended.isEmpty()) return current; if (history.size() > BtsSimulatorStateStore.MAX_HISTORY_RECORDS) history = new ArrayList<>(history.subList(history.size() - (int) BtsSimulatorStateStore.MAX_HISTORY_RECORDS, history.size())); BtsExcelFixture.FixtureRecord latest = BtsExcelFixture.record(sequence - 1L); boolean finished = sequence > BtsExcelFixture.recordCount(); BtsPlaybackState next = new BtsPlaybackState(!finished && current.playing(), current.speedMultiplier(), latest.cycleNo(), latest.stepNo(), latest.stepName(), latest.stepType(), simulated, now, sequence, finished); stateStore.saveBtsPlaybackAndAppendHistory(key, next, appended); btsHistory.put(key, history); return next; } private BtsPlaybackState defaultPlayback(Instant now) { BtsExcelFixture.FixtureRecord first = BtsExcelFixture.record(1); return new BtsPlaybackState(true, 1, first.cycleNo(), first.stepNo(), first.stepName(), first.stepType(), now.minusSeconds(SIMULATED_RECORD_INTERVAL_SECONDS), now, 1, false); } private BtsExcelFixture.FixtureRecord nextFixtureStep(int cycleNo, int stepNo) { return BtsExcelFixture.records().stream() .filter(item -> item.cycleNo() > cycleNo || (item.cycleNo() == cycleNo && item.stepNo() > stepNo)) .findFirst().orElse(null); } private void savePlayback(String key, BtsPlaybackState playback) { stateStore.saveBtsPlaybackState(key, playback); } public record BtsStepRecord(int cycleNo, int stepNo, String stepName, String stepType, long recordCount, Instant startedAt, Instant endedAt, double durationSeconds, double capacityAh, double energyWh, double startVoltageV, double endVoltageV, double chargePeakVoltageV, double dischargeValleyVoltageV) { } public record BtsCycleRecord(String recordNo, int cycleNo, long recordCount, Instant startedAt, Instant endedAt, double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, double dischargeEnergyWh, double efficiency) { } private static double efficiency(double chargeCapacityAh, double dischargeCapacityAh) { return chargeCapacityAh > 0d ? dischargeCapacityAh / chargeCapacityAh * 100d : 0d; } public record PlaybackSnapshot(int deviceId, int channelId, BtsPlaybackState playback, List records, List steps, List cycles) { } private static final class BtsStepAccumulator { private final int cycle; private final int step; private final String name; private final String type; private long count; private Instant first; private Instant last; private double duration; private double capacity; private double energy; private double startVoltage; private double endVoltage; private double chargePeak = Double.NEGATIVE_INFINITY; private double dischargeValley = Double.POSITIVE_INFINITY; BtsStepAccumulator(BtsHistoryRecord first) { cycle = first.cycleNo(); step = first.stepNo(); name = first.stepName(); type = first.stepType(); } void add(BtsHistoryRecord item) { count++; if (first == null) { first = item.deviceTime(); startVoltage = item.voltageV(); } last = item.deviceTime(); endVoltage = item.voltageV(); duration = first == null ? 0 : Math.max(0, java.time.Duration.between(first, last).toMillis() / 1000d); capacity = item.capacityAh(); energy = item.energyWh(); if (item.currentA() >= 0) chargePeak = Math.max(chargePeak, item.voltageV()); else dischargeValley = Math.min(dischargeValley, item.voltageV()); } BtsStepRecord finish() { return new BtsStepRecord(cycle, step, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley); } } private static final class BtsCycleAccumulator { private final int cycle; private long count; private Instant first; private Instant last; private double maximumCapacity = Double.NEGATIVE_INFINITY; private double minimumCapacity = Double.POSITIVE_INFINITY; private double chargeEnergy; private double dischargeEnergy; BtsCycleAccumulator(BtsHistoryRecord first) { cycle = first.cycleNo(); } void add(BtsHistoryRecord item) { count++; if (first == null) first = item.deviceTime(); last = item.deviceTime(); maximumCapacity = Math.max(maximumCapacity, item.capacityAh()); minimumCapacity = Math.min(minimumCapacity, item.capacityAh()); if (item.currentA() >= 0) chargeEnergy = Math.max(chargeEnergy, item.energyWh()); else dischargeEnergy = Math.max(dischargeEnergy, Math.abs(item.energyWh())); } BtsCycleRecord finish() { double charge = Math.max(0d, maximumCapacity); double discharge = Math.max(0d, maximumCapacity - minimumCapacity); return new BtsCycleRecord(cycle, count, first, last, charge, discharge, chargeEnergy, dischargeEnergy, efficiency(charge, discharge)); } } private java.util.Set requestedChannels(String request) { java.util.Set channels = new java.util.HashSet<>(); try { XMLInputFactory factory = XMLInputFactory.newFactory(); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request)); while (reader.hasNext()) { if (reader.next() == XMLStreamConstants.START_ELEMENT && "inquire".equals(reader.getLocalName())) { String devid = reader.getAttributeValue(null, "devid"); String chlid = reader.getAttributeValue(null, "chlid"); if (devid != null && chlid != null) channels.add(devid + ":" + chlid); } } return channels; } catch (Exception exception) { throw new IllegalArgumentException("Invalid BTS inquire XML", exception); } } private BtsDeviceProfile profileFor(DeviceState device) { return device.profile() == null ? BtsDeviceProfile.virtual(device.channels().size()) : device.profile(); } private BtsDeviceProfile resizeProfile(BtsDeviceProfile profile, int channelCount) { return new BtsDeviceProfile(profile.devtype(), profile.subDeviceId(), profile.name(), profile.manufacturer(), profile.model(), profile.serialNumber(), profile.firmwareVersion(), channelCount, profile.currentMinA(), profile.currentMaxA(), profile.voltageMinV(), profile.voltageMaxV(), profile.sampleIntervalMs()); } private void updateDeviceWithProfile(DeviceState current, BtsDeviceProfile profile) { int channelCount = profile.channelCount(); List channels = new ArrayList<>(channelCount); for (int channelId = 1; channelId <= channelCount; channelId++) channels.add(channelId <= current.channels().size() ? current.channels().get(channelId - 1) : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); List devices = replaceDevice(new DeviceState(current.deviceId(), current.ip(), current.online(), channels, profile)); List tombstones = new ArrayList<>(state.tombstones()); for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++) upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId); tombstones.removeIf(item -> item.deviceId() == current.deviceId() && item.channelId() <= channelCount); persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(), state.nextCollectorId(), tombstones)); } private BtsDeviceProfile newareProfile(String model) { if (NEWARE_CT_4008T_5V6A_S1_F.equals(model)) return new BtsDeviceProfile(22, 1, "新威 CT-4008T", "新威", model, "SIM-CT4008T-5V6A", "BTS7.5.4", 8, new BigDecimal("-6.000"), new BigDecimal("6.000"), BigDecimal.ZERO, new BigDecimal("5.000"), 100); if (NEWARE_CT_4008TN_5V12A_S1_F.equals(model)) return new BtsDeviceProfile(22, 1, "新威 CT-4008Tn", "新威", model, "SIM-CT4008TN-5V12A", "BTS8.0", 8, new BigDecimal("-12.000"), new BigDecimal("12.000"), BigDecimal.ZERO, new BigDecimal("5.000"), 100); throw new IllegalArgumentException("Unsupported Neware simulator model: " + model); } private double clamp(double value, double minimum, double maximum) { return Math.max(minimum, Math.min(maximum, value)); } private String decimal(double value) { return String.format(java.util.Locale.ROOT, "%.3f", value); } private String toBtsWorkStatus(DeviceWorkStatus status) { return switch (status) { case STOPPED -> "stop"; case WORKING -> "working"; case PAUSED -> "pause"; case FINISHED -> "finish"; default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status); }; } private void validateDeviceConfiguration(String ip, int channelCount, Integer currentDeviceId) { if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator device IP must be a valid IPv4 address"); if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT) throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT); boolean duplicate = state.devices().stream() .anyMatch(device -> device.ip().equals(ip.trim()) && device.deviceId() != (currentDeviceId == null ? -1 : currentDeviceId)); duplicate = duplicate || state.collectors().stream().anyMatch(collector -> collector.ip().equals(ip.trim())); if (duplicate) throw new IllegalArgumentException("Simulator device IP must be unique"); } private void validateCollectorConfiguration(String ip, int channelCount, Integer currentCollectorId) { if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator collector IP must be a valid IPv4 address"); if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT) throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT); boolean duplicate = state.collectors().stream() .anyMatch(collector -> collector.ip().equals(ip.trim()) && collector.collectorId() != (currentCollectorId == null ? -1 : currentCollectorId)); duplicate = duplicate || state.devices().stream().anyMatch(device -> device.ip().equals(ip.trim())); if (duplicate) throw new IllegalArgumentException("Simulator collector IP must be unique"); } private boolean isIpv4(String ip) { if (ip == null) return false; String[] parts = ip.trim().split("\\.", -1); if (parts.length != 4) return false; for (String part : parts) { try { int value = Integer.parseInt(part); if (part.isEmpty() || value < 0 || value > 255) return false; } catch (NumberFormatException exception) { return false; } } return true; } private DeviceState requireDevice(int deviceId) { return state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown simulator device: " + deviceId)); } private CollectorState requireCollector(int collectorId) { return state.collectors().stream().filter(item -> item.collectorId() == collectorId).findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown simulator collector: " + collectorId)); } private List replaceDevice(DeviceState replacement) { List devices = new ArrayList<>(state.devices()); devices.replaceAll(item -> item.deviceId() == replacement.deviceId() ? replacement : item); return devices; } private void upsertTombstone(List tombstones, int deviceId, String ip, int channelId) { tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() == channelId); tombstones.add(new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT)); } private void persist(SimulatorState nextState) { // 写穿失败时保留当前内存副本,避免控制页显示未持久化状态。 stateStore.save(nextState); state = nextState; } private void persist(SimulatorState nextState, List historyAppends) { // 主状态与原始历史批次必须一起成功,内存序号才能向前推进。 stateStore.save(nextState, historyAppends); state = nextState; } private String simulatorMac(int devtype, int devid) { return String.format("02:44:%02X:%02X:%02X:%02X", (devtype >>> 8) & 0xFF, devtype & 0xFF, (devid >>> 8) & 0xFF, devid & 0xFF); } private String simulatorCollectorMac(int collectorId) { return String.format("02:43:00:00:%02X:%02X", (collectorId >>> 8) & 0xFF, collectorId & 0xFF); } private void requireControllableStatus(DeviceWorkStatus status) { if (status == null || (status != DeviceWorkStatus.STOPPED && status != DeviceWorkStatus.WORKING && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED)) throw new IllegalArgumentException("Simulator work status must be STOPPED, WORKING, PAUSED or FINISHED"); } private void validateRestoredState() { java.util.Set deviceIds = new java.util.HashSet<>(); java.util.Set collectorIds = new java.util.HashSet<>(); java.util.Set ips = new java.util.HashSet<>(); int maximumDeviceId = 0; for (DeviceState device : state.devices()) { if (device.deviceId() < 1 || !deviceIds.add(device.deviceId()) || !isIpv4(device.ip()) || !ips.add(device.ip()) || device.channels().isEmpty() || device.channels().size() > MAX_CHANNEL_COUNT) throw new IllegalStateException("Invalid BTS simulator device state in Redis"); if (profileFor(device).channelCount() != device.channels().size()) throw new IllegalStateException("BTS simulator device profile channel count does not match topology"); maximumDeviceId = Math.max(maximumDeviceId, device.deviceId()); for (int index = 0; index < device.channels().size(); index++) { ChannelState channel = device.channels().get(index); if (channel.channelId() != index + 1) throw new IllegalStateException("Invalid BTS simulator channel state in Redis"); requireControllableStatus(channel.workStatus()); } } int maximumCollectorId = 0; for (CollectorState collector : state.collectors()) { if (collector.collectorId() < 1 || !collectorIds.add(collector.collectorId()) || !isIpv4(collector.ip()) || !ips.add(collector.ip()) || collector.channelCount() < 1 || collector.channelCount() > MAX_CHANNEL_COUNT) throw new IllegalStateException("Invalid BTS simulator collector state in Redis"); DevelopmentCollectorSimulation.validate(collector, stateStore); maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId()); } java.util.Set tombstoneIds = new java.util.HashSet<>(); boolean invalidTombstone = state.tombstones().stream().anyMatch(item -> item.deviceId() < 1 || !isIpv4(item.ip()) || item.channelId() < 1 || item.channelId() > MAX_CHANNEL_COUNT || item.remainingDiscoveries() < 1 || item.remainingDiscoveries() > TOMBSTONE_DISCOVERY_COUNT || !tombstoneIds.add(item.deviceId() + ":" + item.channelId())); if (state.nextDeviceId() <= maximumDeviceId || state.nextCollectorId() <= maximumCollectorId || invalidTombstone) throw new IllegalStateException("Invalid BTS simulator numbering or tombstone state in Redis"); } @Override public synchronized void close() throws Exception { running = false; simulator.close(); } private record ObservedChannel(int deviceId, ChannelState channel) { } public record StateSnapshot(boolean running, String listenAddress, List devices, List collectors, List recentRequests) { } public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile, List channels) { } public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { } public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name, String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs, List channels, CollectorHistoryFaults historyFaults, long historyRecordCount, List latestMeasurements) { public CollectorSnapshot { channels = List.copyOf(channels); latestMeasurements = List.copyOf(latestMeasurements); } } }