yeg
2026-09-05 bd50f1b756c1da2dbf92f6541407ad40f455e24a
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
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
package com.battery.device.simulator;
 
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.battery.device.model.DeviceWorkStatus;
import com.battery.device.model.DeviceConnectionStatus;
import com.battery.device.model.DeviceIdentity;
import com.battery.device.model.DeviceObservation;
import com.battery.device.model.DetectedChannel;
import com.battery.device.model.DetectedDeviceMetadata;
import com.battery.device.model.ChannelIdentity;
import com.battery.device.model.AdapterCapabilities;
import com.battery.device.api.DeviceObservationAdapter;
import com.battery.device.simulator.BtsSimulatorStateStore;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState;
import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest;
import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.battery.device.simulator.InMemoryBtsSimulatorStateStore;
import com.battery.device.simulator.DevelopmentCollectorSimulation;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorAdvance;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
 
/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
public final class BtsDevelopmentSimulator implements DeviceObservationAdapter
{
    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<String, java.util.List<BtsHistoryRecord>> 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"));
 
    @Override
    public String adapterCode()
    {
        return ADAPTER_CODE;
    }
 
    @Override
    public AdapterCapabilities capabilities()
    {
        return AdapterCapabilities.observeOnly();
    }
 
    /** 直接提供模拟器当前拓扑,供不需要 TCP/XML 的契约测试使用。 */
    @Override
    public synchronized List<DetectedChannel> discoverChannels()
    {
        List<DetectedChannel> result = new ArrayList<>();
        for (DeviceState device : state.devices())
        {
            BtsDeviceProfile profile = profileFor(device);
            for (ChannelState channel : device.channels())
            {
                ChannelIdentity identity = new ChannelIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(),
                        profile.subDeviceId(), channel.channelId());
                result.add(new DetectedChannel(identity, device.ip(), device.online() && channel.online(),
                        new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(),
                                simulatorMac(profile.devtype(), device.deviceId()))));
            }
        }
        return List.copyOf(result);
    }
 
    /** 直接提供模拟器观测;离线通道不保留上一次测量或工步事实。 */
    @Override
    public synchronized java.util.Map<ChannelIdentity, DeviceObservation> observe(
            java.util.Set<ChannelIdentity> channels)
    {
        if (channels == null || channels.isEmpty()) return java.util.Map.of();
        java.util.Map<ChannelIdentity, DeviceObservation> result = new java.util.LinkedHashMap<>();
        for (ChannelIdentity requested : channels)
        {
            DeviceState device = state.devices().stream().filter(item -> item.deviceId() == requested.devid())
                    .findFirst().orElse(null);
            ChannelState channel = device == null ? null : device.channels().stream()
                    .filter(item -> item.channelId() == requested.chlid()).findFirst().orElse(null);
            if (device == null || channel == null || !ADAPTER_CODE.equals(requested.adapterCode())
                    || !device.online() || !channel.online())
            {
                result.put(requested, DeviceObservation.offline(requested, "OFFLINE"));
                continue;
            }
            BtsDeviceProfile profile = profileFor(device);
            if (requested.devtype() != profile.devtype() || requested.subdevid() != profile.subDeviceId())
            {
                result.put(requested, DeviceObservation.offline(requested, "OFFLINE"));
                continue;
            }
            result.put(requested, new DeviceObservation(
                    new DeviceIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(), profile.subDeviceId()),
                    requested, DeviceConnectionStatus.ONLINE, channel.workStatus(), List.of(), null,
                    java.util.Map.of(), null));
        }
        return java.util.Map.copyOf(result);
    }
 
    /** 启动仅监听 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<DeviceState> 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<ChannelState> 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<DeviceState> devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels,
                resizeProfile(current.profile(), channelCount)));
        List<ChannelTombstone> 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<DeviceState> 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<DeviceState> devices = new ArrayList<>(state.devices());
        devices.removeIf(item -> item.deviceId() == deviceId);
        List<ChannelTombstone> 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<ChannelState> 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<CollectorState> 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<CollectorState> 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<CollectorState> 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<CollectorMetricState> metrics)
    {
        CollectorState current = requireCollector(collectorId);
        replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
    }
 
    /**
     * 模拟现场人工统一开关机:关机停止采样,开机重启检测柜夹具生命周期并保持通道待机。
     * 采集器历史继续保留,检测柜夹具历史在关机后的开机上升沿清理。
     * 该操作只改变模拟设备事实,不代表系统具备真实设备控制能力。
     */
    public synchronized void setAllDevicesPoweredOn(boolean poweredOn)
    {
        boolean wasPoweredOff = (!state.devices().isEmpty() || !state.collectors().isEmpty())
                && state.devices().stream().noneMatch(DeviceState::online)
                && state.collectors().stream().noneMatch(CollectorState::online);
        Instant changedAt = Instant.now();
        if (poweredOn && wasPoweredOff)
        {
            // 一键开机代表新一轮演示;清理检测柜夹具,避免序号从 1 重放并与旧生命周期冲突。
            for (DeviceState device : state.devices())
                for (ChannelState channel : device.channels())
                    resetBtsFixtureLifecycle(btsKey(device, channel.channelId()), changedAt);
        }
        List<DeviceState> 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();
        List<CollectorState> 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()));
    }
 
    /** 清理检测柜夹具的历史和播放指针;采集器历史由总电源开关独立保留。 */
    private void resetBtsFixtureLifecycle(String key, Instant now)
    {
        btsHistory.remove(key);
        stateStore.deleteBtsHistory(key);
        stateStore.deleteBtsPlaybackState(key);
        savePlayback(key, defaultPlayback(now));
    }
 
    /** 按每台采集器自己的频率推进实时和历史采样。 */
    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<CollectorState> collectors = new ArrayList<>(state.collectors().size());
        List<CollectorHistoryAppend> 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);
        resetBtsFixtureLifecycle(key, 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<BtsHistoryRecord> btsRecords(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        return List.copyOf(btsHistoryFor(btsKey(device, channelId)));
    }
 
    /** 返回开发模拟器当前通道的持久化槽位间隔;仅供开发调度层使用。 */
    public synchronized long persistenceIntervalMillis(int deviceId, int channelId)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
        return Math.max(100L, 60000L / Math.max(1, playback.speedMultiplier()));
    }
 
    /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */
    public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId)
    {
        java.util.Map<String, BtsStepAccumulator> grouped = new java.util.LinkedHashMap<>();
        for (BtsHistoryRecord item : btsRecords(deviceId, channelId))
        {
            String key = item.cycleNo() + ":" + item.stepNo();
            int position = BtsExcelFixture.records().stream()
                    .filter(record -> record.cycleNo() == item.cycleNo())
                    .map(BtsExcelFixture.FixtureRecord::stepNo).distinct().toList().indexOf(item.stepNo()) + 1;
            grouped.computeIfAbsent(key, ignored -> new BtsStepAccumulator(item, position)).add(item);
        }
        return grouped.values().stream().map(BtsStepAccumulator::finish).toList();
    }
 
    /** 按实际出现的循环汇总 record,不增加测试结束统计行。 */
    public synchronized List<BtsCycleRecord> btsCycles(int deviceId, int channelId)
    {
        java.util.Map<Integer, BtsCycleAccumulator> 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();
    }
 
    /** 返回检测柜测试方案事实;方案行来自 Excel 夹具,当前状态来自该通道播放状态。 */
    public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId)
    { return btsTest(deviceId, channelId, null); }
 
    /** 返回指定循环的检测柜测试方案;未开始接收的测试固定使用第一个循环作为核对上下文。 */
    public synchronized BtsTestSnapshot btsTest(int deviceId, int channelId, Integer forcedCycleNo)
    {
        DeviceState device = requireDevice(deviceId);
        requireChannel(device, channelId);
        BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
        int requestedCycleNo = forcedCycleNo == null ? playback.cycleNo() : forcedCycleNo;
        int currentCycleNo = requestedCycleNo;
        List<BtsExcelFixture.FixtureRecord> currentCycleRecords = BtsExcelFixture.records().stream()
                .filter(item -> item.cycleNo() == requestedCycleNo).toList();
        if (currentCycleRecords.isEmpty())
        {
            int fallbackCycleNo = BtsExcelFixture.records().get(0).cycleNo();
            currentCycleNo = fallbackCycleNo;
            currentCycleRecords = BtsExcelFixture.records().stream()
                    .filter(item -> item.cycleNo() == fallbackCycleNo).toList();
        }
        java.util.Map<Integer, BtsExcelFixture.FixtureRecord> firstByStep = new java.util.LinkedHashMap<>();
        for (BtsExcelFixture.FixtureRecord item : currentCycleRecords)
            firstByStep.putIfAbsent(item.stepNo(), item);
 
        List<BtsTestPlanRow> rows = new ArrayList<>();
        int cyclePosition = 1;
        for (BtsExcelFixture.FixtureRecord first : firstByStep.values())
        {
            List<BtsExcelFixture.FixtureRecord> sameCycle = currentCycleRecords.stream()
                    .filter(item -> item.stepNo() == first.stepNo()).toList();
            BtsExcelFixture.FixtureRecord last = sameCycle.get(sameCycle.size() - 1);
            // step 号在每个循环内重复,跨循环时必须按当前循环的工步位置判断,不能把上一循环的同号工步当成当前状态。
            String status = forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成"
                    : playback.stepNo() > first.stepNo() ? "已完成"
                            : playback.stepNo() == first.stepNo() ? "进行中" : "待执行";
            rows.add(new BtsTestPlanRow("STEP", first.stepNo(), cyclePosition++, first.stepName(),
                    Math.max(0d, last.offsetSeconds() - first.offsetSeconds()), first.voltageV(), status,
                    null, null));
        }
        rows.add(new BtsTestPlanRow("CYCLE", BtsExcelFixture.startStepNo(), null, "循环", 0d, null,
                forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "进行中", currentCycleNo,
                (long) currentCycleRecords.size()));
        rows.add(new BtsTestPlanRow("END", 0, null, "结束", 0d, null,
                forcedCycleNo != null ? "待执行" : playback.naturallyFinished() ? "已完成" : "待执行", null, (long) BtsExcelFixture.recordCount()));
        return new BtsTestSnapshot(currentCycleNo, playback.stepNo(), BtsExcelFixture.cycleCount(), rows);
    }
 
    /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */
    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<DeviceSnapshot> 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<CollectorSnapshot> 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<ChannelState> 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<CollectorState> 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 -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
        };
    }
 
    private synchronized void recordRequest(String command, String request)
    {
        List<RecentRequest> 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("<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>")
                .append("<serverip count=\"0\"/><middle count=\"").append(channelCount).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<ChannelTombstone> 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("</middle></bts>").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("<channel ip=\"").append(ip).append("\" devtype=\"").append(profile.devtype())
                .append("\" devid=\"").append(deviceId).append("\" subdevid=\"").append(profile.subDeviceId())
                .append("\" Channelid=\"").append(channelId).append("\" name=\"").append(profile.name())
                .append("\" manufacturer=\"").append(profile.manufacturer()).append("\" model=\"")
                .append(profile.model()).append("\" serialNumber=\"").append(profile.serialNumber())
                .append("\" firmwareVersion=\"").append(profile.firmwareVersion()).append("\" mac=\"")
                .append(simulatorMac(profile.devtype(), deviceId)).append("\">")
                .append(available).append("</channel>");
    }
 
    private synchronized String observationResponse(String request)
    {
        long tick = ++observationSequence;
        java.util.Set<String> requested = requestedChannels(request);
        List<ObservedChannel> 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("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"")
                .append(available.size()).append("\">");
        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("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId())
                    .append('-').append(profile.subDeviceId()).append('-').append(channel.channelId())
                    .append("-0\" cycle_id=\"").append(cycleNo).append("\" step_id=\"").append(stepNo)
                    .append("\" step_type=\"").append(stepType).append("\" workstatus=\"")
                    .append(toBtsWorkStatus(channel.workStatus()))
                    .append("\" current=\"").append(decimal(current)).append("\" voltage=\"")
                    .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity))
                    .append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"")
                    .append(working ? tick : 0).append("\" relativetime=\"").append(working ? tick : 0)
                    .append("\" open_or_close=\"0\" log_code=\"0\">")
                    // 模拟器返回当前设备方案,供统一观测模型展示;不代表真实 BTS 的完整方案字段。
                    .append("<plan><step step_no=\"").append(stepNo).append("\" step_type=\"")
                    .append(stepType).append("\" name=\"").append(stepType).append("\"/></plan></inquire>");
        }
        return response.append("</list></bts>").toString();
    }
 
    private synchronized String historyAvailabilityResponse(String request)
    {
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("<chl\\s+([^>]*)/?>").matcher(request);
        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquiredf_resp</cmd><list>");
        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("<chl devtype=\"").append(devtype).append("\" devid=\"").append(devid)
                    .append("\" subdevid=\"").append(subdevid).append("\" chlid=\"").append(chlid)
                    .append("\" testid=\"SIM-").append(devid).append('-').append(chlid).append("\" count=\"")
                    .append(count).append("\">true</chl>");
        }
        return response.append("</list></bts>").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<BtsHistoryRecord> 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("<bts version=\"1.0\"><cmd>download_resp</cmd><download devtype=\"")
                .append(devtype).append("\" devid=\"").append(devid).append("\" subdevid=\"").append(subdevid)
                .append("\" chlid=\"").append(chlid).append("\" testid=\"").append(testId)
                .append("\"><list>");
        for (BtsHistoryRecord item : history.subList(from, to))
            response.append("<data seqid=\"").append(item.sequenceId()).append("\" stepid=\"").append(item.stepNo())
                    .append("\" cycleid=\"").append(item.cycleNo()).append("\" steptype=\"").append(item.stepType()).append("\" atime=\"")
                    .append(BTS_HISTORY_TIME.format(item.deviceTime())).append("\" testtime=\"60000\" volt=\"")
                    .append(decimal(item.voltageV())).append("\" curr=\"").append(decimal(item.currentA()))
                    .append("\" cap=\"").append(decimal(item.capacityAh())).append("\" eng=\"").append(decimal(item.energyWh())).append("\"/>");
        return response.append("</list></download></bts>").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<BtsHistoryRecord> 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<BtsHistoryRecord> 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<BtsHistoryRecord> 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<BtsHistoryRecord> 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<BtsHistoryRecord> history = new ArrayList<>(btsHistoryFor(key));
        List<BtsHistoryRecord> 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, Integer cyclePosition, 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) { }
 
    public record BtsTestPlanRow(String rowType, int stepNo, Integer cyclePosition, String stepName, double durationSeconds,
            Double voltageV, String executionStatus, Integer cycleCount, Long recordCount) { }
 
    public record BtsTestSnapshot(int currentCycleNo, int currentStepNo, int plannedCycleCount,
            List<BtsTestPlanRow> rows) { }
 
    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<BtsHistoryRecord> records, List<BtsStepRecord> steps, List<BtsCycleRecord> cycles) { }
 
    private static final class BtsStepAccumulator
    {
        private final int cycle; private final int step; private final int position; 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, int position) { cycle = first.cycleNo(); step = first.stepNo(); this.position = position; 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, position, 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<String> requestedChannels(String request)
    {
        java.util.Set<String> 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<ChannelState> 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<DeviceState> devices = replaceDevice(new DeviceState(current.deviceId(), current.ip(), current.online(),
                channels, profile));
        List<ChannelTombstone> 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<DeviceState> replaceDevice(DeviceState replacement)
    {
        List<DeviceState> devices = new ArrayList<>(state.devices());
        devices.replaceAll(item -> item.deviceId() == replacement.deviceId() ? replacement : item);
        return devices;
    }
 
    private void upsertTombstone(List<ChannelTombstone> 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<CollectorHistoryAppend> 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<Integer> deviceIds = new java.util.HashSet<>();
        java.util.Set<Integer> collectorIds = new java.util.HashSet<>();
        java.util.Set<String> 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<String> 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()
    {
        running = false;
        try
        {
            simulator.close();
        }
        catch (Exception exception)
        {
            throw new IllegalStateException("关闭开发模拟器失败", exception);
        }
    }
 
    private record ObservedChannel(int deviceId, ChannelState channel) { }
 
    public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices,
            List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { }
 
    public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile,
            List<ChannelSnapshot> 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<CollectorChannelState> channels, CollectorHistoryFaults historyFaults, long historyRecordCount,
            List<CollectorRealtimeMeasurement> latestMeasurements)
    {
        public CollectorSnapshot
        {
            channels = List.copyOf(channels);
            latestMeasurements = List.copyOf(latestMeasurements);
        }
    }
 
}