yeg
2026-09-05 7259e265b9237286996b09da03038ea5a133e1e0
收敛模拟器为设备事实与实时观测

新增软件可访问性及采集器通道状态,按请求生成当前观测并保存连续序号。移除倍速、时间跳跃、生命周期播放、历史生成及对应接口。

修复暂停接收时仍请求采集器和发现失败后残留旧快照的问题。全量测试与干净打包通过,共234项测试。
3个文件已删除
17个文件已修改
3236 ■■■■■ 已修改文件
battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java 821 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsExcelFixture.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java 173 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/DevelopmentCollectorSimulation.java 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/InMemoryBtsSimulatorStateStore.java 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorSamplingScheduler.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizer.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorDiscoveryScheduler.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java 404 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java
@@ -68,6 +68,15 @@
    /** 发现失败或返回空拓扑时,清除该适配器下已登记设备的过期运行事实。 */
    public void markUnavailable(String adapterCode)
    {
        Instant failedAt = Instant.now();
        // 失败立即撤下旧测量,但保留来源身份,不能等候选 TTL 到期才停止展示成功值。
        candidateStore.recordCabinets(candidateStore.snapshot(failedAt).cabinets().stream()
                .filter(item -> item.adapterCode().equals(adapterCode))
                .map(item -> new CabinetCandidate(item.adapterCode(), item.adapterId(), item.deviceCode(),
                        item.devtype(), item.devid(), item.ip(), item.mac(), item.name(), item.manufacturer(), item.model(),
                        item.channels().stream().map(channel -> new ChannelCandidate(channel.identity(), false,
                                DeviceWorkStatus.UNOBSERVABLE, failedAt)).toList(), failedAt, item.expiresAt()))
                .toList());
        detectionCabinetMapper.markRegisteredFactsUnobservable(adapterCode);
        detectionCabinetMapper.markRegisteredCabinetsOffline(adapterCode);
    }
battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java
@@ -52,7 +52,7 @@
            if (battery.getBinding() != null)
                sources.add(snapshotFor(cabinetSource(battery, cabinets), receiving));
            if (battery.getCollectorId() != null)
                sources.add(snapshotFor(collectorSource(battery), receiving));
                sources.add(collectorSource(battery, receiving));
        }
        List<Map<String, Object>> exceptions = new ArrayList<>();
        if (receiving)
@@ -157,11 +157,20 @@
        return value;
    }
    private LiveSource collectorSource(TTestBattery battery)
    private LiveSource collectorSource(TTestBattery battery, boolean receiving)
    {
        if (adapter == null || mapper == null) return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
                "OFFLINE", Map.of());
        var collector = adapter.latestCollector(mapper.selectCollectorCode(battery.getCollectorId()));
        String collectorCode = mapper.selectCollectorCode(battery.getCollectorId());
        // 非接收状态只读设备发现事实,不能先请求测量再隐藏,否则仍会产生新观测。
        if (!receiving)
        {
            boolean online = candidates.findCollector(collectorCode, Instant.now())
                    .map(DeviceDiscoveryCandidateStore.CollectorCandidate::online).orElse(false);
            return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
                    online ? "ONLINE" : "OFFLINE", Map.of());
        }
        var collector = adapter.latestCollector(collectorCode);
        if (collector != null)
        {
            Map<String, Object> value = new LinkedHashMap<>();
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -25,22 +25,15 @@
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 模拟端点。 */
@@ -62,11 +55,8 @@
    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"));
    private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong");
    private static final DateTimeFormatter DEVICE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
    @Override
    public String adapterCode()
@@ -92,11 +82,12 @@
            {
                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(),
                result.add(new DetectedChannel(identity, device.ip(), state.softwareAccessible() && device.online() && channel.online(),
                        new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(),
                                simulatorMac(profile.devtype(), device.deviceId()))));
            }
        }
        recordRequest("discover", ADAPTER_CODE, result.toString());
        return List.copyOf(result);
    }
@@ -107,30 +98,25 @@
    {
        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));
        }
        for (ChannelIdentity requested : channels) result.put(requested, observation(requested));
        recordRequest("observe", channels.toString(), result.toString());
        return java.util.Map.copyOf(result);
    }
    private DeviceObservation observation(ChannelIdentity requested)
    {
        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 (!state.softwareAccessible() || device == null || channel == null
                || !ADAPTER_CODE.equals(requested.adapterCode()) || !device.online() || !channel.online()
                || requested.devtype() != profileFor(device).devtype()
                || requested.subdevid() != profileFor(device).subDeviceId())
            return DeviceObservation.offline(requested, failureReason());
        return new DeviceObservation(new DeviceIdentity(ADAPTER_CODE, requested.devtype(), requested.devid(),
                requested.subdevid()), requested, DeviceConnectionStatus.ONLINE, channel.workStatus(),
                List.of(), null, java.util.Map.of(), null);
    }
    /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */
@@ -144,11 +130,7 @@
    {
        this.stateStore = stateStore;
        state = stateStore.loadOrInitialize(defaultState());
        // 仅清理嵌在主状态中的旧演示历史;独立历史由模拟器存储保留。
        state = stateStore.resetHistoryForDemoStartup(state);
        restoreBtsHistory();
        validateRestoredState();
        stateStore.completeValidatedRestore(state);
        simulator = ScriptedBtsTcpSimulator.responding(port, this::respond);
    }
@@ -219,15 +201,9 @@
        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));
@@ -281,16 +257,7 @@
        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));
        persist(next);
    }
    /** 配置一个稳定 S 通道的固定或动态演示指标。 */
@@ -301,373 +268,135 @@
        replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
    }
    /**
     * 模拟现场人工统一开关机:关机停止采样,开机重启检测柜夹具生命周期并保持通道待机。
     * 采集器历史继续保留,检测柜夹具历史在关机后的开机上升沿清理。
     * 该操作只改变模拟设备事实,不代表系统具备真实设备控制能力。
     */
    /** 修改软件可访问性,不改变设备身份、在线配置或通道工作状态。 */
    public synchronized void setSoftwareAccessible(boolean accessible)
    {
        SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
                state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), accessible, state.observationSequence());
        stateStore.save(next);
        state = next;
    }
    /** 批量修改设备在线事实,保留各通道工作状态。 */
    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()))
                .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn, device.channels(), device.profile()))
                .toList();
        List<CollectorState> collectors = state.collectors().stream()
                .map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(),
                        collector.channelCount(), poweredOn, changedAt))
                .toList();
                        collector.channelCount(), poweredOn, Instant.now())).toList();
        persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones()));
    }
    /** 清理检测柜夹具的历史和播放指针;采集器历史由总电源开关独立保留。 */
    private void resetBtsFixtureLifecycle(String key, Instant now)
    /** 修改采集器单个通道工作状态;不代表真实采集器控制能力。 */
    public synchronized void updateCollectorChannelStatus(int collectorId, String channelCode, DeviceWorkStatus status)
    {
        btsHistory.remove(key);
        stateStore.deleteBtsHistory(key);
        stateStore.deleteBtsPlaybackState(key);
        savePlayback(key, defaultPlayback(now));
        requireControllableStatus(status);
        replaceCollector(DevelopmentCollectorSimulation.configureStatus(requireCollector(collectorId), channelCode, status));
    }
    /** 按每台采集器自己的频率推进实时和历史采样。 */
    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()));
    }
    /** 返回固定的开发计划,不推进工步或生成历史。 */
    @Override
    public synchronized TestPlan testPlan(ChannelIdentity channel)
    {
        if (!canObserve(channel)) return null;
        var plan = btsTest(channel.devid(), channel.chlid());
        return new TestPlan(plan.currentCycleNo(), plan.currentStepNo(), plan.plannedCycleCount(),
                plan.rows().stream().map(row -> new PlanRow(row.rowType(), row.stepNo(), row.cyclePosition(),
                        row.stepName(), row.durationSeconds(), row.voltageV(), row.executionStatus(),
                        row.cycleCount(), row.recordCount())).toList());
        return new TestPlan(1, 1, 1, List.of(new PlanRow("STEP", 1, 1, "恒流充电", 60d, 3.7d,
                requireDevice(channel.devid()).channels().get(channel.chlid() - 1).workStatus() == DeviceWorkStatus.FINISHED
                        ? "已完成" : "待执行", null, null)));
    }
    /** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */
    @Override
    public synchronized CabinetRealtime latestCabinet(ChannelIdentity channel)
    {
        if (!canObserve(channel)) return null;
        BtsHistoryRecord record = btsRecords(channel.devid(), channel.chlid()).stream()
                .max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).orElse(null);
        return record == null ? null : new CabinetRealtime(record.sequenceId(), record.deviceTime(), record.currentA(),
                record.voltageV(), record.capacityAh(), record.energyWh(), record.cycleNo(), record.stepNo(),
                record.stepName(), record.stepType(), record.serverReceivedAt(), record.deviceTime().toString(), "UTC");
        CabinetRealtime result = canObserve(channel) ? cabinetMeasurement(channel) : null;
        recordRequest("cabinet-realtime", String.valueOf(channel),
                result == null ? failureReason() : result.toString());
        return result;
    }
    private CabinetRealtime cabinetMeasurement(ChannelIdentity identity)
    {
        DeviceState device = requireDevice(identity.devid());
        ChannelState channel = device.channels().get(identity.chlid() - 1);
        if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) return null;
        Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
        long sequence = nextObservationSequence();
        double wave = Math.sin((sequence + channel.channelId()) * 0.7d);
        BtsDeviceProfile profile = profileFor(device);
        double current = channel.workStatus() == DeviceWorkStatus.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());
        CabinetRealtime result = new CabinetRealtime(sequence, now, current, voltage, 1.2d, 4.44d,
                1, 1, "恒流充电", "cc", now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId());
        return result;
    }
    /** 请求采集器当前配置值;软件不可访问或设备离线时返回空测量。 */
    @Override
    public synchronized CollectorRealtime latestCollector(String collectorCode)
    {
        var collector = snapshot().collectors().stream().filter(item -> item.deviceCode().equals(collectorCode))
        CollectorState collector = state.collectors().stream().filter(item -> item.deviceCode().equals(collectorCode))
                .findFirst().orElse(null);
        if (collector == null) return null;
        CollectorRealtimeSnapshot snapshot = collectorRealtime(collector.collectorId());
        var snapshot = collectorRealtime(collector.collectorId());
        return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(),
                snapshot.measurements().stream().map(item -> new CollectorMeasurement(item.channelCode(), item.sequenceId(),
                        item.deviceTime(), item.deviceTimeRaw(), item.serverReceivedAt(), item.metricCode(), item.value(),
                        item.unit())).toList(), "Asia/Hong_Kong");
                        item.unit())).toList(), DEVICE_ZONE.getId());
    }
    /** 模拟器不提供历史工步统计。 */
    @Override
    public synchronized List<StepRecord> stepRecords(ChannelIdentity channel)
    {
        if (!canObserve(channel)) return List.of();
        var playback = playbackState(btsKey(requireDevice(channel.devid()), channel.chlid()), Instant.now());
        // 仅发布已离开的工步;手动跳转也可结束工步,不用记录数作为完整性门禁。
        return btsSteps(channel.devid(), channel.chlid()).stream().filter(item -> playback.naturallyFinished()
                || item.cycleNo() != playback.cycleNo() || item.stepNo() != playback.stepNo()).map(item -> new StepRecord(item.recordNo(),
                item.cycleNo(), item.stepNo(), item.stepName(), item.stepType(), item.startedAt(), item.endedAt(),
                item.endVoltageV(), item.capacityAh(), item.energyWh(), item.endedAt().toString(), "UTC", receivedAt(channel, item.endedAt()),
                java.util.Map.of("stepDuration", java.math.BigDecimal.valueOf(item.durationSeconds()),
                        "startVoltage", java.math.BigDecimal.valueOf(item.startVoltageV()),
                        "chargePeakVoltage", java.math.BigDecimal.valueOf(item.chargePeakVoltageV()),
                        "dischargeValleyVoltage", java.math.BigDecimal.valueOf(item.dischargeValleyVoltageV())))).toList();
    }
    public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); }
    /** 模拟器不提供历史循环统计。 */
    @Override
    public synchronized List<CycleRecord> cycleRecords(ChannelIdentity channel)
    {
        if (!canObserve(channel)) return List.of();
        var playback = playbackState(btsKey(requireDevice(channel.devid()), channel.chlid()), Instant.now());
        return btsCycles(channel.devid(), channel.chlid()).stream().filter(item -> playback.naturallyFinished()
                || item.cycleNo() != playback.cycleNo()).map(item -> new CycleRecord(item.recordNo(),
                item.cycleNo(), item.startedAt(), item.endedAt(), item.chargeCapacityAh(), item.dischargeCapacityAh(),
                item.chargeEnergyWh(), item.dischargeEnergyWh(), item.efficiency(), item.endedAt().toString(), "UTC", receivedAt(channel, item.endedAt()))).toList();
    }
    public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); }
    /** 使用后端正常持久化周期,不提供开发倍速。 */
    @Override
    public synchronized long persistenceIntervalMillis(ChannelIdentity channel)
    { return canObserve(channel) ? persistenceIntervalMillis(channel.devid(), channel.chlid()) : 60000L; }
    public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; }
    private boolean canObserve(ChannelIdentity channel)
    {
        // 全身份与在线事实共同限定来源,避免同编号的其他适配器被写入模拟数据。
        return channel != null && observe(java.util.Set.of(channel)).get(channel).connectionStatus() == DeviceConnectionStatus.ONLINE;
        // 完整来源身份限定数据归属;不可访问和离线只改变观测结果。
        return channel != null && observation(channel).connectionStatus() == DeviceConnectionStatus.ONLINE
                && observation(channel).workStatus() != DeviceWorkStatus.UNOBSERVABLE;
    }
    private Instant receivedAt(ChannelIdentity channel, Instant endedAt)
    {
        return btsRecords(channel.devid(), channel.chlid()).stream().filter(row -> row.deviceTime().equals(endedAt))
                .map(BtsHistoryRecord::serverReceivedAt).filter(java.util.Objects::nonNull).max(Instant::compareTo).orElse(null);
    }
    /** 按实际出现的循环和工步汇总 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));
        CollectorState collector = requireCollector(collectorId);
        boolean available = state.softwareAccessible() && collector.online();
        if (available)
        {
            collector = DevelopmentCollectorSimulation.sample(collector, Instant.now(), nextObservationSequence());
            replaceCollector(collector);
        }
        var result = new CollectorRealtimeSnapshot(collector.deviceCode(), available, collector.sampleIntervalMs(),
                available ? collector.latestMeasurements() : List.of());
        recordRequest("collector-realtime", collector.deviceCode(), available ? result.toString() : failureReason());
        return result;
    }
    /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */
    public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit)
    private String failureReason()
    {
        CollectorState collector = requireCollector(collectorId);
        return DevelopmentCollectorSimulation.history(collector, stateStore, offset, limit);
        return state.softwareAccessible() ? "UNOBSERVABLE" : "SOFTWARE_UNAVAILABLE";
    }
    private long nextObservationSequence()
    {
        long sequence = Math.max(state.observationSequence() + 1, Instant.now().toEpochMilli());
        SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
                state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), state.softwareAccessible(), sequence);
        stateStore.save(next);
        state = next;
        return sequence;
    }
    /** 返回控制页需要的运行状态快照。 */
@@ -684,10 +413,9 @@
                .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()))
                        collector.channels(), state.softwareAccessible() && collector.online() ? collector.latestMeasurements() : List.of()))
                .toList();
        return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
        return new StateSnapshot(running, state.softwareAccessible(), HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
                stateStore.loadRecentRequests());
    }
@@ -696,7 +424,6 @@
    {
        SimulatorState defaultState = defaultState();
        stateStore.reset(defaultState);
        btsHistory.clear();
        state = defaultState;
    }
@@ -735,24 +462,23 @@
                state.nextCollectorId(), state.tombstones()));
    }
    private String respond(String request)
    private synchronized String respond(String request)
    {
        String command = readCommand(request);
        recordRequest(command, request);
        return switch (command)
        String response = state.softwareAccessible() ? 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>";
        };
        } : "<bts version=\"1.0\"><cmd>error</cmd></bts>";
        recordRequest(command, request, state.softwareAccessible() ? response : "SOFTWARE_UNAVAILABLE");
        return response;
    }
    private synchronized void recordRequest(String command, String request)
    private void recordRequest(String command, String request, String response)
    {
        List<RecentRequest> recentRequests = new ArrayList<>(stateStore.loadRecentRequests());
        recentRequests.add(0, new RecentRequest(Instant.now(), command, request));
        recentRequests.add(0, new RecentRequest(Instant.now(), command, request, response));
        if (recentRequests.size() > MAX_RECENT_REQUESTS)
            recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS));
        stateStore.saveRecentRequests(recentRequests);
@@ -825,13 +551,13 @@
    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())))
                if (device.online() && channel.online() && channel.workStatus() != DeviceWorkStatus.UNOBSERVABLE
                        && requested.contains(profileFor(device).devtype() + ":" + device.deviceId()
                                + ":" + profileFor(device).subDeviceId() + ":" + channel.channelId()))
                    available.add(new ObservedChannel(device.deviceId(), channel));
        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"")
@@ -841,29 +567,12 @@
            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();
            CabinetRealtime measurement = cabinetMeasurement(new ChannelIdentity(ADAPTER_CODE, profile.devtype(),
                    device.deviceId(), profile.subDeviceId(), channel.channelId()));
            double current = measurement.currentA(), voltage = measurement.voltageV();
            double capacity = measurement.capacityAh(), energy = measurement.energyWh();
            int cycleNo = measurement.cycleNo(), stepNo = measurement.stepNo();
            String stepType = measurement.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)
@@ -872,267 +581,13 @@
                    .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(0).append("\" relativetime=\"").append(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.cycleNo(), item.stepNo(), item.stepName(), item.stepType(),
                        item.serverReceivedAt()) : 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(), now);
            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,
            String recordNo)
    {
        public 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)
        {
            this(cycleNo, stepNo, cyclePosition, stepName, stepType, recordCount, startedAt, endedAt,
                    durationSeconds, capacityAh, energyWh, startVoltageV, endVoltageV, chargePeakVoltageV,
                    dischargeValleyVoltageV, null);
        }
    }
    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, "STEP-" + cycle + "-" + step + "-" + last.toEpochMilli()); }
    }
    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)
@@ -1150,7 +605,10 @@
                {
                    String devid = reader.getAttributeValue(null, "devid");
                    String chlid = reader.getAttributeValue(null, "chlid");
                    if (devid != null && chlid != null) channels.add(devid + ":" + chlid);
                    String devtype = reader.getAttributeValue(null, "devtype");
                    String subdevid = reader.getAttributeValue(null, "subdevid");
                    if (devtype != null && devid != null && subdevid != null && chlid != null)
                        channels.add(devtype + ":" + devid + ":" + subdevid + ":" + chlid);
                }
            }
            return channels;
@@ -1222,6 +680,7 @@
            case WORKING -> "working";
            case PAUSED -> "pause";
            case FINISHED -> "finish";
            case PROTECTED -> "protect";
            default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status);
        };
    }
@@ -1298,15 +757,11 @@
    private void persist(SimulatorState nextState)
    {
        // 写穿失败时保留当前内存副本,避免控制页显示未持久化状态。
        stateStore.save(nextState);
        state = nextState;
    }
    private void persist(SimulatorState nextState, List<CollectorHistoryAppend> historyAppends)
    {
        // 主状态与原始历史批次必须一起成功,内存序号才能向前推进。
        stateStore.save(nextState, historyAppends);
        state = nextState;
        SimulatorState configured = new SimulatorState(nextState.version(), nextState.devices(), nextState.collectors(),
                nextState.nextDeviceId(), nextState.nextCollectorId(), nextState.tombstones(),
                state.softwareAccessible(), state.observationSequence());
        stateStore.save(configured);
        state = configured;
    }
    private String simulatorMac(int devtype, int devid)
@@ -1323,8 +778,9 @@
    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");
                && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED
                && status != DeviceWorkStatus.PROTECTED && status != DeviceWorkStatus.UNOBSERVABLE))
            throw new IllegalArgumentException("Unsupported simulator work status");
    }
    private void validateRestoredState()
@@ -1357,7 +813,7 @@
                    || !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);
            DevelopmentCollectorSimulation.validate(collector);
            maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId());
        }
        java.util.Set<String> tombstoneIds = new java.util.HashSet<>();
@@ -1386,7 +842,7 @@
    private record ObservedChannel(int deviceId, ChannelState channel) { }
    public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices,
    public record StateSnapshot(boolean running, boolean softwareAccessible, String listenAddress, List<DeviceSnapshot> devices,
            List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { }
    public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile,
@@ -1396,8 +852,7 @@
    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)
            List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements)
    {
        public CollectorSnapshot
        {
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsExcelFixture.java
File was deleted
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
@@ -9,74 +9,11 @@
public interface BtsSimulatorStateStore
{
    int CURRENT_VERSION = 1;
    int MAX_HISTORY_BATCH_SIZE = 1000;
    /** 开发演示只保留有限历史,避免 Redis 随运行时间无限增长。 */
    long MAX_HISTORY_RECORDS = 20_000L;
    /** 读取已有状态;仅在状态不存在时原子写入并返回默认状态。 */
    /** 读取已有状态,仅在不存在时保存默认拓扑。 */
    SimulatorState loadOrInitialize(SimulatorState defaultState);
    /** 开发模拟器启动时清理主状态中的旧内嵌历史,保留独立存储的历史。 */
    default SimulatorState resetHistoryForDemoStartup(SimulatorState state) { return state; }
    /** 完整保存下一版模拟器状态。 */
    /** 保存设备配置和观测序号;返回前必须完成写入。 */
    void save(SimulatorState state);
    /** 状态和独立历史全部校验通过后,提交兼容旧格式所需的迁移。 */
    default void completeValidatedRestore(SimulatorState state) { }
    /** 原子保存主状态并追加本轮新产生的原始历史。 */
    default void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends)
    {
        if (!historyAppends.isEmpty())
            throw new UnsupportedOperationException("Collector history append is not supported");
        save(state);
    }
    /** 按原始写入顺序或反向顺序读取有界历史批次。 */
    default CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, int limit,
            boolean reverse)
    {
        if (offset < 0) throw new IllegalArgumentException("offset must not be negative");
        if (limit < 1 || limit > MAX_HISTORY_BATCH_SIZE)
            throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_BATCH_SIZE);
        return new CollectorHistoryBatch(offset, 0, false, 0, List.of());
    }
    /** 读取检测柜模拟历史;正式适配器不依赖该演示扩展。 */
    default BtsHistoryBatch loadBtsHistoryPage(String historyKey, long offset, int limit)
    {
        return new BtsHistoryBatch(offset, 0, false, 0, List.of());
    }
    /** 追加检测柜模拟历史,供重启续采合同使用。 */
    default void appendBtsHistory(String historyKey, BtsHistoryRecord record) { }
    /** 读取检测柜通道的播放状态;实现可在不支持播放持久化时返回 null。 */
    default BtsPlaybackState loadBtsPlaybackState(String historyKey) { return null; }
    /** 保存检测柜通道的播放状态。 */
    default void saveBtsPlaybackState(String historyKey, BtsPlaybackState state) { }
    /** 删除检测柜通道的播放状态。 */
    default void deleteBtsPlaybackState(String historyKey) { }
    /** 删除检测柜通道的持久化历史和对应索引。 */
    default void deleteBtsHistory(String historyKey) { }
    /** 原子提交检测柜新增历史与推进后的播放状态。 */
    default void saveBtsPlaybackAndAppendHistory(String historyKey, BtsPlaybackState state,
            List<BtsHistoryRecord> records)
    {
        for (BtsHistoryRecord record : records) appendBtsHistory(historyKey, record);
        saveBtsPlaybackState(historyKey, state);
    }
    /** 原子保存删除采集器后的主状态,并删除该采集器的独立历史。 */
    default void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode)
    {
        throw new UnsupportedOperationException("Atomic collector deletion is not supported");
    }
    /** 读取短期保留的最近请求。 */
    List<RecentRequest> loadRecentRequests();
@@ -88,12 +25,20 @@
    void reset(SimulatorState defaultState);
    record SimulatorState(int version, List<DeviceState> devices, List<CollectorState> collectors,
            int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones)
            int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones,
            Boolean softwareAccessible, long observationSequence)
    {
        public SimulatorState(int version, List<DeviceState> devices, List<CollectorState> collectors,
                int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones)
        {
            this(version, devices, collectors, nextDeviceId, nextCollectorId, tombstones, true, 0L);
        }
        public SimulatorState
        {
            if (version != CURRENT_VERSION)
                throw new IllegalStateException("Unsupported BTS simulator state version: " + version);
            softwareAccessible = softwareAccessible == null ? true : softwareAccessible;
            devices = List.copyOf(devices);
            collectors = List.copyOf(collectors);
            tombstones = List.copyOf(tombstones);
@@ -118,31 +63,35 @@
    record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
            String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
            long nextSequence, Instant lastSampledAt, List<CollectorChannelState> channels,
            List<CollectorRealtimeMeasurement> latestMeasurements,
            long historyRecordCount, CollectorHistoryFaults historyFaults)
            List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements)
    {
        public CollectorState
        {
            channels = List.copyOf(channels);
            latestMeasurements = List.copyOf(latestMeasurements);
            historyFaults = historyFaults == null ? CollectorHistoryFaults.none() : historyFaults;
        }
        /** 兼容只包含发现配置的旧模拟状态;Redis 读取边界会补齐采样合同。 */
        public CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
                String manufacturer, String model, int channelCount, boolean online)
        {
            this(collectorId, deviceCode, ip, mac, name, manufacturer, model, channelCount, online,
                    DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, 1L, null,
                    DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of(), 0L,
                    CollectorHistoryFaults.none());
                    DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS,
                    DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of());
        }
    }
    record CollectorChannelState(String channelCode, List<CollectorMetricState> metrics)
    record CollectorChannelState(String channelCode, List<CollectorMetricState> metrics, DeviceWorkStatus workStatus)
    {
        public CollectorChannelState { metrics = List.copyOf(metrics); }
        public CollectorChannelState
        {
            metrics = List.copyOf(metrics);
            workStatus = workStatus == null ? DeviceWorkStatus.WORKING : workStatus;
        }
        public CollectorChannelState(String channelCode, List<CollectorMetricState> metrics)
        {
            this(channelCode, metrics, DeviceWorkStatus.WORKING);
        }
    }
    record CollectorMetricState(String metricCode, BigDecimal value, String unit) { }
@@ -150,76 +99,6 @@
    record CollectorRealtimeMeasurement(String adapterCode, String collectorCode, String channelCode,
            long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt,
            String metricCode, BigDecimal value, String unit) { }
    record CollectorMeasurementRecord(String adapterCode, String collectorCode, String channelCode,
            long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt,
            String metricCode, BigDecimal value, String unit) { }
    record CollectorHistoryAppend(String collectorCode, List<CollectorMeasurementRecord> records)
    {
        public CollectorHistoryAppend { records = List.copyOf(records); }
    }
    record CollectorHistoryBatch(long offset, long nextOffset, boolean hasMore, long total,
            List<CollectorMeasurementRecord> records)
    {
        public CollectorHistoryBatch { records = List.copyOf(records); }
    }
    record BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV,
            double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType,
            Instant serverReceivedAt)
    {
        /** 兼容现有调用方;新生成记录应传入服务器接收时间。 */
        public BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV,
                double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType)
        {
            this(sequenceId, deviceTime, currentA, voltageV, capacityAh, energyWh, cycleNo, stepNo,
                    stepName, stepType, Instant.now());
        }
        /** 为仅包含核心测量值的历史记录补齐默认工步信息。 */
        public BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV,
                double capacityAh, double energyWh)
        {
            this(sequenceId, deviceTime, currentA, voltageV, capacityAh, energyWh, 1, 1, "恒流充电", "cc");
        }
    }
    /** 检测柜单通道演示播放状态;与测试的接收状态相互独立。 */
    record BtsPlaybackState(boolean playing, int speedMultiplier, int cycleNo, int stepNo,
            String stepName, String stepType, Instant simulatedAt, Instant wallClockAt,
            long nextSequence, boolean naturallyFinished)
    {
        public BtsPlaybackState
        {
            if (speedMultiplier != 1 && speedMultiplier != 60 && speedMultiplier != 600)
                throw new IllegalArgumentException("speedMultiplier must be 1, 60 or 600");
            if (cycleNo < 1 || stepNo < 1 || nextSequence < 1 || stepName == null || stepName.isBlank()
                    || stepType == null || stepType.isBlank())
                throw new IllegalArgumentException("Invalid BTS playback state");
        }
    }
    record BtsHistoryBatch(long offset, long nextOffset, boolean hasMore, long total,
            List<BtsHistoryRecord> records)
    {
        public BtsHistoryBatch { records = List.copyOf(records); }
    }
    record CollectorHistoryFaults(List<Long> missingSequences, Long duplicateSequence,
            Long conflictingSequence, boolean outOfOrder)
    {
        public CollectorHistoryFaults
        {
            missingSequences = missingSequences == null ? List.of() : List.copyOf(missingSequences);
        }
        public static CollectorHistoryFaults none()
        {
            return new CollectorHistoryFaults(List.of(), null, null, false);
        }
    }
    record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { }
@@ -251,5 +130,5 @@
    record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { }
    record RecentRequest(Instant receivedAt, String command, String request) { }
    record RecentRequest(Instant receivedAt, String command, String request, String response) { }
}
battery-device-simulator/src/main/java/com/battery/device/simulator/DevelopmentCollectorSimulation.java
@@ -2,34 +2,19 @@
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
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.api.model.DeviceWorkStatus;
import com.battery.device.simulator.BtsSimulatorStateStore.*;
/** 实现第一版开发采集器的独立采样与故障注入合同。 */
/** 提供请求时的采集器当前值;S 通道仍是待现场验证的开发合同。 */
public final class DevelopmentCollectorSimulation
{
    public static final int DEFAULT_SAMPLE_INTERVAL_MS = 1000;
    public static final int MIN_SAMPLE_INTERVAL_MS = 100;
    public static final int MAX_SAMPLE_INTERVAL_MS = 60000;
    public static final int MAX_HISTORY_PAGE_SIZE = BtsSimulatorStateStore.MAX_HISTORY_BATCH_SIZE;
    private static final int MAX_SAMPLES_PER_ADVANCE = 1000;
    private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong");
    private static final DateTimeFormatter DEVICE_TIME_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
    private static final DateTimeFormatter DEVICE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
    private DevelopmentCollectorSimulation() { }
@@ -48,320 +33,72 @@
        return List.copyOf(channels);
    }
    /** 调整通道数时保留已有通道配置,并为新增通道生成稳定默认配置。 */
    public static CollectorState resize(CollectorState current, String ip, int channelCount, boolean online,
            Instant changedAt)
    /** 调整设备拓扑,保留已有通道的配置。 */
    public static CollectorState resize(CollectorState current, String ip, int channelCount, boolean online, Instant changedAt)
    {
        List<CollectorChannelState> defaults = defaultChannels(channelCount);
        List<CollectorChannelState> channels = new ArrayList<>(channelCount);
        var defaults = defaultChannels(channelCount);
        List<CollectorChannelState> channels = new ArrayList<>();
        for (int index = 0; index < channelCount; index++)
            channels.add(index < current.channels().size() ? current.channels().get(index) : defaults.get(index));
        Instant lastSampledAt = online == current.online() ? current.lastSampledAt() : changedAt;
        List<CollectorRealtimeMeasurement> latestMeasurements = retainConfiguredMeasurements(
                current.latestMeasurements(), channels);
        return copy(current, ip, channelCount, online, current.sampleIntervalMs(), current.nextSequence(),
                lastSampledAt, channels, latestMeasurements, current.historyRecordCount(), current.historyFaults());
        return new CollectorState(current.collectorId(), current.deviceCode(), ip.trim(), current.mac(),
                current.name(), current.manufacturer(), current.model(), channelCount, online,
                current.sampleIntervalMs(), channels, List.of());
    }
    /** 更新采样频率和历史下载故障;测量原始历史保持只追加。 */
    public static CollectorState configureSampling(CollectorState current, int sampleIntervalMs,
            CollectorHistoryFaults historyFaults)
    {
        validateSampleInterval(sampleIntervalMs);
        validateFaults(historyFaults);
        return copy(current, current.ip(), current.channelCount(), current.online(), sampleIntervalMs,
                current.nextSequence(), current.lastSampledAt(), current.channels(), current.latestMeasurements(),
                current.historyRecordCount(), historyFaults);
    }
    /** 替换一个演示通道的指标配置;动态添加项与固定指标共用指标编码合同。 */
    public static CollectorState configureChannel(CollectorState current, String channelCode,
            List<CollectorMetricState> metrics)
    /** 保存当前测量配置,等待下一次实时请求。 */
    public static CollectorState configureChannel(CollectorState current, String channelCode, List<CollectorMetricState> metrics)
    {
        validateMetrics(metrics);
        int channelIndex = parseChannelIndex(channelCode, current.channelCount());
        int index = parseChannelIndex(channelCode, current.channelCount());
        List<CollectorChannelState> channels = new ArrayList<>(current.channels());
        channels.set(channelIndex, new CollectorChannelState(channelCode(channelIndex + 1), metrics));
        List<CollectorRealtimeMeasurement> latestMeasurements = retainConfiguredMeasurements(
                current.latestMeasurements(), channels);
        return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
                current.nextSequence(), current.lastSampledAt(), channels, latestMeasurements,
                current.historyRecordCount(), current.historyFaults());
        channels.set(index, new CollectorChannelState(channelCode, metrics, channels.get(index).workStatus()));
        return copy(current, channels, List.of());
    }
    /** 按采集器自身频率推进采样;离线期间不生成记录,恢复后从下一序号继续。 */
    public static CollectorAdvance advance(CollectorState current, Instant now)
    /** 保存通道工作状态,等待下一次实时请求。 */
    public static CollectorState configureStatus(CollectorState current, String channelCode, DeviceWorkStatus status)
    {
        if (!current.online()) return new CollectorAdvance(current, List.of());
        Instant sampleAt = current.lastSampledAt() == null
                ? now
                : current.lastSampledAt().plusMillis(current.sampleIntervalMs());
        if (sampleAt.isAfter(now)) return new CollectorAdvance(current, List.of());
        int index = parseChannelIndex(channelCode, current.channelCount());
        List<CollectorChannelState> channels = new ArrayList<>(current.channels());
        channels.set(index, new CollectorChannelState(channelCode, channels.get(index).metrics(), status));
        return copy(current, channels, List.of());
    }
        List<CollectorMeasurementRecord> appendedRecords = new ArrayList<>();
        Map<String, CollectorRealtimeMeasurement> latest = new LinkedHashMap<>();
        for (CollectorRealtimeMeasurement measurement : current.latestMeasurements())
            latest.put(measurementKey(measurement.channelCode(), measurement.metricCode()), measurement);
        long sequence = current.nextSequence();
        int generated = 0;
        Instant lastSampledAt = current.lastSampledAt();
        while (!sampleAt.isAfter(now) && generated < MAX_SAMPLES_PER_ADVANCE)
    /** 仅生成本次请求的当前值,不补算上一次请求与本次之间的空档。 */
    public static CollectorState sample(CollectorState current, Instant now, long sequence)
    {
        Instant observedAt = now.truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
        List<CollectorRealtimeMeasurement> measurements = new ArrayList<>();
        for (CollectorChannelState channel : current.channels())
        {
            for (CollectorChannelState channel : current.channels())
            {
                for (CollectorMetricState metric : channel.metrics())
                {
                    CollectorMeasurementRecord record = new CollectorMeasurementRecord("dev-sim", current.deviceCode(),
                            channel.channelCode(), sequence, sampleAt,
                            DEVICE_TIME_FORMATTER.format(sampleAt.atZone(DEVICE_ZONE)), now,
                            metric.metricCode(), metric.value(), metric.unit());
                    appendedRecords.add(record);
                    latest.put(measurementKey(record.channelCode(), record.metricCode()),
                            new CollectorRealtimeMeasurement(record.adapterCode(), record.collectorCode(),
                                    record.channelCode(), record.sequenceId(), record.deviceTime(),
                                    record.deviceTimeRaw(), record.serverReceivedAt(), record.metricCode(),
                                    record.value(), record.unit()));
                }
            }
            lastSampledAt = sampleAt;
            sequence++;
            generated++;
            sampleAt = sampleAt.plusMillis(current.sampleIntervalMs());
            if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) continue;
            for (CollectorMetricState metric : channel.metrics())
                measurements.add(new CollectorRealtimeMeasurement("dev-sim", current.deviceCode(), channel.channelCode(),
                        sequence, observedAt, DEVICE_TIME_FORMATTER.format(observedAt.atZone(DEVICE_ZONE)), observedAt,
                        metric.metricCode(), metric.value(), metric.unit()));
        }
        CollectorState next = copy(current, current.ip(), current.channelCount(), current.online(),
                current.sampleIntervalMs(),
                sequence, lastSampledAt, current.channels(), List.copyOf(latest.values()),
                Math.min(BtsSimulatorStateStore.MAX_HISTORY_RECORDS,
                        current.historyRecordCount() + appendedRecords.size()),
                current.historyFaults());
        return new CollectorAdvance(next, appendedRecords);
        return copy(current, current.channels(), measurements);
    }
    /** 返回独立保存的最新实时快照,不从原始历史反推当前状态。 */
    public static CollectorRealtimeSnapshot realtime(CollectorState current)
    /** 校验恢复后的稳定通道和测量配置。 */
    public static void validate(CollectorState state)
    {
        return new CollectorRealtimeSnapshot(current.deviceCode(), current.online(), current.sampleIntervalMs(),
                current.latestMeasurements());
    }
    /** 对只追加原始模拟历史应用可重复的下载故障视图,再按偏移量分页。 */
    public static CollectorHistoryPage history(CollectorState current, BtsSimulatorStateStore stateStore,
            long offset, int limit)
    {
        if (offset < 0) throw new IllegalArgumentException("offset must not be negative");
        if (limit < 1 || limit > MAX_HISTORY_PAGE_SIZE)
            throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_PAGE_SIZE);
        CollectorHistoryFaults faults = current.historyFaults();
        if (faults.missingSequences().isEmpty() && faults.duplicateSequence() == null
                && faults.conflictingSequence() == null && !faults.outOfOrder())
        {
            BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage(
                    current.deviceCode(), offset, limit, false);
            return new CollectorHistoryPage(current.deviceCode(), offset, batch.nextOffset(), batch.hasMore(),
                    batch.total(), batch.records());
        }
        return faultedHistoryPage(current, stateStore, offset, limit);
    }
    /** 校验 Redis 恢复的采集器采样状态,拒绝损坏状态被静默替换。 */
    public static void validate(CollectorState state, BtsSimulatorStateStore stateStore)
    {
        validateSampleInterval(state.sampleIntervalMs());
        if (state.nextSequence() < 1 || state.channels().size() != state.channelCount())
            throw new IllegalStateException("Invalid collector sampling state in Redis");
        if (state.channels().size() != state.channelCount()) throw new IllegalStateException("Invalid collector topology");
        for (int index = 0; index < state.channels().size(); index++)
        {
            CollectorChannelState channel = state.channels().get(index);
            if (!channelCode(index + 1).equals(channel.channelCode()))
                throw new IllegalStateException("Invalid collector channel identity in Redis");
            var channel = state.channels().get(index);
            if (!channelCode(index + 1).equals(channel.channelCode())) throw new IllegalStateException("Invalid collector channel identity");
            validateMetrics(channel.metrics());
        }
        validateLatestMeasurements(state);
        validateHistory(state, stateStore);
        validateFaults(state.historyFaults());
    }
    private static void validateLatestMeasurements(CollectorState state)
    private static CollectorState copy(CollectorState current, List<CollectorChannelState> channels,
            List<CollectorRealtimeMeasurement> measurements)
    {
        Set<String> configured = configuredMeasurementKeys(state.channels());
        Set<String> identities = new java.util.HashSet<>();
        for (CollectorRealtimeMeasurement measurement : state.latestMeasurements())
        {
            String key = measurementKey(measurement.channelCode(), measurement.metricCode());
            if (!validMeasurement(state, measurement.adapterCode(), measurement.collectorCode(),
                    measurement.channelCode(), measurement.sequenceId(), measurement.deviceTime(),
                    measurement.deviceTimeRaw(), measurement.serverReceivedAt(), measurement.metricCode(),
                    measurement.value(), measurement.unit()) || !configured.contains(key) || !identities.add(key)
                    || measurement.sequenceId() >= state.nextSequence())
                throw new IllegalStateException("Invalid collector realtime state in Redis");
        }
    }
    private static void validateHistory(CollectorState state, BtsSimulatorStateStore stateStore)
    {
        long maximumSequence = 0;
        long previousSequence = 0;
        long recordCount = 0;
        long offset = 0;
        Set<String> identities = new java.util.HashSet<>();
        while (true)
        {
            BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage(
                    state.deviceCode(), offset, MAX_HISTORY_PAGE_SIZE, false);
            if (batch.offset() != offset || batch.records().size() > MAX_HISTORY_PAGE_SIZE)
                throw new IllegalStateException("Invalid collector history page in Redis");
            for (CollectorMeasurementRecord record : batch.records())
            {
                if (record.sequenceId() < previousSequence)
                    throw new IllegalStateException("Invalid collector history state in Redis");
                if (record.sequenceId() != previousSequence)
                {
                    identities.clear();
                    previousSequence = record.sequenceId();
                }
                String identity = record.channelCode() + "\u0000" + record.metricCode();
                if (!validMeasurement(state, record.adapterCode(), record.collectorCode(), record.channelCode(),
                        record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(),
                        record.metricCode(), record.value(), record.unit()) || !identities.add(identity))
                    throw new IllegalStateException("Invalid collector history state in Redis");
                maximumSequence = Math.max(maximumSequence, record.sequenceId());
                recordCount++;
            }
            if (!batch.hasMore())
            {
                if (batch.total() != recordCount)
                    throw new IllegalStateException("Invalid collector history count in Redis");
                break;
            }
            if (batch.nextOffset() <= offset)
                throw new IllegalStateException("Invalid collector history page in Redis");
            offset = batch.nextOffset();
        }
        if (state.nextSequence() <= maximumSequence)
            throw new IllegalStateException("Invalid collector history sequence in Redis");
        if (state.historyRecordCount() != recordCount)
            throw new IllegalStateException("Invalid collector history count in Redis");
    }
    private static boolean validMeasurement(CollectorState state, String adapterCode, String collectorCode,
            String channelCode, long sequenceId, Instant deviceTime, String deviceTimeRaw,
            Instant serverReceivedAt, String metricCode, BigDecimal value, String unit)
    {
        return "dev-sim".equals(adapterCode) && state.deviceCode().equals(collectorCode)
                && validHistoricalChannelCode(channelCode)
                && sequenceId > 0 && deviceTime != null && deviceTimeRaw != null && !deviceTimeRaw.isBlank()
                && validDeviceTime(deviceTime, deviceTimeRaw) && serverReceivedAt != null
                && !serverReceivedAt.isBefore(deviceTime) && metricCode != null && !metricCode.isBlank()
                && metricCode.equals(metricCode.trim()) && value != null && unit != null && !unit.isBlank()
                && unit.equals(unit.trim());
    }
    private static boolean validHistoricalChannelCode(String channelCode)
    {
        if (channelCode == null || !channelCode.matches("S-\\d{2}")) return false;
        int index = Integer.parseInt(channelCode.substring(2));
        return index >= 1 && channelCode(index).equals(channelCode);
    }
    private static boolean validDeviceTime(Instant deviceTime, String deviceTimeRaw)
    {
        try
        {
            Instant parsed = LocalDateTime.parse(deviceTimeRaw, DEVICE_TIME_FORMATTER)
                    .atZone(DEVICE_ZONE).toInstant();
            return parsed.equals(deviceTime.truncatedTo(ChronoUnit.MILLIS));
        }
        catch (DateTimeParseException exception)
        {
            return false;
        }
    }
    private static List<CollectorRealtimeMeasurement> retainConfiguredMeasurements(
            List<CollectorRealtimeMeasurement> measurements, List<CollectorChannelState> channels)
    {
        Set<String> configured = configuredMeasurementKeys(channels);
        return measurements.stream()
                .filter(item -> configured.contains(measurementKey(item.channelCode(), item.metricCode())))
                .toList();
    }
    private static Set<String> configuredMeasurementKeys(List<CollectorChannelState> channels)
    {
        Set<String> configured = new java.util.HashSet<>();
        for (CollectorChannelState channel : channels)
            for (CollectorMetricState metric : channel.metrics())
                configured.add(measurementKey(channel.channelCode(), metric.metricCode()));
        return configured;
    }
    private static String measurementKey(String channelCode, String metricCode)
    {
        return channelCode + "\u0000" + metricCode;
    }
    private static CollectorHistoryPage faultedHistoryPage(CollectorState current,
            BtsSimulatorStateStore stateStore, long offset, int limit)
    {
        // 故障视图需返回精确 total,因此逐批扫描原始历史;正常下载会直接读取目标页。
        CollectorHistoryFaults faults = current.historyFaults();
        Set<Long> missing = Set.copyOf(faults.missingSequences());
        List<CollectorMeasurementRecord> page = new ArrayList<>(limit);
        long rawOffset = 0;
        long total = 0;
        while (true)
        {
            BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage(
                    current.deviceCode(), rawOffset, MAX_HISTORY_PAGE_SIZE, faults.outOfOrder());
            for (CollectorMeasurementRecord record : batch.records())
            {
                if (missing.contains(record.sequenceId())) continue;
                total = appendFaultedRecord(page, record, total, offset, limit);
                if (Long.valueOf(record.sequenceId()).equals(faults.duplicateSequence()))
                    total = appendFaultedRecord(page, record, total, offset, limit);
                if (Long.valueOf(record.sequenceId()).equals(faults.conflictingSequence()))
                    total = appendFaultedRecord(page, conflictingRecord(record), total, offset, limit);
            }
            if (!batch.hasMore()) break;
            if (batch.nextOffset() <= rawOffset)
                throw new IllegalStateException("Invalid collector history page in Redis");
            rawOffset = batch.nextOffset();
        }
        long from = Math.min(offset, total);
        long nextOffset = from + page.size();
        return new CollectorHistoryPage(current.deviceCode(), offset, nextOffset, nextOffset < total, total, page);
    }
    private static long appendFaultedRecord(List<CollectorMeasurementRecord> page,
            CollectorMeasurementRecord record, long index, long offset, int limit)
    {
        if (index >= offset && page.size() < limit) page.add(record);
        return index + 1;
    }
    private static CollectorMeasurementRecord conflictingRecord(CollectorMeasurementRecord record)
    {
        return new CollectorMeasurementRecord(record.adapterCode(), record.collectorCode(), record.channelCode(),
                record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(),
                record.metricCode(), record.value().add(new BigDecimal("0.001")), record.unit());
    }
    private static CollectorState copy(CollectorState current, String ip, int channelCount, boolean online,
            int sampleIntervalMs, long nextSequence, Instant lastSampledAt,
            List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements,
            long historyRecordCount,
            CollectorHistoryFaults faults)
    {
        return new CollectorState(current.collectorId(), current.deviceCode(), ip.trim(), current.mac(),
                current.name(), current.manufacturer(), current.model(), channelCount, online, sampleIntervalMs,
                nextSequence, lastSampledAt, channels, latestMeasurements, historyRecordCount, faults);
    }
    private static void validateSampleInterval(int sampleIntervalMs)
    {
        if (sampleIntervalMs < MIN_SAMPLE_INTERVAL_MS || sampleIntervalMs > MAX_SAMPLE_INTERVAL_MS)
            throw new IllegalArgumentException("sampleIntervalMs must be between " + MIN_SAMPLE_INTERVAL_MS
                    + " and " + MAX_SAMPLE_INTERVAL_MS);
        return new CollectorState(current.collectorId(), current.deviceCode(), current.ip(), current.mac(),
                current.name(), current.manufacturer(), current.model(), current.channelCount(), current.online(),
                current.sampleIntervalMs(), channels, measurements);
    }
    private static void validateMetrics(List<CollectorMetricState> metrics)
@@ -377,15 +114,6 @@
                    || !metric.unit().equals(metric.unit().trim()) || !codes.add(metric.metricCode()))
                throw new IllegalArgumentException("Collector metric code, value and unit must be valid and unique");
        }
    }
    private static void validateFaults(CollectorHistoryFaults faults)
    {
        if (faults == null) throw new IllegalArgumentException("historyFaults must not be null");
        if (faults.missingSequences().stream().anyMatch(sequence -> sequence == null || sequence < 1)
                || faults.duplicateSequence() != null && faults.duplicateSequence() < 1
                || faults.conflictingSequence() != null && faults.conflictingSequence() < 1)
            throw new IllegalArgumentException("History fault sequences must be positive");
    }
    private static int parseChannelIndex(String value, int channelCount)
@@ -409,14 +137,4 @@
        public CollectorRealtimeSnapshot { measurements = List.copyOf(measurements); }
    }
    public record CollectorAdvance(CollectorState state, List<CollectorMeasurementRecord> appendedRecords)
    {
        public CollectorAdvance { appendedRecords = List.copyOf(appendedRecords); }
    }
    public record CollectorHistoryPage(String collectorCode, long offset, long nextOffset, boolean hasMore,
            long total, List<CollectorMeasurementRecord> records)
    {
        public CollectorHistoryPage { records = List.copyOf(records); }
    }
}
battery-device-simulator/src/main/java/com/battery/device/simulator/InMemoryBtsSimulatorStateStore.java
@@ -1,167 +1,33 @@
package com.battery.device.simulator;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryBatch;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryBatch;
import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest;
import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState;
/** 测试和独立协议合同使用的内存状态存储。 */
public final class InMemoryBtsSimulatorStateStore implements BtsSimulatorStateStore
{
    private SimulatorState state;
    private List<RecentRequest> recentRequests = List.of();
    private final Map<String, ArrayList<CollectorMeasurementRecord>> collectorHistory = new LinkedHashMap<>();
    private final Map<String, ArrayList<BtsHistoryRecord>> btsHistory = new LinkedHashMap<>();
    private final Map<String, BtsPlaybackState> btsPlayback = new LinkedHashMap<>();
    public InMemoryBtsSimulatorStateStore() { }
    public InMemoryBtsSimulatorStateStore(SimulatorState state)
    {
        this.state = state;
    }
    public InMemoryBtsSimulatorStateStore(SimulatorState state) { this.state = state; }
    @Override
    public synchronized SimulatorState loadOrInitialize(SimulatorState defaultState)
    public synchronized SimulatorState loadOrInitialize(SimulatorState defaults)
    {
        if (state == null) state = defaultState;
        if (state == null) state = defaults;
        return state;
    }
    @Override
    public synchronized void save(SimulatorState state)
    {
        this.state = state;
    }
    public synchronized void save(SimulatorState state) { this.state = state; }
    @Override
    public synchronized void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends)
    {
        for (CollectorHistoryAppend append : historyAppends)
        {
            ArrayList<CollectorMeasurementRecord> history = collectorHistory.computeIfAbsent(
                    append.collectorCode(), key -> new ArrayList<>());
            history.addAll(append.records());
            int overflow = history.size() - (int) MAX_HISTORY_RECORDS;
            if (overflow > 0) history.subList(0, overflow).clear();
        }
        this.state = state;
    }
    public synchronized List<RecentRequest> loadRecentRequests() { return recentRequests; }
    @Override
    public synchronized CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset,
            int limit, boolean reverse)
    {
        if (offset < 0) throw new IllegalArgumentException("offset must not be negative");
        if (limit < 1 || limit > MAX_HISTORY_BATCH_SIZE)
            throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_BATCH_SIZE);
        List<CollectorMeasurementRecord> history = collectorHistory.getOrDefault(collectorCode,
                new ArrayList<>());
        long total = history.size();
        int from = (int) Math.min(offset, total);
        int count = Math.min(limit, history.size() - from);
        List<CollectorMeasurementRecord> records = new ArrayList<>(count);
        if (reverse)
        {
            for (int index = history.size() - 1 - from; index >= history.size() - from - count; index--)
                records.add(history.get(index));
        }
        else
        {
            records.addAll(history.subList(from, from + count));
        }
        long nextOffset = from + records.size();
        return new CollectorHistoryBatch(offset, nextOffset, nextOffset < total, total, records);
    }
    public synchronized void saveRecentRequests(List<RecentRequest> requests) { recentRequests = List.copyOf(requests); }
    @Override
    public synchronized void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode)
    public synchronized void reset(SimulatorState defaults)
    {
        collectorHistory.remove(collectorCode);
        this.state = state;
    }
    @Override
    public synchronized BtsHistoryBatch loadBtsHistoryPage(String historyKey, long offset, int limit)
    {
        List<BtsHistoryRecord> history = btsHistory.getOrDefault(historyKey, new ArrayList<>());
        int from = (int) Math.min(offset, history.size());
        int count = Math.min(limit, history.size() - from);
        List<BtsHistoryRecord> records = new ArrayList<>(history.subList(from, from + count));
        long next = from + records.size();
        return new BtsHistoryBatch(offset, next, next < history.size(), history.size(), records);
    }
    @Override
    public synchronized void appendBtsHistory(String historyKey, BtsHistoryRecord record)
    {
        ArrayList<BtsHistoryRecord> history = btsHistory.computeIfAbsent(historyKey, key -> new ArrayList<>());
        history.add(record);
        if (history.size() > MAX_HISTORY_RECORDS) history.remove(0);
    }
    @Override
    public synchronized BtsPlaybackState loadBtsPlaybackState(String historyKey)
    {
        return btsPlayback.get(historyKey);
    }
    @Override
    public synchronized void saveBtsPlaybackState(String historyKey, BtsPlaybackState playback)
    {
        btsPlayback.put(historyKey, playback);
    }
    @Override
    public synchronized void deleteBtsPlaybackState(String historyKey)
    {
        btsPlayback.remove(historyKey);
    }
    @Override
    public synchronized void deleteBtsHistory(String historyKey)
    {
        btsHistory.remove(historyKey);
    }
    @Override
    public synchronized void saveBtsPlaybackAndAppendHistory(String historyKey, BtsPlaybackState playback,
            List<BtsHistoryRecord> records)
    {
        ArrayList<BtsHistoryRecord> history = btsHistory.computeIfAbsent(historyKey, key -> new ArrayList<>());
        history.addAll(records);
        int overflow = history.size() - (int) MAX_HISTORY_RECORDS;
        if (overflow > 0) history.subList(0, overflow).clear();
        btsPlayback.put(historyKey, playback);
    }
    @Override
    public synchronized List<RecentRequest> loadRecentRequests()
    {
        return recentRequests;
    }
    @Override
    public synchronized void saveRecentRequests(List<RecentRequest> requests)
    {
        recentRequests = List.copyOf(requests);
    }
    @Override
    public synchronized void reset(SimulatorState defaultState)
    {
        state = defaultState;
        state = defaults;
        recentRequests = List.of();
        collectorHistory.clear();
        btsHistory.clear();
        btsPlayback.clear();
    }
}
battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
@@ -24,10 +24,6 @@
import com.battery.device.simulator.BtsSimulatorStateStore;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryBatch;
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.CollectorState;
import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
@@ -38,182 +34,20 @@
class SimulatorPublicContractTest
{
    @Test
    void collectorGeneratesIndependentRealtimeAndHistoricalMeasurements() throws Exception
    {
        Instant startedAt = Instant.parse("2026-08-29T01:00:00Z");
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            simulator.configureCollectorSampling(1, 200, CollectorHistoryFaults.none());
            simulator.configureCollectorChannel(1, "S-01", List.of(
                    new CollectorMetricState("temperature", new BigDecimal("26.5"), "℃"),
                    new CollectorMetricState("positive_potential", new BigDecimal("3.801"), "V"),
                    new CollectorMetricState("negative_potential", new BigDecimal("-0.120"), "V"),
                    new CollectorMetricState("pressure", new BigDecimal("101.2"), "kPa")));
            simulator.advanceCollectors(startedAt);
            simulator.advanceCollectors(startedAt.plusMillis(400));
            var realtime = simulator.snapshot().collectors().get(0).latestMeasurements();
            var history = simulator.collectorHistory(1, 0, 1000);
            assertEquals(13, realtime.size());
            assertTrue(realtime.stream().allMatch(item -> item.sequenceId() == 3));
            assertTrue(realtime.stream().anyMatch(item -> item.channelCode().equals("S-01")
                    && item.metricCode().equals("pressure") && item.unit().equals("kPa")));
            assertEquals(39, history.total());
            assertEquals("dev-sim", history.records().get(0).adapterCode());
            assertEquals("dev-sim-collector-1", history.records().get(0).collectorCode());
            assertEquals(startedAt, history.records().get(0).deviceTime());
            assertTrue(history.records().get(0).deviceTimeRaw().startsWith("2026-08-29 09:00:00"));
            assertEquals(startedAt, history.records().get(0).serverReceivedAt());
        }
    }
    @Test
    void collectorHistorySupportsPagingAndRepeatableFaultScenarios() throws Exception
    {
        Instant startedAt = Instant.parse("2026-08-29T02:00:00Z");
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            simulator.configureCollectorSampling(1, 100,
                    new CollectorHistoryFaults(List.of(2L), 1L, 3L, true));
            simulator.advanceCollectors(startedAt);
            simulator.advanceCollectors(startedAt.plusMillis(200));
            var first = simulator.collectorHistory(1, 0, 17);
            var second = simulator.collectorHistory(1, first.nextOffset(), 1000);
            List<com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord> records =
                    new java.util.ArrayList<>(first.records());
            records.addAll(second.records());
            assertEquals(48, first.total());
            assertTrue(first.hasMore());
            assertFalse(second.hasMore());
            assertTrue(records.stream().noneMatch(item -> item.sequenceId() == 2));
            assertEquals(24, records.stream().filter(item -> item.sequenceId() == 1).count());
            assertEquals(2, records.stream().filter(item -> item.sequenceId() == 3
                    && item.channelCode().equals("S-01") && item.metricCode().equals("temperature")).count());
            assertTrue(records.get(0).sequenceId() >= records.get(records.size() - 1).sequenceId());
        }
    }
    @Test
    void collectorStopsWhileOfflineAndResumesFromPersistedSequence() throws Exception
    {
        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
        Instant now = Instant.now();
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
        {
            simulator.advanceCollectors(now);
            simulator.updateCollector(1, "127.0.1.2", 4, false);
            simulator.advanceCollectors(now.plusSeconds(10));
            assertEquals(1, simulator.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId());
        }
        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
        {
            restored.updateCollector(1, "127.0.1.2", 4, true);
            restored.advanceCollectors(Instant.now().plusSeconds(2));
            assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId() >= 2);
            assertEquals(List.of("S-01", "S-02", "S-03", "S-04"),
                    restored.snapshot().collectors().get(0).channels().stream()
                            .map(channel -> channel.channelCode()).toList());
        }
    }
    @Test
    void simulatorPowerSwitchStopsSamplingAndRestoresWithoutDeletingHistory() throws Exception
    {
        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
        long historyCount;
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
        {
            simulator.advanceCollectors(Instant.now());
            historyCount = simulator.snapshot().collectors().get(0).historyRecordCount();
            simulator.setAllDevicesPoweredOn(false);
            simulator.advanceCollectors(Instant.now().plusSeconds(10));
            var poweredOff = simulator.snapshot();
            assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online()
                    || device.channels().stream().anyMatch(channel -> channel.online()
                            || channel.workStatus() != DeviceWorkStatus.STOPPED)));
            assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online()));
            assertEquals(historyCount, poweredOff.collectors().get(0).historyRecordCount());
        }
        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
        {
            assertFalse(restored.snapshot().collectors().get(0).online());
            assertEquals(historyCount, restored.collectorHistory(1, 0, 1000).total());
            restored.setAllDevicesPoweredOn(true);
            var poweredOn = restored.snapshot();
            assertTrue(poweredOn.devices().stream().allMatch(device -> device.online()
                    && device.channels().stream().allMatch(channel -> channel.online()
                            && channel.workStatus() == DeviceWorkStatus.STOPPED)));
            assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online()));
            restored.advanceCollectors(Instant.now().plusSeconds(2));
            assertTrue(restored.snapshot().collectors().get(0).historyRecordCount() > historyCount);
        }
    }
    @Test
    void collectorKeepsOnlyBoundedDemoHistory() throws Exception
    {
        Instant startedAt = Instant.parse("2026-08-29T03:00:00Z");
        TrackingHistoryStore store = new TrackingHistoryStore();
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
        {
            simulator.configureCollectorSampling(1, 100, CollectorHistoryFaults.none());
            simulator.advanceCollectors(startedAt);
            for (int batch = 1; batch <= 5; batch++)
                simulator.advanceCollectors(startedAt.plusMillis(batch * 100_000L));
            store.resetReadStats();
            var page = simulator.collectorHistory(1, 0, 1);
            assertEquals(BtsSimulatorStateStore.MAX_HISTORY_RECORDS, page.total());
            assertTrue(page.records().get(0).sequenceId() > 1);
            assertEquals(1, store.historyReads);
            assertEquals(1, store.recordsRead);
        }
        store.resetReadStats();
        try (BtsDevelopmentSimulator ignored = new BtsDevelopmentSimulator(0, store))
        {
            assertTrue(store.historyReads > 1);
            assertEquals(1000, store.maximumRequestedLimit);
            assertEquals(BtsSimulatorStateStore.MAX_HISTORY_RECORDS, store.recordsRead);
        }
    }
    @Test
    void collectorRawHistoryIsNotEmbeddedInMainSimulatorState() throws Exception
    {
        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
        {
            simulator.advanceCollectors(Instant.parse("2026-08-29T03:30:00Z"));
            String mainState = JSON.toJSONString(store.loadOrInitialize(null));
            assertFalse(mainState.contains("\"history\":[{"));
            assertEquals(12, simulator.collectorHistory(1, 0, 100).total());
        }
    }
    @Test
    void collectorRealtimeDropsMeasurementsRemovedFromCurrentConfiguration() throws Exception
    {
        Instant startedAt = Instant.parse("2026-08-29T04:00:00Z");
        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
        {
            simulator.advanceCollectors(startedAt);
            simulator.collectorRealtime(1);
            assertEquals(12, simulator.snapshot().collectors().get(0).latestMeasurements().size());
            simulator.configureCollectorChannel(1, "S-01", List.of(
                    new CollectorMetricState("temperature", new BigDecimal("27.0"), "℃")));
            simulator.updateCollector(1, "127.0.1.2", 2, true);
            simulator.collectorRealtime(1);
            var snapshot = simulator.snapshot().collectors().get(0);
            assertEquals(4, snapshot.latestMeasurements().size());
            assertTrue(snapshot.latestMeasurements().stream()
@@ -221,13 +55,11 @@
                            || item.channelCode().equals("S-04")
                            || item.channelCode().equals("S-01")
                                    && item.metricCode().equals("positive_potential")));
            assertEquals(12, simulator.collectorHistory(1, 0, 100).total());
        }
        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
        {
            assertEquals(4, restored.snapshot().collectors().get(0).latestMeasurements().size());
            assertEquals(12, restored.collectorHistory(1, 0, 100).total());
            assertEquals(4, restored.collectorRealtime(1).measurements().size());
        }
    }
@@ -496,9 +328,7 @@
        {
            @Override public SimulatorState loadOrInitialize(SimulatorState value)
            { return delegate.loadOrInitialize(value); }
            @Override public void save(SimulatorState value) { delegate.save(value); }
            @Override public void saveAndDeleteCollectorHistory(SimulatorState value, String collectorCode)
            { throw new IllegalStateException("redis unavailable"); }
            @Override public void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); }
            @Override public java.util.List<RecentRequest> loadRecentRequests()
            { return delegate.loadRecentRequests(); }
            @Override public void saveRecentRequests(java.util.List<RecentRequest> requests)
@@ -551,141 +381,4 @@
                () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
    }
    @Test
    void developmentSimulatorRejectsDamagedCollectorHistoryIdentity()
    {
        Instant observedAt = Instant.parse("2026-08-29T05:00:00Z");
        List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of(
                new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃"))));
        CollectorMeasurementRecord record = new CollectorMeasurementRecord(
                "dev-sim", "wrong-collector", "S-01", 1L, observedAt,
                "2026-08-29 13:00:00.000", observedAt, "temperature",
                new BigDecimal("25.0"), "℃");
        CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2",
                "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true,
                1000, 2L, observedAt, channels, List.of(), 1L, CollectorHistoryFaults.none());
        SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
                List.of(), List.of(collector), 1, 2, List.of());
        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged);
        store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), List.of(record))));
        assertThrows(IllegalStateException.class,
                () -> new BtsDevelopmentSimulator(0, store));
    }
    @Test
    void developmentSimulatorRejectsCollectorSequenceBehindPersistedHistory()
    {
        Instant observedAt = Instant.parse("2026-08-29T06:00:00Z");
        List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of(
                new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃"))));
        CollectorMeasurementRecord record = new CollectorMeasurementRecord(
                "dev-sim", "dev-sim-collector-1", "S-01", 1L, observedAt,
                "2026-08-29 14:00:00.000", observedAt, "temperature",
                new BigDecimal("25.0"), "℃");
        CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2",
                "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true,
                1000, 1L, observedAt, channels, List.of(), 1L, CollectorHistoryFaults.none());
        SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
                List.of(), List.of(collector), 1, 2, List.of());
        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged);
        store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), List.of(record))));
        assertThrows(IllegalStateException.class,
                () -> new BtsDevelopmentSimulator(0, store));
    }
    @Test
    void developmentSimulatorRejectsHistorySequenceRegressionAcrossPages()
    {
        Instant observedAt = Instant.parse("2026-08-29T06:30:00Z");
        List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of(
                new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃"))));
        List<CollectorMeasurementRecord> records = new java.util.ArrayList<>();
        for (long sequence = 1; sequence <= 1000; sequence++)
            records.add(new CollectorMeasurementRecord("dev-sim", "dev-sim-collector-1", "S-01", sequence,
                    observedAt, "2026-08-29 14:30:00.000", observedAt, "temperature",
                    new BigDecimal("25.0"), "℃"));
        records.add(new CollectorMeasurementRecord("dev-sim", "dev-sim-collector-1", "S-01", 999,
                observedAt, "2026-08-29 14:30:00.000", observedAt, "temperature",
                new BigDecimal("25.0"), "℃"));
        CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2",
                "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true,
                1000, 1001L, observedAt, channels, List.of(), records.size(), CollectorHistoryFaults.none());
        SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
                List.of(), List.of(collector), 1, 2, List.of());
        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(damaged);
        store.save(damaged, List.of(new CollectorHistoryAppend(collector.deviceCode(), records)));
        assertThrows(IllegalStateException.class,
                () -> new BtsDevelopmentSimulator(0, store));
    }
    private static final class TrackingHistoryStore implements BtsSimulatorStateStore
    {
        private final InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore();
        private int historyReads;
        private int maximumRequestedLimit;
        private long recordsRead;
        @Override
        public SimulatorState loadOrInitialize(SimulatorState defaultState)
        {
            return delegate.loadOrInitialize(defaultState);
        }
        @Override
        public void save(SimulatorState state)
        {
            delegate.save(state);
        }
        @Override
        public void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends)
        {
            delegate.save(state, historyAppends);
        }
        @Override
        public CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, int limit,
                boolean reverse)
        {
            CollectorHistoryBatch batch = delegate.loadCollectorHistoryPage(collectorCode, offset, limit, reverse);
            historyReads++;
            maximumRequestedLimit = Math.max(maximumRequestedLimit, limit);
            recordsRead += batch.records().size();
            return batch;
        }
        @Override
        public void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode)
        {
            delegate.saveAndDeleteCollectorHistory(state, collectorCode);
        }
        @Override
        public List<RecentRequest> loadRecentRequests()
        {
            return delegate.loadRecentRequests();
        }
        @Override
        public void saveRecentRequests(List<RecentRequest> requests)
        {
            delegate.saveRecentRequests(requests);
        }
        @Override
        public void reset(SimulatorState defaultState)
        {
            delegate.reset(defaultState);
        }
        private void resetReadStats()
        {
            historyReads = 0;
            maximumRequestedLimit = 0;
            recordsRead = 0;
        }
    }
}
battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java
File was deleted
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
@@ -8,13 +8,85 @@
class SimulatorObservationAdapterContractTest
{
    @Test void configuredChannelFactsSurviveSoftwareFailureAndRestart() throws Exception
    {
        var store = new InMemoryBtsSimulatorStateStore();
        long firstSequence;
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
        {
            simulator.updateChannel(1, 1, true, DeviceWorkStatus.PROTECTED);
            simulator.updateCollectorChannelStatus(1, "S-01", DeviceWorkStatus.UNOBSERVABLE);
            var collector = simulator.latestCollector("dev-sim-collector-1");
            assertTrue(collector.measurements().stream().noneMatch(item -> item.channelCode().equals("S-01")));
            firstSequence = collector.measurements().get(0).sequenceId();
            simulator.setSoftwareAccessible(false);
            simulator.updateDevice(1, "127.0.0.8", 4, false);
            assertFalse(simulator.snapshot().softwareAccessible());
        }
        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
        {
            assertFalse(restored.snapshot().softwareAccessible());
            assertEquals("127.0.0.8", restored.snapshot().devices().get(0).ip());
            assertEquals(DeviceWorkStatus.PROTECTED, restored.snapshot().devices().get(0).channels().get(0).workStatus());
            assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().isEmpty());
            restored.setSoftwareAccessible(true);
            assertTrue(restored.latestCollector("dev-sim-collector-1").measurements().get(0).sequenceId() > firstSequence);
            assertEquals(DeviceWorkStatus.UNOBSERVABLE, restored.snapshot().collectors().get(0).channels().get(0).workStatus());
        }
    }
    @Test void offlineCollectorDoesNotReturnLastSuccessAndStateReadsDoNotSample() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            assertTrue(simulator.snapshot().collectors().get(0).latestMeasurements().isEmpty());
            var first = simulator.collectorRealtime(1);
            var recent = simulator.snapshot().recentRequests();
            assertEquals(recent, simulator.snapshot().recentRequests());
            simulator.updateCollector(1, "127.0.1.2", 4, false);
            assertFalse(simulator.collectorRealtime(1).online());
            assertTrue(simulator.collectorRealtime(1).measurements().isEmpty());
            simulator.updateCollector(1, "127.0.1.2", 4, true);
            assertTrue(simulator.collectorRealtime(1).measurements().get(0).sequenceId() > first.measurements().get(0).sequenceId());
            var channel = simulator.discoverChannels().get(0).identity();
            simulator.updateChannel(1, 1, true, DeviceWorkStatus.UNOBSERVABLE);
            assertNull(simulator.latestCabinet(channel));
            assertEquals(DeviceWorkStatus.UNOBSERVABLE, simulator.observe(java.util.Set.of(channel)).get(channel).workStatus());
        }
    }
    @Test void softwareFailurePreservesIdentityAndDoesNotReplayMeasurements() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            var channel = simulator.discoverChannels().get(0).identity();
            simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
            var first = simulator.latestCabinet(channel);
            assertNotNull(first);
            assertFalse(simulator.latestCollector("dev-sim-collector-1").measurements().isEmpty());
            simulator.setSoftwareAccessible(false);
            assertEquals(channel, simulator.discoverChannels().get(0).identity());
            assertTrue(simulator.snapshot().devices().get(0).online());
            assertEquals(DeviceWorkStatus.UNOBSERVABLE,
                    simulator.observe(java.util.Set.of(channel)).get(channel).workStatus());
            assertNull(simulator.latestCabinet(channel));
            assertTrue(simulator.latestCollector("dev-sim-collector-1").measurements().isEmpty());
            simulator.setSoftwareAccessible(true);
            var next = simulator.latestCabinet(channel);
            assertTrue(next.sequenceId() > first.sequenceId());
            assertFalse(next.deviceTime().isBefore(first.deviceTime()));
            assertTrue(simulator.stepRecords(channel).isEmpty());
            assertTrue(simulator.cycleRecords(channel).isEmpty());
            assertEquals(60000L, simulator.persistenceIntervalMillis(channel));
        }
    }
    @Test void persistenceRejectsForeignIdentitiesAndOfflineCachedFacts() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            ChannelIdentity channel = simulator.discoverChannels().get(0).identity();
            simulator.updateChannel(channel.devid(), channel.chlid(), true, DeviceWorkStatus.WORKING);
            simulator.advanceBts(java.time.Instant.now());
            assertNotNull(simulator.latestCabinet(channel));
            for (ChannelIdentity foreign : java.util.List.of(
                    new ChannelIdentity("another-adapter", channel.devtype(), channel.devid(), channel.subdevid(), channel.chlid()),
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorSamplingScheduler.java
File was deleted
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizer.java
@@ -41,7 +41,7 @@
        List<CollectorCandidate> candidates = simulatorState.collectors().stream()
                .map(item -> new CollectorCandidate(BtsDevelopmentSimulator.ADAPTER_CODE, item.deviceCode(),
                        item.ip(), item.mac(), item.name(),
                        item.manufacturer(), item.model(), item.channelCount(), item.online(), observedAt,
                        item.manufacturer(), item.model(), item.channelCount(), simulatorState.softwareAccessible() && item.online(), observedAt,
                        observedAt.plus(Duration.ofSeconds(30))))
                .toList();
        candidateStore.replaceCollectors(BtsDevelopmentSimulator.ADAPTER_CODE, candidates);
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorDiscoveryScheduler.java
@@ -13,6 +13,7 @@
@Profile("dev-simulator")
public class DevelopmentSimulatorDiscoveryScheduler
{
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DevelopmentSimulatorDiscoveryScheduler.class);
    private final DeviceAdapterRegistry registry;
    private final DeviceDiscoveryPersistenceService persistence;
    public DevelopmentSimulatorDiscoveryScheduler(DeviceAdapterRegistry registry,
@@ -27,7 +28,15 @@
    public void discover()
    {
        if (!registry.contains(BtsDevelopmentSimulator.ADAPTER_CODE)) return;
        persistence.discoverForReception(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null,
                BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS));
        try
        {
            persistence.discoverForReception(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null,
                    BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS));
        }
        catch (RuntimeException exception)
        {
            persistence.markUnavailable(BtsDevelopmentSimulator.ADAPTER_CODE);
            log.warn("模拟设备观测失败,已清除过期实时值:{}", exception.getMessage());
        }
    }
}
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java
@@ -1,13 +1,9 @@
package com.battery.business.runtime;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.context.annotation.Profile;
@@ -16,9 +12,6 @@
import com.alibaba.fastjson2.JSONObject;
import com.battery.device.simulator.BtsSimulatorStateStore;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryBatch;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsPlaybackState;
import com.battery.device.api.model.DeviceWorkStatus;
/** 将开发模拟器完整状态写入 Redis,主状态不设置普通 TTL。 */
@@ -28,16 +21,9 @@
{
    static final String STATE_KEY = "dev:bts-simulator:state:v1";
    static final String RECENT_REQUESTS_KEY = "dev:bts-simulator:recent-requests:v1";
    static final String HISTORY_KEYS_KEY = "dev:bts-simulator:collector-history-keys:v1";
    static final String HISTORY_KEY_PREFIX = "dev:bts-simulator:collector-history:v1:";
    // 历史与播放位置使用独立键版本,避免把旧的三循环演示历史恢复到当前时间轴。
    static final String BTS_HISTORY_KEYS_KEY = "dev:bts-simulator:bts-history-keys:v2";
    static final String BTS_HISTORY_KEY_PREFIX = "dev:bts-simulator:bts-history:v2:";
    static final String BTS_PLAYBACK_KEY_PREFIX = "dev:bts-simulator:bts-playback:v2:";
    private static final long RECENT_REQUESTS_TTL_HOURS = 1;
    private final RedisTemplate<Object, Object> redisTemplate;
    private List<CollectorHistoryAppend> pendingEmbeddedHistory = List.of();
    public RedisBtsSimulatorStateStore(RedisTemplate<Object, Object> redisTemplate)
    {
@@ -57,236 +43,9 @@
    }
    @Override
    public SimulatorState resetHistoryForDemoStartup(SimulatorState state)
    {
        if (pendingEmbeddedHistory.isEmpty()) return state;
        java.util.Map<String, Integer> embeddedCounts = new java.util.HashMap<>();
        pendingEmbeddedHistory.forEach(append -> embeddedCounts.put(append.collectorCode(), append.records().size()));
        List<CollectorState> collectors = state.collectors().stream().map(collector -> {
            int embeddedCount = embeddedCounts.getOrDefault(collector.deviceCode(), 0);
            if (embeddedCount == 0) return collector;
            long remaining = Math.max(0L, collector.historyRecordCount() - embeddedCount);
            return new CollectorState(collector.collectorId(), collector.deviceCode(), collector.ip(), collector.mac(),
                    collector.name(), collector.manufacturer(), collector.model(), collector.channelCount(),
                    collector.online(), collector.sampleIntervalMs(), collector.nextSequence(), collector.lastSampledAt(),
                    collector.channels(), collector.latestMeasurements().stream()
                            .filter(item -> collector.deviceCode().equals(item.collectorCode()))
                            .toList(), remaining, collector.historyFaults());
        }).toList();
        // 不删除独立历史 Redis key;这里只阻止旧内嵌数据在本次启动时迁移回来。
        pendingEmbeddedHistory = List.of();
        SimulatorState cleaned = new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones());
        save(cleaned);
        return cleaned;
    }
    private SimulatorState restoreState(Object stored)
    {
        ParsedSimulatorState parsed = parseState(stored);
        // 旧格式历史先只供恢复校验读取,损坏状态不得被迁移写回覆盖。
        pendingEmbeddedHistory = parsed.embeddedHistory();
        return parsed.state();
    }
    @Override
    public void completeValidatedRestore(SimulatorState state)
    {
        if (pendingEmbeddedHistory.isEmpty()) return;
        save(state, pendingEmbeddedHistory);
        pendingEmbeddedHistory = List.of();
    }
    @Override
    public void save(SimulatorState state)
    {
        save(state, List.of());
    }
    @Override
    @SuppressWarnings({ "rawtypes", "unchecked" })
    public void save(SimulatorState state, List<CollectorHistoryAppend> historyAppends)
    {
        List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>()
        {
            @Override
            public <K, V> List<Object> execute(RedisOperations<K, V> operations)
            {
                RedisOperations<Object, Object> typed = (RedisOperations) operations;
                typed.multi();
                for (CollectorHistoryAppend append : historyAppends)
                {
                    String historyKey = historyKey(append.collectorCode());
                    if (!append.records().isEmpty())
                    {
                        List<Object> records = new ArrayList<>(
                                append.records().stream().map(JSON::toJSONString).toList());
                        typed.opsForList().rightPushAll(historyKey, records);
                        typed.opsForList().trim(historyKey, -MAX_HISTORY_RECORDS, -1);
                        typed.opsForSet().add(HISTORY_KEYS_KEY, historyKey);
                    }
                }
                typed.opsForValue().set(STATE_KEY, JSON.toJSONString(state));
                return typed.exec();
            }
        });
        if (results == null) throw new IllegalStateException("BTS simulator state transaction failed in Redis");
    }
    @Override
    public CollectorHistoryBatch loadCollectorHistoryPage(String collectorCode, long offset, int limit,
            boolean reverse)
    {
        if (offset < 0) throw new IllegalArgumentException("offset must not be negative");
        if (limit < 1 || limit > MAX_HISTORY_BATCH_SIZE)
            throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_BATCH_SIZE);
        String key = historyKey(collectorCode);
        Long storedSize = redisTemplate.opsForList().size(key);
        long redisCount = storedSize == null ? 0 : storedSize;
        List<CollectorMeasurementRecord> embedded = pendingEmbeddedHistory.stream()
                .filter(append -> append.collectorCode().equals(collectorCode))
                .findFirst().map(CollectorHistoryAppend::records).orElse(List.of());
        long total = redisCount + embedded.size();
        long from = Math.min(offset, total);
        int count = (int) Math.min(limit, total - from);
        List<CollectorMeasurementRecord> records = new ArrayList<>(count);
        if (reverse)
        {
            long lower = total - from - count;
            long upper = total - from;
            long embeddedLower = Math.max(lower, redisCount);
            for (long index = upper - 1; index >= embeddedLower; index--)
                records.add(embedded.get((int) (index - redisCount)));
            long redisUpper = Math.min(upper, redisCount);
            if (lower < redisUpper)
            {
                List<Object> stored = redisTemplate.opsForList().range(key, lower, redisUpper - 1);
                if (stored != null)
                    for (int index = stored.size() - 1; index >= 0; index--)
                        records.add(collectorMeasurementRecord(stored.get(index)));
            }
        }
        else
        {
            long upper = from + count;
            long redisUpper = Math.min(upper, redisCount);
            if (from < redisUpper)
            {
                List<Object> stored = redisTemplate.opsForList().range(key, from, redisUpper - 1);
                if (stored != null) stored.forEach(item -> records.add(collectorMeasurementRecord(item)));
            }
            long embeddedFrom = Math.max(from, redisCount);
            if (embeddedFrom < upper)
                records.addAll(embedded.subList((int) (embeddedFrom - redisCount),
                        (int) (upper - redisCount)));
        }
        if (records.size() != count)
            throw new IllegalStateException("Collector history page changed while reading Redis");
        long nextOffset = from + records.size();
        return new CollectorHistoryBatch(offset, nextOffset, nextOffset < total, total, records);
    }
    @Override
    public BtsHistoryBatch loadBtsHistoryPage(String historyKey, long offset, int limit)
    {
        if (offset < 0 || limit < 1 || limit > MAX_HISTORY_BATCH_SIZE)
            throw new IllegalArgumentException("invalid BTS history page");
        String key = BTS_HISTORY_KEY_PREFIX + historyKey;
        Long size = redisTemplate.opsForList().size(key);
        long total = size == null ? 0 : size;
        long from = Math.min(offset, total);
        int count = (int) Math.min(limit, total - from);
        List<Object> values = count == 0 ? List.of() : redisTemplate.opsForList().range(key, from, from + count - 1);
        List<BtsHistoryRecord> records = values == null ? List.of() : values.stream()
                .map(item -> JSON.parseObject(item.toString(), BtsHistoryRecord.class)).toList();
        long next = from + records.size();
        return new BtsHistoryBatch(offset, next, next < total, total, records);
    }
    @Override
    @SuppressWarnings({ "rawtypes", "unchecked" })
    public void appendBtsHistory(String historyKey, BtsHistoryRecord record)
    {
        String key = BTS_HISTORY_KEY_PREFIX + historyKey;
        RedisOperations<Object, Object> operations = redisTemplate;
        operations.opsForList().rightPush(key, JSON.toJSONString(record));
        operations.opsForList().trim(key, -MAX_HISTORY_RECORDS, -1);
        operations.opsForSet().add(BTS_HISTORY_KEYS_KEY, key);
    }
    @Override
    public BtsPlaybackState loadBtsPlaybackState(String historyKey)
    {
        Object value = redisTemplate.opsForValue().get(BTS_PLAYBACK_KEY_PREFIX + historyKey);
        return value == null ? null : JSON.parseObject(value.toString(), BtsPlaybackState.class);
    }
    @Override
    public void saveBtsPlaybackState(String historyKey, BtsPlaybackState playback)
    {
        redisTemplate.opsForValue().set(BTS_PLAYBACK_KEY_PREFIX + historyKey, JSON.toJSONString(playback));
    }
    @Override
    public void deleteBtsPlaybackState(String historyKey)
    {
        redisTemplate.delete(BTS_PLAYBACK_KEY_PREFIX + historyKey);
    }
    @Override
    public void deleteBtsHistory(String historyKey)
    {
        String key = BTS_HISTORY_KEY_PREFIX + historyKey;
        redisTemplate.delete(key);
        redisTemplate.opsForSet().remove(BTS_HISTORY_KEYS_KEY, key);
    }
    @Override
    @SuppressWarnings({ "rawtypes", "unchecked" })
    public void saveBtsPlaybackAndAppendHistory(String historyKey, BtsPlaybackState playback,
            List<BtsHistoryRecord> records)
    {
        List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>()
        {
            @Override
            public <K, V> List<Object> execute(RedisOperations<K, V> operations)
            {
                RedisOperations<Object, Object> typed = (RedisOperations) operations;
                typed.multi();
                String key = BTS_HISTORY_KEY_PREFIX + historyKey;
                if (!records.isEmpty())
                {
                    typed.opsForList().rightPushAll(key, new ArrayList<>(records.stream()
                            .map(JSON::toJSONString).toList()));
                    typed.opsForList().trim(key, -MAX_HISTORY_RECORDS, -1);
                    typed.opsForSet().add(BTS_HISTORY_KEYS_KEY, key);
                }
                typed.opsForValue().set(BTS_PLAYBACK_KEY_PREFIX + historyKey, JSON.toJSONString(playback));
                return typed.exec();
            }
        });
        if (results == null) throw new IllegalStateException("BTS simulator playback transaction failed in Redis");
    }
    @Override
    @SuppressWarnings({ "rawtypes", "unchecked" })
    public void saveAndDeleteCollectorHistory(SimulatorState state, String collectorCode)
    {
        String historyKey = historyKey(collectorCode);
        List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>()
        {
            @Override
            public <K, V> List<Object> execute(RedisOperations<K, V> operations)
            {
                RedisOperations<Object, Object> typed = (RedisOperations) operations;
                typed.multi();
                typed.delete(historyKey);
                typed.opsForSet().remove(HISTORY_KEYS_KEY, historyKey);
                typed.opsForValue().set(STATE_KEY, JSON.toJSONString(state));
                return typed.exec();
            }
        });
        if (results == null) throw new IllegalStateException("BTS simulator collector deletion failed in Redis");
        redisTemplate.opsForValue().set(STATE_KEY, JSON.toJSONString(state));
    }
    @Override
@@ -307,21 +66,11 @@
    @Override
    public void reset(SimulatorState defaultState)
    {
        pendingEmbeddedHistory = List.of();
        Set<Object> historyKeys = redisTemplate.opsForSet().members(HISTORY_KEYS_KEY);
        if (historyKeys != null && !historyKeys.isEmpty()) redisTemplate.delete(historyKeys);
        redisTemplate.delete(HISTORY_KEYS_KEY);
        Set<Object> btsHistoryKeys = redisTemplate.opsForSet().members(BTS_HISTORY_KEYS_KEY);
        if (btsHistoryKeys != null && !btsHistoryKeys.isEmpty()) redisTemplate.delete(btsHistoryKeys);
        redisTemplate.delete(BTS_HISTORY_KEYS_KEY);
        // 播放状态按通道独立保存,重置演示时必须一并清理。
        Set<Object> playbackKeys = redisTemplate.keys(BTS_PLAYBACK_KEY_PREFIX + "*");
        if (playbackKeys != null && !playbackKeys.isEmpty()) redisTemplate.delete(playbackKeys);
        redisTemplate.delete(RECENT_REQUESTS_KEY);
        save(defaultState);
    }
    private ParsedSimulatorState parseState(Object stored)
    private SimulatorState restoreState(Object stored)
    {
        try
        {
@@ -333,20 +82,13 @@
            List<DeviceState> devices = new ArrayList<>();
            requiredArray(root, "devices").forEach(item -> devices.add(deviceState(item)));
            List<CollectorState> collectors = new ArrayList<>();
            List<CollectorHistoryAppend> embeddedHistory = new ArrayList<>();
            requiredArray(root, "collectors").forEach(item -> {
                JSONObject collectorObject = jsonObject(item);
                List<CollectorMeasurementRecord> history = embeddedCollectorHistory(collectorObject);
                CollectorState collector = collectorState(collectorObject, history);
                collectors.add(collector);
                if (!history.isEmpty())
                    embeddedHistory.add(new CollectorHistoryAppend(collector.deviceCode(), history));
            });
            requiredArray(root, "collectors").forEach(item -> collectors.add(collectorState(jsonObject(item))));
            List<ChannelTombstone> tombstones = new ArrayList<>();
            requiredArray(root, "tombstones").forEach(item -> tombstones.add(channelTombstone(item)));
            SimulatorState state = new SimulatorState(version, devices, collectors,
                    requiredInteger(root, "nextDeviceId"), requiredInteger(root, "nextCollectorId"), tombstones);
            return new ParsedSimulatorState(state, embeddedHistory);
                    requiredInteger(root, "nextDeviceId"), requiredInteger(root, "nextCollectorId"), tombstones,
                    root.getBoolean("softwareAccessible"), root.getLongValue("observationSequence"));
            return state;
        }
        catch (IllegalStateException exception)
        {
@@ -406,46 +148,18 @@
                requiredDecimal(object, "voltageMaxV"), requiredInteger(object, "sampleIntervalMs"));
    }
    private List<CollectorMeasurementRecord> embeddedCollectorHistory(JSONObject object)
    private CollectorState collectorState(JSONObject object)
    {
        JSONArray values = object.getJSONArray("history");
        if (values == null) return List.of();
        List<CollectorMeasurementRecord> history = new ArrayList<>(values.size());
        values.forEach(item -> history.add(collectorMeasurementRecord(item)));
        return List.copyOf(history);
    }
    private CollectorState collectorState(JSONObject object, List<CollectorMeasurementRecord> embeddedHistory)
    {
        int collectorId = requiredInteger(object, "collectorId");
        String deviceCode = object.getString("deviceCode");
        String ip = object.getString("ip");
        String mac = object.getString("mac");
        String name = object.getString("name");
        String manufacturer = object.getString("manufacturer");
        String model = object.getString("model");
        int channelCount = requiredInteger(object, "channelCount");
        boolean online = object.getBooleanValue("online");
        // 仅含发现配置的旧状态首次恢复时,以演示默认值补齐采样状态。
        if (!object.containsKey("sampleIntervalMs"))
            return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model,
                    channelCount, online);
        int count = requiredInteger(object, "channelCount");
        List<CollectorChannelState> channels = new ArrayList<>();
        requiredArray(object, "channels").forEach(item -> channels.add(collectorChannelState(item)));
        List<CollectorRealtimeMeasurement> latestMeasurements = new ArrayList<>();
        JSONArray latest = object.getJSONArray("latestMeasurements");
        if (latest != null)
            latest.forEach(item -> latestMeasurements.add(collectorRealtimeMeasurement(item)));
        else
            latestMeasurements.addAll(latestMeasurementsFromLegacyHistory(embeddedHistory, channels));
        long historyRecordCount = object.containsKey("historyRecordCount")
                ? requiredLong(object, "historyRecordCount") : embeddedHistory.size();
        return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model,
                channelCount, online, requiredInteger(object, "sampleIntervalMs"),
                requiredLong(object, "nextSequence"), optionalInstant(object, "lastSampledAt"),
                channels, latestMeasurements, historyRecordCount,
                collectorHistoryFaults(object.get("historyFaults")));
        JSONArray configured = object.getJSONArray("channels");
        if (configured == null) channels.addAll(com.battery.device.simulator.DevelopmentCollectorSimulation.defaultChannels(count));
        else configured.forEach(item -> channels.add(collectorChannelState(item)));
        // 重启只恢复配置,不把上次响应或已废止的历史作为新观测。
        return new CollectorState(requiredInteger(object, "collectorId"), object.getString("deviceCode"),
                object.getString("ip"), object.getString("mac"), object.getString("name"),
                object.getString("manufacturer"), object.getString("model"), count, object.getBooleanValue("online"),
                com.battery.device.simulator.DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, channels, List.of());
    }
    private CollectorChannelState collectorChannelState(Object value)
@@ -453,7 +167,9 @@
        JSONObject object = jsonObject(value);
        List<CollectorMetricState> metrics = new ArrayList<>();
        requiredArray(object, "metrics").forEach(item -> metrics.add(collectorMetricState(item)));
        return new CollectorChannelState(object.getString("channelCode"), metrics);
        return new CollectorChannelState(object.getString("channelCode"), metrics,
                object.getString("workStatus") == null ? DeviceWorkStatus.WORKING
                        : DeviceWorkStatus.valueOf(object.getString("workStatus")));
    }
    private CollectorMetricState collectorMetricState(Object value)
@@ -462,60 +178,6 @@
        BigDecimal measurement = object.getBigDecimal("value");
        if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value");
        return new CollectorMetricState(object.getString("metricCode"), measurement, object.getString("unit"));
    }
    private CollectorMeasurementRecord collectorMeasurementRecord(Object value)
    {
        JSONObject object = jsonObject(value);
        BigDecimal measurement = object.getBigDecimal("value");
        if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value");
        return new CollectorMeasurementRecord(object.getString("adapterCode"), object.getString("collectorCode"),
                object.getString("channelCode"), requiredLong(object, "sequenceId"),
                requiredInstant(object, "deviceTime"), object.getString("deviceTimeRaw"),
                requiredInstant(object, "serverReceivedAt"), object.getString("metricCode"),
                measurement, object.getString("unit"));
    }
    private CollectorRealtimeMeasurement collectorRealtimeMeasurement(Object value)
    {
        JSONObject object = jsonObject(value);
        BigDecimal measurement = object.getBigDecimal("value");
        if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value");
        return new CollectorRealtimeMeasurement(object.getString("adapterCode"), object.getString("collectorCode"),
                object.getString("channelCode"), requiredLong(object, "sequenceId"),
                requiredInstant(object, "deviceTime"), object.getString("deviceTimeRaw"),
                requiredInstant(object, "serverReceivedAt"), object.getString("metricCode"),
                measurement, object.getString("unit"));
    }
    /** 从尚未拆分快照模型的旧状态中提取最新测量值。 */
    private List<CollectorRealtimeMeasurement> latestMeasurementsFromLegacyHistory(
            List<CollectorMeasurementRecord> history, List<CollectorChannelState> channels)
    {
        java.util.Set<String> configured = new java.util.HashSet<>();
        channels.forEach(channel -> channel.metrics().forEach(metric ->
                configured.add(channel.channelCode() + "\u0000" + metric.metricCode())));
        java.util.Map<String, CollectorRealtimeMeasurement> latest = new java.util.LinkedHashMap<>();
        for (CollectorMeasurementRecord record : history)
        {
            String key = record.channelCode() + "\u0000" + record.metricCode();
            if (configured.contains(key))
                latest.put(key, new CollectorRealtimeMeasurement(record.adapterCode(), record.collectorCode(),
                        record.channelCode(), record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(),
                        record.serverReceivedAt(), record.metricCode(), record.value(), record.unit()));
        }
        return List.copyOf(latest.values());
    }
    private CollectorHistoryFaults collectorHistoryFaults(Object value)
    {
        if (value == null) return CollectorHistoryFaults.none();
        JSONObject object = jsonObject(value);
        List<Long> missing = new ArrayList<>();
        JSONArray values = object.getJSONArray("missingSequences");
        if (values != null) values.forEach(item -> missing.add(Long.valueOf(item.toString())));
        return new CollectorHistoryFaults(missing, object.getLong("duplicateSequence"),
                object.getLong("conflictingSequence"), object.getBooleanValue("outOfOrder"));
    }
    private ChannelState channelState(Object value)
@@ -534,13 +196,6 @@
                requiredInteger(object, "channelId"), requiredInteger(object, "remainingDiscoveries"));
    }
    private long requiredLong(JSONObject object, String field)
    {
        Long value = object.getLong(field);
        if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field);
        return value;
    }
    private BigDecimal requiredDecimal(JSONObject object, String field)
    {
        BigDecimal value = object.getBigDecimal(field);
@@ -548,26 +203,5 @@
        return value;
    }
    private Instant requiredInstant(JSONObject object, String field)
    {
        Instant value = optionalInstant(object, field);
        if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field);
        return value;
    }
    private Instant optionalInstant(JSONObject object, String field)
    {
        String value = object.getString(field);
        return value == null ? null : Instant.parse(value);
    }
    private String historyKey(String collectorCode)
    {
        return HISTORY_KEY_PREFIX + collectorCode;
    }
    private record ParsedSimulatorState(SimulatorState state, List<CollectorHistoryAppend> embeddedHistory)
    {
        private ParsedSimulatorState { embeddedHistory = List.copyOf(embeddedHistory); }
    }
}
ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java
@@ -12,7 +12,6 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.beans.factory.annotation.Autowired;
@@ -22,11 +21,8 @@
import com.battery.device.simulator.BtsDevelopmentSimulator.StateSnapshot;
import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
import com.battery.device.simulator.BtsDevelopmentSimulator.PlaybackSnapshot;
/** 本机临时 BTS 模拟器控制 API。 */
@Anonymous
@@ -69,7 +65,7 @@
        return simulator.snapshot();
    }
    /** 人工清空长期状态并恢复一台默认检测柜和采集器。 */
    /** 人工清空模拟配置并恢复默认检测柜和采集器。 */
    @PostMapping("/dev/bts-simulator/reset")
    public StateSnapshot reset()
    {
@@ -171,24 +167,6 @@
        }
    }
    /** 配置采集器采样频率和可重复的历史下载故障场景。 */
    @PutMapping("/dev/bts-simulator/collectors/{collectorId}/sampling")
    public StateSnapshot configureCollectorSampling(@PathVariable int collectorId,
            @RequestBody CollectorSamplingCommand command)
    {
        try
        {
            simulator.configureCollectorSampling(collectorId, command.sampleIntervalMs(),
                    new CollectorHistoryFaults(command.missingSequences(), command.duplicateSequence(),
                            command.conflictingSequence(), command.outOfOrder()));
            return simulator.snapshot();
        }
        catch (IllegalArgumentException exception)
        {
            throw badRequest(exception);
        }
    }
    /** 配置稳定 S 通道的固定和动态测量项。 */
    @PutMapping("/dev/bts-simulator/collectors/{collectorId}/channels/{channelCode}")
    public StateSnapshot configureCollectorChannel(@PathVariable int collectorId,
@@ -208,28 +186,13 @@
        }
    }
    /** 查询采集器最新实时快照;离线时保留最后观测并明确返回离线事实。 */
    /** 请求采集器当前实时快照;不可访问或离线时返回空测量。 */
    @GetMapping("/dev/bts-simulator/collectors/{collectorId}/realtime")
    public CollectorRealtimeSnapshot collectorRealtime(@PathVariable int collectorId)
    {
        try
        {
            return simulator.collectorRealtime(collectorId);
        }
        catch (IllegalArgumentException exception)
        {
            throw badRequest(exception);
        }
    }
    /** 按偏移量分页下载采集器模拟历史,每页最多 1000 条。 */
    @GetMapping("/dev/bts-simulator/collectors/{collectorId}/history")
    public CollectorHistoryPage collectorHistory(@PathVariable int collectorId,
            @RequestParam(defaultValue = "0") long offset, @RequestParam(defaultValue = "100") int limit)
    {
        try
        {
            return simulator.collectorHistory(collectorId, offset, limit);
        }
        catch (IllegalArgumentException exception)
        {
@@ -268,95 +231,27 @@
        }
    }
    /** 返回检测柜通道播放、record、step 和 cycle 的当前快照。 */
    @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback")
    public PlaybackSnapshot btsPlayback(@PathVariable int deviceId, @PathVariable int channelId)
    /** 修改软件可访问性,设备配置继续保留。 */
    @PutMapping("/dev/bts-simulator/software")
    public StateSnapshot software(@RequestBody SoftwareCommand command)
    {
        try { return simulator.btsPlayback(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
        if (command == null || command.accessible() == null)
            throw badRequest(new IllegalArgumentException("accessible must be true or false"));
        simulator.setSoftwareAccessible(command.accessible());
        synchronizeOnlineStatusIfConfigured();
        return simulator.snapshot();
    }
    @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/records")
    public List<com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord> btsRecords(
            @PathVariable int deviceId, @PathVariable int channelId)
    {
        try { return simulator.btsRecords(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/steps")
    public List<BtsDevelopmentSimulator.BtsStepRecord> btsSteps(
            @PathVariable int deviceId, @PathVariable int channelId)
    {
        try { return simulator.btsSteps(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @GetMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/cycles")
    public List<BtsDevelopmentSimulator.BtsCycleRecord> btsCycles(
            @PathVariable int deviceId, @PathVariable int channelId)
    {
        try { return simulator.btsCycles(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    /** 设置检测柜通道演示倍速。 */
    @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback")
    public PlaybackSnapshot configureBtsPlayback(@PathVariable int deviceId, @PathVariable int channelId,
            @RequestBody PlaybackCommand command)
    /** 修改采集器通道当前工作状态。 */
    @PutMapping("/dev/bts-simulator/collectors/{collectorId}/channels/{channelCode}/status")
    public StateSnapshot collectorChannelStatus(@PathVariable int collectorId, @PathVariable String channelCode,
            @RequestBody CollectorChannelStatusCommand command)
    {
        try
        {
            if (command.speedMultiplier() != null) simulator.setBtsSpeed(deviceId, channelId, command.speedMultiplier());
            if (command.playing() != null)
            {
                if (command.playing()) simulator.playBts(deviceId, channelId);
                else simulator.pauseBts(deviceId, channelId);
            }
            return simulator.btsPlayback(deviceId, channelId);
            simulator.updateCollectorChannelStatus(collectorId, channelCode, command.workStatus());
            return simulator.snapshot();
        }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/play")
    public PlaybackSnapshot playBts(@PathVariable int deviceId, @PathVariable int channelId)
    {
        try { simulator.playBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/pause")
    public PlaybackSnapshot pauseBts(@PathVariable int deviceId, @PathVariable int channelId)
    {
        try { simulator.pauseBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/resume")
    public PlaybackSnapshot resumeBts(@PathVariable int deviceId, @PathVariable int channelId)
    {
        try { simulator.resumeBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/next-step")
    public PlaybackSnapshot nextBtsStep(@PathVariable int deviceId, @PathVariable int channelId)
    {
        try { simulator.nextBtsStep(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/next-cycle")
    public PlaybackSnapshot nextBtsCycle(@PathVariable int deviceId, @PathVariable int channelId)
    {
        try { simulator.nextBtsCycle(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
    @PostMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/playback/reset")
    public PlaybackSnapshot resetBts(@PathVariable int deviceId, @PathVariable int channelId)
    {
        try { simulator.resetBts(deviceId, channelId); return simulator.btsPlayback(deviceId, channelId); }
        catch (IllegalArgumentException exception) { throw badRequest(exception); }
    }
@@ -394,18 +289,11 @@
    public record CollectorStateCommand(String ip, int channelCount, boolean online) { }
    public record CollectorSamplingCommand(int sampleIntervalMs, List<Long> missingSequences,
            Long duplicateSequence, Long conflictingSequence, boolean outOfOrder)
    {
        public CollectorSamplingCommand
        {
            missingSequences = missingSequences == null ? List.of() : List.copyOf(missingSequences);
        }
    }
    public record CollectorChannelCommand(List<CollectorMetricCommand> metrics) { }
    public record CollectorMetricCommand(String metricCode, BigDecimal value, String unit) { }
    public record PlaybackCommand(Integer speedMultiplier, Boolean playing) { }
    public record SoftwareCommand(Boolean accessible) { }
    public record CollectorChannelStatusCommand(DeviceWorkStatus workStatus) { }
}
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -24,9 +24,9 @@
    .status::before { width: 8px; height: 8px; content: ""; border-radius: 50%; background: #c43d4b; }
    .status.online::before { background: #218a5b; }
    .add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; }
    .device-settings { grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; }
    .collector-settings { display: grid; grid-template-columns: minmax(160px, 1fr) 110px 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; }
    .sampling-settings { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; gap: 12px; align-items: end; padding: 0 16px 16px; }
    .device-settings { grid-template-columns: minmax(130px, 1fr) 90px minmax(150px, 1fr) 100px auto auto; }
    .collector-settings { display: grid; grid-template-columns: minmax(130px, 1fr) 90px 110px auto auto; gap: 12px; align-items: end; padding: 16px; }
    label { color: #5e6a73; font-size: 12px; }
    label > span { display: block; margin-bottom: 6px; }
    input[type="text"], input[type="number"], select, textarea { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
@@ -49,7 +49,7 @@
    td select { width: 150px; }
    .request-time { width: 185px; font: 12px Consolas, monospace; color: #53616b; }
    .request-command { width: 130px; font: 12px Consolas, monospace; font-weight: 600; }
    .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
    .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: pre-wrap; overflow-wrap: anywhere; }
    .collector-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #e3e9ed; }
    .collector-output { min-height: 46px; margin: 0 16px 16px; padding: 10px; overflow: auto; color: #34434d; background: #f7f9fa; border: 1px solid #d5dde3; font: 11px Consolas, monospace; white-space: pre-wrap; }
    .empty { height: 96px; color: #7d8992; text-align: center; }
@@ -60,10 +60,12 @@
      main { width: calc(100% - 20px); margin-top: 12px; }
      .summary { grid-template-columns: 1fr; }
      .summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; }
      .add-form, .device-settings, .collector-settings, .sampling-settings { grid-template-columns: 1fr 1fr; }
      .add-form, .device-settings, .collector-settings { grid-template-columns: 1fr 1fr; }
      .device-settings button, .collector-settings button { width: 100%; }
      .device { margin: 10px; overflow-x: auto; }
      .device table { min-width: 520px; }
      .device table { min-width: 620px; }
      .section-title, .device-title { flex-wrap: wrap; padding: 10px; }
      .device-title .label { overflow-wrap: anywhere; }
      th, td { padding: 8px; }
      .request-time { width: 130px; }
      .request-command { width: 90px; }
@@ -76,10 +78,11 @@
    <div id="error" class="error"></div>
    <section>
      <div class="section-title">
        <h2>运行状态</h2>
        <h2>软件连接</h2>
        <div class="power-actions">
          <button id="power-on" type="button">一键模拟开机</button>
          <button id="power-off" class="danger" type="button">一键模拟关机</button>
          <label class="switch-label"><input id="software-accessible" type="checkbox"><span>新威软件可访问</span></label>
          <button id="power-on" type="button">全部设备上线</button>
          <button id="power-off" class="danger" type="button">全部设备离线</button>
          <button id="reset-simulator" class="danger" type="button">重置模拟器</button>
        </div>
      </div>
@@ -119,14 +122,14 @@
    <section>
      <div class="section-title"><h2>最近请求</h2><span id="request-count" class="label">0 条</span></div>
      <table>
        <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 XML</th></tr></thead>
        <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 / 响应</th></tr></thead>
        <tbody id="requests"></tbody>
      </table>
    </section>
  </main>
  <script>
    const api = '/dev/bts-simulator';
    const labels = { STOPPED: '空闲', WORKING: '工作中', PAUSED: '暂停', FINISHED: '完成' };
    const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' };
    const errorBox = document.querySelector('#error');
    async function request(path, options) {
@@ -157,18 +160,17 @@
        collectors: Array.isArray(state.collectors) ? state.collectors : [],
        recentRequests: Array.isArray(state.recentRequests) ? state.recentRequests : [],
      };
      document.querySelector('#software-accessible').checked = state.softwareAccessible;
      const runtime = document.querySelector('#runtime');
      runtime.textContent = state.running ? '运行中' : '已停止';
      runtime.classList.toggle('online', state.running);
      const devicesPoweredOn = state.devices.length + state.collectors.length > 0
        && state.devices.every(device => device.online
          && device.channels.every(channel => channel.online))
        && state.devices.every(device => device.online)
        && state.collectors.every(collector => collector.online);
      const devicesPoweredOff = state.devices.every(device => !device.online
          && device.channels.every(channel => !channel.online && channel.workStatus === 'STOPPED'))
      const devicesPoweredOff = state.devices.every(device => !device.online)
        && state.collectors.every(collector => !collector.online);
      const devicePower = document.querySelector('#device-power');
      devicePower.textContent = devicesPoweredOn ? '已开机' : devicesPoweredOff ? '已关机' : '部分开启';
      devicePower.textContent = devicesPoweredOn ? '全部在线' : devicesPoweredOff ? '全部离线' : '部分在线';
      devicePower.classList.toggle('online', devicesPoweredOn);
      document.querySelector('#power-on').disabled = devicesPoweredOn;
      document.querySelector('#power-off').disabled = devicesPoweredOff;
@@ -198,7 +200,7 @@
        heading.textContent = `检测柜 ${device.deviceId}`;
        const identity = document.createElement('span');
        identity.className = 'label';
        identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / ${device.profile.model}`;
        identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / subdevid ${device.profile.subDeviceId} / ${device.profile.model}`;
        title.append(heading, identity);
        const settings = document.createElement('div');
@@ -240,7 +242,7 @@
        settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove);
        const table = document.createElement('table');
        table.innerHTML = '<thead><tr><th style="width:18%">通道</th><th style="width:22%">在线状态</th><th style="width:20%">工作状态</th><th>模拟播放</th></tr></thead>';
        table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th></tr></thead>';
        const body = document.createElement('tbody');
        body.append(...device.channels.map(channel => channelRow(device.deviceId, channel)));
        table.append(body);
@@ -276,9 +278,6 @@
        const channelCount = field('通道数', 'number', collector.channelCount);
        channelCount.input.min = '1';
        channelCount.input.max = '99';
        const sampleInterval = field('采样间隔(毫秒)', 'number', collector.sampleIntervalMs);
        sampleInterval.input.min = '100';
        sampleInterval.input.max = '60000';
        const onlineLabel = document.createElement('label');
        onlineLabel.className = 'switch-label';
        const online = document.createElement('input');
@@ -293,8 +292,7 @@
        save.textContent = '保存配置';
        save.addEventListener('click', async () => {
          try {
            await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) });
            render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), ...collector.historyFaults }) }));
            render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) }));
          }
          catch (error) { showError(error); }
        });
@@ -306,33 +304,10 @@
          try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); }
          catch (error) { showError(error); }
        });
        settings.append(ip.label, channelCount.label, sampleInterval.label, onlineLabel, save, remove);
        const faults = document.createElement('div');
        faults.className = 'sampling-settings';
        const missing = field('缺失序号(逗号分隔)', 'text', collector.historyFaults.missingSequences.join(','));
        const duplicate = field('重复序号', 'number', collector.historyFaults.duplicateSequence || '');
        const conflict = field('冲突序号', 'number', collector.historyFaults.conflictingSequence || '');
        const outOfOrderLabel = document.createElement('label');
        outOfOrderLabel.className = 'switch-label';
        const outOfOrder = document.createElement('input');
        outOfOrder.type = 'checkbox';
        outOfOrder.checked = collector.historyFaults.outOfOrder;
        const outOfOrderText = document.createElement('span');
        outOfOrderText.textContent = '历史乱序';
        outOfOrderLabel.append(outOfOrder, outOfOrderText);
        const saveFaults = document.createElement('button');
        saveFaults.type = 'button';
        saveFaults.textContent = '保存历史场景';
        saveFaults.addEventListener('click', async () => {
          const missingSequences = missing.input.value.split(',').map(value => value.trim()).filter(Boolean).map(Number);
          try { render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), missingSequences, duplicateSequence: numberOrNull(duplicate.input.value), conflictingSequence: numberOrNull(conflict.input.value), outOfOrder: outOfOrder.checked }) })); }
          catch (error) { showError(error); }
        });
        faults.append(missing.label, duplicate.label, conflict.label, outOfOrderLabel, saveFaults);
        settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
        const table = document.createElement('table');
        table.innerHTML = '<thead><tr><th style="width:110px">稳定通道</th><th>测量项(每行:指标编码=值 单位)</th><th style="width:130px">操作</th></tr></thead>';
        table.innerHTML = '<thead><tr><th style="width:100px">稳定通道</th><th style="width:170px">工作状态</th><th>测量项</th><th style="width:130px">操作</th></tr></thead>';
        const channelBody = document.createElement('tbody');
        channelBody.append(...collector.channels.map(channel => collectorChannelRow(collector.collectorId, channel)));
        table.append(channelBody);
@@ -342,29 +317,17 @@
        const realtimeButton = document.createElement('button');
        realtimeButton.type = 'button';
        realtimeButton.textContent = '查看实时';
        const historyButton = document.createElement('button');
        historyButton.type = 'button';
        historyButton.textContent = '下载历史';
        const historyCount = document.createElement('span');
        historyCount.className = 'label';
        historyCount.textContent = `原始历史 ${collector.historyRecordCount} 条`;
        const output = document.createElement('pre');
        output.className = 'collector-output';
        output.textContent = collector.latestMeasurements.length
          ? measurementLines(collector.latestMeasurements)
          : '等待第一条采样';
          : '暂无成功观测';
        realtimeButton.addEventListener('click', async () => {
          try { output.textContent = measurementLines((await request(`/collectors/${collector.collectorId}/realtime`)).measurements); }
          catch (error) { showError(error); }
        });
        historyButton.addEventListener('click', async () => {
          try {
            const page = await request(`/collectors/${collector.collectorId}/history?offset=0&limit=20`);
            output.textContent = `历史 ${page.total} 条 / 本页 ${page.records.length} 条\n${measurementLines(page.records)}`;
          } catch (error) { showError(error); }
        });
        actions.append(realtimeButton, historyButton, historyCount);
        panel.append(title, settings, faults, table, actions, output);
        actions.append(realtimeButton);
        panel.append(title, settings, table, actions, output);
        return panel;
      }));
    }
@@ -387,7 +350,17 @@
        catch (error) { showError(error); }
      });
      action.append(save);
      row.append(identity, metricsCell, action);
      const statusCell = document.createElement('td');
      const status = document.createElement('select');
      status.setAttribute('aria-label', channel.channelCode + ' 工作状态');
      Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value)));
      status.value = channel.workStatus;
      status.addEventListener('change', async () => {
        try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}/status`, { method: 'PUT', body: JSON.stringify({ workStatus: status.value }) })); }
        catch (error) { showError(error); }
      });
      statusCell.append(status);
      row.append(identity, statusCell, metricsCell, action);
      return row;
    }
@@ -402,10 +375,6 @@
    function measurementLines(items) {
      if (!items.length) return '暂无测量';
      return items.map(item => `${item.collectorCode} #${item.sequenceId} ${item.channelCode} ${item.metricCode}=${item.value} ${item.unit} 设备 ${item.deviceTimeRaw} / 接收 ${item.serverReceivedAt}`).join('\n');
    }
    function numberOrNull(value) {
      return value.trim() ? Number(value) : null;
    }
    function field(labelText, type, value) {
@@ -437,6 +406,7 @@
      const status = document.createElement('select');
      Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value)));
      status.value = channel.workStatus;
      status.setAttribute('aria-label', `检测柜 ${deviceId} 通道 ${channel.channelId} 工作状态`);
      const update = async () => {
        try { render(await request(`/devices/${deviceId}/channels/${channel.channelId}`, { method: 'PUT', body: JSON.stringify({ online: online.checked, workStatus: status.value }) })); }
        catch (error) { showError(error); await refresh(); }
@@ -444,16 +414,7 @@
      online.addEventListener('change', update);
      status.addEventListener('change', update);
      statusCell.append(status);
      const playbackCell = document.createElement('td');
      const speed = document.createElement('select');
      [['1','1x'], ['60','60x'], ['600','600x']].forEach(([value, label]) => speed.add(new Option(label, value)));
      const controls = document.createElement('span');
      controls.style.cssText = 'display:inline-flex;gap:5px;margin-left:6px';
      const action = (label, path) => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback/${path}`, { method: 'POST' }); await refresh(); } catch (error) { showError(error); } }); return button; };
      speed.addEventListener('change', async () => { try { await request(`/devices/${deviceId}/channels/${channel.channelId}/playback`, { method: 'PUT', body: JSON.stringify({ speedMultiplier: Number(speed.value) }) }); await refresh(); } catch (error) { showError(error); } });
      controls.append(action('播放', 'play'), action('暂停', 'pause'), action('下一工步', 'next-step'), action('下一循环', 'next-cycle'), action('重置', 'reset'));
      playbackCell.append(speed, controls);
      row.append(identity, onlineCell, statusCell, playbackCell);
      row.append(identity, onlineCell, statusCell);
      return row;
    }
@@ -480,8 +441,8 @@
        command.textContent = item.command;
        const xml = document.createElement('td');
        xml.className = 'request-xml';
        xml.textContent = item.request;
        xml.title = item.request;
        xml.textContent = `${item.request}\n${item.response || '暂无响应'}`;
        xml.title = xml.textContent;
        row.append(time, command, xml);
        return row;
      }));
@@ -497,13 +458,18 @@
      try { render(await request('/reset', { method: 'POST' })); } catch (error) { showError(error); }
    });
    document.getElementById('software-accessible').addEventListener('change', async event => {
      try { render(await request('/software', { method: 'PUT', body: JSON.stringify({ accessible: event.target.checked }) })); }
      catch (error) { showError(error); }
    });
    document.getElementById('power-on').addEventListener('click', async () => {
      try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: true }) })); }
      catch (error) { showError(error); }
    });
    document.getElementById('power-off').addEventListener('click', async () => {
      if (!confirm('确认关闭全部模拟检测柜和采集器?已有历史会保留,停止增长。')) return;
      if (!confirm('确认将全部模拟检测柜和采集器设为离线?')) return;
      try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: false }) })); }
      catch (error) { showError(error); }
    });
ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java
@@ -61,7 +61,7 @@
                List.of(new CollectorState(3, "dev-sim-collector-3", "127.0.1.3",
                        "02:43:00:00:00:03", "模拟采集器", "开发模拟器",
                        "Virtual Collector", 3, true)),
                3, 4, List.of(new ChannelTombstone(1, "127.0.0.2", 4, 2)));
                3, 4, List.of(new ChannelTombstone(1, "127.0.0.2", 4, 2)), false, 900L);
        FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
        AtomicReference<Object> stored = new AtomicReference<>(
                serializer.deserialize(serializer.serialize(JSON.toJSONString(expected))));
@@ -86,70 +86,8 @@
        CollectorState collector = restored.collectors().get(0);
        assertEquals(1000, collector.sampleIntervalMs());
        assertEquals(1L, collector.nextSequence());
        assertEquals(List.of("S-01", "S-02"),
                collector.channels().stream().map(channel -> channel.channelCode()).toList());
        assertEquals(0L, collector.historyRecordCount());
    }
    @Test
    void migratesEmbeddedCollectorHistoryToAppendOnlyRedisList()
    {
        AtomicReference<Object> stored = new AtomicReference<>("{\"version\":1,\"devices\":[],"
                + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\","
                + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\","
                + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\","
                + "\"model\":\"Virtual Collector\",\"channelCount\":1,\"online\":true,"
                + "\"sampleIntervalMs\":1000,\"nextSequence\":2,"
                + "\"lastSampledAt\":\"2026-08-29T05:00:00Z\","
                + "\"channels\":[{\"channelCode\":\"S-01\",\"metrics\":[{"
                + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]}],"
                + "\"history\":[{\"adapterCode\":\"dev-sim\","
                + "\"collectorCode\":\"dev-sim-collector-1\",\"channelCode\":\"S-01\","
                + "\"sequenceId\":1,\"deviceTime\":\"2026-08-29T05:00:00Z\","
                + "\"deviceTimeRaw\":\"2026-08-29 13:00:00.000\","
                + "\"serverReceivedAt\":\"2026-08-29T05:00:00Z\","
                + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}],"
                + "\"historyFaults\":{\"missingSequences\":[],\"outOfOrder\":false}}],"
                + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}");
        RedisBtsSimulatorStateStore store = store(stored);
        SimulatorState restored = store.loadOrInitialize(state(1, "127.0.0.2"));
        assertEquals(1L, restored.collectors().get(0).historyRecordCount());
        assertEquals(1, store.loadCollectorHistoryPage("dev-sim-collector-1", 0, 1000, false)
                .records().size());
        assertEquals(true, stored.get().toString().contains("\"history\":[{"));
        store.completeValidatedRestore(restored);
        assertEquals(false, stored.get().toString().contains("\"history\":[{"));
    }
    @Test
    void clearsLegacyEmbeddedHistoryForDemoStartup() throws Exception
    {
        AtomicReference<Object> stored = new AtomicReference<>("{\"version\":1,\"devices\":[],"
                + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\","
                + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\","
                + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\","
                + "\"model\":\"Virtual Collector\",\"channelCount\":1,\"online\":true,"
                + "\"sampleIntervalMs\":1000,\"nextSequence\":2,"
                + "\"lastSampledAt\":\"2026-08-29T05:00:00Z\","
                + "\"channels\":[{\"channelCode\":\"S-01\",\"metrics\":[{"
                + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}]}],"
                + "\"history\":[{\"adapterCode\":\"dev-sim\","
                + "\"collectorCode\":\"wrong-collector\",\"channelCode\":\"S-01\","
                + "\"sequenceId\":1,\"deviceTime\":\"2026-08-29T05:00:00Z\","
                + "\"deviceTimeRaw\":\"2026-08-29 13:00:00.000\","
                + "\"serverReceivedAt\":\"2026-08-29T05:00:00Z\","
                + "\"metricCode\":\"temperature\",\"value\":25.1,\"unit\":\"℃\"}],"
                + "\"historyFaults\":{\"missingSequences\":[],\"outOfOrder\":false}}],"
                + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}");
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store(stored)))
        {
            assertEquals(0L, simulator.snapshot().collectors().get(0).historyRecordCount());
        }
        assertEquals(false, stored.get().toString().contains("\"history\":[{"));
    }
    @Test
@@ -164,58 +102,6 @@
        assertEquals("Unsupported BTS simulator state version: 2", exception.getMessage());
        assertEquals(true, stored.get().toString().contains("\"version\":2"));
    }
    @Test
    void atomicallySavesCollectorDeletionAndRemovesIndependentHistory()
    {
        AtomicReference<Object> stored = new AtomicReference<>();
        RedisBtsSimulatorStateStore store = store(stored);
        SimulatorState current = state(1, "127.0.0.2");
        store.loadOrInitialize(current);
        var record = new com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord(
                "dev-sim", "dev-sim-collector-1", "S-01", 1L,
                java.time.Instant.parse("2026-08-29T05:00:00Z"), "2026-08-29 13:00:00.000",
                java.time.Instant.parse("2026-08-29T05:00:00Z"), "temperature",
                new java.math.BigDecimal("25.1"), "℃");
        store.save(current, List.of(
                new com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend(
                        "dev-sim-collector-1", List.of(record))));
        SimulatorState next = state(7, "127.0.0.9");
        store.saveAndDeleteCollectorHistory(next, "dev-sim-collector-1");
        assertEquals(next, store.loadOrInitialize(current));
        assertEquals(List.of(), store.loadCollectorHistoryPage("dev-sim-collector-1", 0, 1000, false)
                .records());
    }
    @Test
    void readsOnlyRequestedCollectorHistoryRange()
    {
        AtomicReference<Object> stored = new AtomicReference<>();
        RedisTemplate<Object, Object> redis = redis(stored);
        RedisBtsSimulatorStateStore store = new RedisBtsSimulatorStateStore(redis);
        SimulatorState current = state(1, "127.0.0.2");
        store.loadOrInitialize(current);
        var record = new com.battery.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord(
                "dev-sim", "dev-sim-collector-1", "S-01", 1L,
                java.time.Instant.parse("2026-08-29T05:00:00Z"), "2026-08-29 13:00:00.000",
                java.time.Instant.parse("2026-08-29T05:00:00Z"), "temperature",
                new java.math.BigDecimal("25.1"), "℃");
        store.save(current, List.of(
                new com.battery.device.simulator.BtsSimulatorStateStore.CollectorHistoryAppend(
                        "dev-sim-collector-1", java.util.Collections.nCopies(1010, record))));
        ListOperations<Object, Object> lists = redis.opsForList();
        clearInvocations(lists);
        var page = store.loadCollectorHistoryPage("dev-sim-collector-1", 1000, 10, false);
        assertEquals(1010, page.total());
        assertEquals(10, page.records().size());
        assertEquals(false, page.hasMore());
        verify(lists).range(RedisBtsSimulatorStateStore.HISTORY_KEY_PREFIX + "dev-sim-collector-1",
                1000L, 1009L);
    }
    @Test
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java
@@ -20,6 +20,36 @@
class DeviceDiscoveryPersistenceServiceTest
{
    @Test
    void failedDiscoveryInvalidatesSuccessfulSnapshotUntilSoftwareReturns() throws Exception
    {
        try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0);
             var registry = new DeviceAdapterRegistry(List.of(new com.battery.device.bts.BtsDeviceAdapterFactory())))
        {
            registry.synchronize(new com.battery.device.api.registry.AdapterInstanceConfig("dev-sim", "BTS",
                    "127.0.0.1", simulator.port(), 5000, "Asia/Hong_Kong", true,
                    com.battery.device.api.model.AdapterCapabilities.observeOnly()));
            var store = new InMemoryDeviceDiscoveryCandidateStore();
            var mapper = Mockito.mock(TDetectionCabinetMapper.class);
            var persistence = new DeviceDiscoveryPersistenceService(store, mapper);
            var scheduler = new com.battery.business.runtime.DevelopmentSimulatorDiscoveryScheduler(registry, persistence);
            simulator.updateChannel(1, 1, true, com.battery.device.api.model.DeviceWorkStatus.WORKING);
            scheduler.discover();
            var before = store.snapshot(java.time.Instant.now()).cabinets().get(0);
            org.junit.jupiter.api.Assertions.assertNotNull(before.channels().get(0).latestSnapshot());
            simulator.setSoftwareAccessible(false);
            scheduler.discover();
            var failed = store.snapshot(java.time.Instant.now()).cabinets().get(0);
            assertEquals(before.deviceCode(), failed.deviceCode());
            org.junit.jupiter.api.Assertions.assertFalse(failed.online());
            org.junit.jupiter.api.Assertions.assertNull(failed.channels().get(0).latestSnapshot());
            verify(mapper).markRegisteredCabinetsOffline("dev-sim");
            simulator.setSoftwareAccessible(true);
            scheduler.discover();
            org.junit.jupiter.api.Assertions.assertTrue(store.snapshot(java.time.Instant.now()).cabinets().get(0).online());
        }
    }
    @Test
    void discoveryPersistsCandidatesWithoutReceivingTest()
    {
        InMemoryDeviceDiscoveryCandidateStore candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
@@ -38,6 +38,38 @@
class TTestLiveServiceImplTest
{
    @Test
    void nonReceivingConsoleReadsCollectorStatusWithoutRequestingMeasurements() throws Exception
    {
        try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0))
        {
            TTest test = test("RECEIVING");
            test.getBatteries().get(0).setCollectorId(7L);
            var tests = mock(ITTestService.class);
            when(tests.selectTestById(10L)).thenReturn(test);
            var mapper = mock(TTestMapper.class);
            when(mapper.selectCollectorCode(7L)).thenReturn("dev-sim-collector-1");
            var candidates = new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore();
            Instant now = Instant.now();
            candidates.replaceCollectors("dev-sim", List.of(new DeviceDiscoveryCandidateStore.CollectorCandidate(
                    "dev-sim", "dev-sim-collector-1", "127.0.1.2", null, "模拟采集器", null, null,
                    4, true, now, now.plusSeconds(30))));
            var service = new TTestLiveServiceImpl(tests, candidates, simulator, mapper);
            service.current(10L);
            var received = simulator.snapshot().recentRequests();
            assertTrue(received.stream().anyMatch(item -> item.command().equals("collector-realtime")));
            for (String status : List.of("PAUSED", "ENDED", "NOT_STARTED"))
            {
                test.setReceptionStatus(status);
                var source = service.current(10L).sources().stream()
                        .filter(item -> item.sourceType().equals("COLLECTOR")).findFirst().orElseThrow();
                assertEquals("ONLINE", source.deviceStatus());
                assertTrue(source.latestSnapshot().isEmpty());
                assertEquals(received, simulator.snapshot().recentRequests());
            }
        }
    }
    @Test
    void notStartedShouldNotExposePreTestDeviceSnapshot()
    {
        Instant now = Instant.parse("2026-09-02T06:00:00Z");
ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java
@@ -30,6 +30,30 @@
class BtsDevelopmentSimulatorControllerTest
{
    @Test
    void restChangesSoftwareAndCollectorChannelFactsAndRetiresPlayback() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build();
            mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/dev/bts-simulator/software")
                    .contentType("application/json").content("{\"accessible\":false}"))
                    .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
            assertFalse(simulator.snapshot().softwareAccessible());
            mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put(
                    "/dev/bts-simulator/collectors/1/channels/S-01/status")
                    .contentType("application/json").content("{\"workStatus\":\"PROTECTED\"}"))
                    .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
            assertEquals(DeviceWorkStatus.PROTECTED, simulator.snapshot().collectors().get(0).channels().get(0).workStatus());
            for (String path : List.of("/devices/1/channels/1/playback/play", "/devices/1/channels/1/playback/next-step"))
                mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/dev/bts-simulator" + path))
                        .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound());
            for (String path : List.of("/collectors/1/history", "/devices/1/channels/1/records"))
                mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/dev/bts-simulator" + path))
                        .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound());
        }
    }
    @Test
    void controllerUpdatesDeviceAndChannelState() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
@@ -119,55 +143,46 @@
    }
    @Test
    void controllerExposesCollectorRealtimeAndPagedHistory() throws Exception
    void controllerExposesOnlyCurrentCollectorMeasurements() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            BtsDevelopmentSimulatorController controller = controller(simulator);
            controller.configureCollectorSampling(1,
                    new BtsDevelopmentSimulatorController.CollectorSamplingCommand(
                            100, List.of(), null, null, false));
            controller.configureCollectorChannel(1, "S-01",
                    new BtsDevelopmentSimulatorController.CollectorChannelCommand(List.of(
                            new BtsDevelopmentSimulatorController.CollectorMetricCommand(
                                    "temperature", new BigDecimal("27.2"), "℃"),
                            new BtsDevelopmentSimulatorController.CollectorMetricCommand(
                                    "custom_voltage", new BigDecimal("1.234"), "V"))));
            simulator.advanceCollectors(Instant.parse("2026-08-29T01:00:00Z"));
            var realtime = controller.collectorRealtime(1);
            var firstPage = controller.collectorHistory(1, 0, 2);
            assertTrue(realtime.online());
            assertTrue(realtime.measurements().stream().anyMatch(item -> item.metricCode().equals("custom_voltage")));
            assertEquals(2, firstPage.records().size());
            assertTrue(firstPage.hasMore());
            assertEquals(2, firstPage.nextOffset());
        }
    }
    @Test
    void controllerPowersAllSimulatorDevicesWithoutResettingHistory() throws Exception
    void controllerChangesOnlineFactsWithoutResettingChannelStatus() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
        {
            BtsDevelopmentSimulatorController controller = controller(simulator);
            simulator.advanceCollectors(Instant.now());
            long historyCount = controller.state().collectors().get(0).historyRecordCount();
            simulator.updateChannel(1, 1, true, DeviceWorkStatus.PAUSED);
            var poweredOff = controller.setPower(
                    new BtsDevelopmentSimulatorController.SimulatorPowerCommand(false));
            assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online()
                    || device.channels().stream().anyMatch(channel -> channel.online())));
            assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online()));
            assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online()));
            assertEquals(historyCount, poweredOff.collectors().get(0).historyRecordCount());
            assertEquals(DeviceWorkStatus.PAUSED, poweredOff.devices().get(0).channels().get(0).workStatus());
            var poweredOn = controller.setPower(
                    new BtsDevelopmentSimulatorController.SimulatorPowerCommand(true));
            assertTrue(poweredOn.devices().stream().allMatch(device -> device.online()
                    && device.channels().stream().allMatch(channel -> channel.online()
                            && channel.workStatus() == DeviceWorkStatus.STOPPED)));
)));
            assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online()));
        }
    }