yeg
2026-08-29 7ab0e426264707fb85ee38d0a9a73caf41899635
实现采集器独立实时与历史采样模拟

扩展开发采集器的稳定通道、独立序号、测量配置、历史分页和故障场景,并将模拟状态兼容恢复到 Redis。补充本机控制接口、观察页面、隔离端口配置及设备合同测试。
2个文件已添加
10个文件已修改
844 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java 249 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java
New file
@@ -0,0 +1,25 @@
package com.ruoyi.config;
import java.time.Instant;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.ruoyi.device.bts.BtsDevelopmentSimulator;
/** 按各采集器配置频率推进开发模拟测量,不参与正式测试数据持久化。 */
@Component
public class DevelopmentCollectorSamplingScheduler
{
    private final BtsDevelopmentSimulator simulator;
    public DevelopmentCollectorSamplingScheduler(BtsDevelopmentSimulator simulator)
    {
        this.simulator = simulator;
    }
    /** 高频检查只在采样到期时写穿 Redis,采集器之间不要求同频。 */
    @Scheduled(fixedDelay = 100)
    public void sample()
    {
        simulator.advanceCollectors(Instant.now());
    }
}
ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
@@ -3,6 +3,7 @@
import java.io.IOException;
import java.util.List;
import org.springframework.boot.ApplicationRunner;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -25,22 +26,24 @@
    }
    @Bean(destroyMethod = "close")
    public BtsDevelopmentSimulator btsDevelopmentSimulator(BtsSimulatorStateStore stateStore) throws IOException
    public BtsDevelopmentSimulator btsDevelopmentSimulator(BtsSimulatorStateStore stateStore,
            @Value("${battery.dev-simulator.port:15020}") int simulatorPort) throws IOException
    {
        return new BtsDevelopmentSimulator(BtsDevelopmentSimulator.DEFAULT_PORT, stateStore);
        return new BtsDevelopmentSimulator(simulatorPort, stateStore);
    }
    @Bean
    public ApplicationRunner registerDevelopmentSimulatorAdapter(DeviceAdapterRegistry registry)
    public ApplicationRunner registerDevelopmentSimulatorAdapter(DeviceAdapterRegistry registry,
            @Value("${battery.dev-simulator.port:15020}") int simulatorPort)
    {
        // 固定开发装配不进入 t_device_adapter,直接注册运行时实例。
        return arguments -> registry.synchronize(developmentSimulatorRuntimeConfig());
        return arguments -> registry.synchronize(developmentSimulatorRuntimeConfig(simulatorPort));
    }
    private AdapterInstanceConfig developmentSimulatorRuntimeConfig()
    private AdapterInstanceConfig developmentSimulatorRuntimeConfig(int simulatorPort)
    {
        return new AdapterInstanceConfig(BtsDevelopmentSimulator.ADAPTER_CODE, "BTS", BtsDevelopmentSimulator.HOST,
                BtsDevelopmentSimulator.DEFAULT_PORT, BtsDevelopmentSimulator.POLL_INTERVAL_MS,
                simulatorPort, BtsDevelopmentSimulator.POLL_INTERVAL_MS,
                "Asia/Hong_Kong", true,
                new AdapterCapabilities(true, true, false, false));
    }
ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
@@ -1,5 +1,7 @@
package com.ruoyi.config;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -133,10 +135,67 @@
    private CollectorState collectorState(Object value)
    {
        JSONObject object = jsonObject(value);
        return new CollectorState(requiredInteger(object, "collectorId"), object.getString("deviceCode"),
                object.getString("ip"), object.getString("mac"), object.getString("name"),
                object.getString("manufacturer"), object.getString("model"),
                requiredInteger(object, "channelCount"), object.getBooleanValue("online"));
        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");
        // Ticket04 状态只包含发现配置;首次恢复时以演示默认值补齐采样状态。
        if (!object.containsKey("sampleIntervalMs"))
            return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model,
                    channelCount, online);
        List<CollectorChannelState> channels = new ArrayList<>();
        requiredArray(object, "channels").forEach(item -> channels.add(collectorChannelState(item)));
        List<CollectorMeasurementRecord> history = new ArrayList<>();
        requiredArray(object, "history").forEach(item -> history.add(collectorMeasurementRecord(item)));
        return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model,
                channelCount, online, requiredInteger(object, "sampleIntervalMs"),
                requiredLong(object, "nextSequence"), optionalInstant(object, "lastSampledAt"),
                channels, history, collectorHistoryFaults(object.get("historyFaults")));
    }
    private CollectorChannelState collectorChannelState(Object value)
    {
        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);
    }
    private CollectorMetricState collectorMetricState(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 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 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)
@@ -154,4 +213,24 @@
        return new ChannelTombstone(requiredInteger(object, "deviceId"), object.getString("ip"),
                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 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);
    }
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
@@ -1,5 +1,7 @@
package com.ruoyi.web.controller.device;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -10,6 +12,7 @@
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 com.ruoyi.common.annotation.Anonymous;
@@ -17,6 +20,10 @@
import com.ruoyi.device.bts.BtsDevelopmentSimulator.StateSnapshot;
import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
import com.ruoyi.device.model.DeviceWorkStatus;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
/** 本机临时 BTS 模拟器控制 API。 */
@Anonymous
@@ -132,6 +139,72 @@
        }
    }
    /** 配置采集器采样频率和可重复的历史下载故障场景。 */
    @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,
            @PathVariable String channelCode, @RequestBody CollectorChannelCommand command)
    {
        try
        {
            List<CollectorMetricState> metrics = command.metrics().stream()
                    .map(metric -> new CollectorMetricState(metric.metricCode(), metric.value(), metric.unit()))
                    .toList();
            simulator.configureCollectorChannel(collectorId, channelCode, metrics);
            return simulator.snapshot();
        }
        catch (IllegalArgumentException | NullPointerException exception)
        {
            throw badRequest(new IllegalArgumentException("Invalid collector channel metrics", exception));
        }
    }
    /** 查询采集器最新实时快照;离线时保留最后观测并明确返回离线事实。 */
    @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)
        {
            throw badRequest(exception);
        }
    }
    /** 删除指定虚拟采集器。 */
    @DeleteMapping("/dev/bts-simulator/collectors/{collectorId}")
    public StateSnapshot deleteCollector(@PathVariable int collectorId)
@@ -177,4 +250,17 @@
    public record CreateCollectorCommand(String ip, int channelCount) { }
    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) { }
}
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -25,10 +25,12 @@
    .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(180px, 1fr) 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; }
    .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; }
    label { color: #5e6a73; font-size: 12px; }
    label > span { display: block; margin-bottom: 6px; }
    input[type="text"], input[type="number"], select { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
    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; }
    textarea { min-height: 64px; padding: 7px 9px; resize: vertical; font: 12px Consolas, monospace; }
    input[type="checkbox"] { width: 17px; height: 17px; accent-color: #218a5b; cursor: pointer; }
    .switch-label { display: inline-flex; align-items: center; gap: 8px; height: 34px; }
    .switch-label > span { margin: 0; }
@@ -46,6 +48,8 @@
    .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; }
    .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; }
    .error { display: none; margin-bottom: 16px; padding: 11px 14px; color: #8b2635; background: #fff0f2; border: 1px solid #e4a8b0; font-size: 13px; }
    .error.visible { display: block; }
@@ -54,7 +58,7 @@
      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 { grid-template-columns: 1fr 1fr; }
      .add-form, .device-settings, .collector-settings, .sampling-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; }
@@ -232,6 +236,9 @@
        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');
@@ -245,7 +252,10 @@
        save.type = 'button';
        save.textContent = '保存配置';
        save.addEventListener('click', async () => {
          try { render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) })); }
          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 }) }));
          }
          catch (error) { showError(error); }
        });
        const remove = document.createElement('button');
@@ -256,12 +266,108 @@
          try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); }
          catch (error) { showError(error); }
        });
        settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
        panel.append(title, settings);
        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);
        const table = document.createElement('table');
        table.innerHTML = '<thead><tr><th style="width:110px">稳定通道</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);
        const actions = document.createElement('div');
        actions.className = 'collector-actions';
        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);
        return panel;
      }));
    }
    function collectorChannelRow(collectorId, channel) {
      const row = document.createElement('tr');
      const identity = document.createElement('td');
      identity.textContent = channel.channelCode;
      const metricsCell = document.createElement('td');
      const metrics = document.createElement('textarea');
      metrics.setAttribute('aria-label', `${channel.channelCode} 测量项`);
      metrics.value = channel.metrics.map(item => `${item.metricCode}=${item.value} ${item.unit}`).join('\n');
      metricsCell.append(metrics);
      const action = document.createElement('td');
      const save = document.createElement('button');
      save.type = 'button';
      save.textContent = '保存测量值';
      save.addEventListener('click', async () => {
        try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}`, { method: 'PUT', body: JSON.stringify({ metrics: parseMetrics(metrics.value) }) })); }
        catch (error) { showError(error); }
      });
      action.append(save);
      row.append(identity, metricsCell, action);
      return row;
    }
    function parseMetrics(text) {
      return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
        const match = line.match(/^([^=\s]+)=(-?\d+(?:\.\d+)?)\s+(.+)$/);
        if (!match) throw new Error(`测量项格式错误:${line}`);
        return { metricCode: match[1], value: Number(match[2]), unit: match[3] };
      });
    }
    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) {
      const label = document.createElement('label');
      const text = document.createElement('span');
ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
@@ -15,7 +15,7 @@
    void registersDevelopmentSimulatorBeforeApplicationReady() throws Exception
    {
        DeviceAdapterRegistry registry = mock(DeviceAdapterRegistry.class);
        var runner = new DeviceAdapterRuntimeConfig().registerDevelopmentSimulatorAdapter(registry);
        var runner = new DeviceAdapterRuntimeConfig().registerDevelopmentSimulatorAdapter(registry, 15021);
        runner.run(new DefaultApplicationArguments(new String[0]));
@@ -23,7 +23,7 @@
        verify(registry).synchronize(adapter.capture());
        assertEquals("dev-sim", adapter.getValue().adapterCode());
        assertEquals("127.0.0.1", adapter.getValue().host());
        assertEquals(15020, adapter.getValue().port());
        assertEquals(15021, adapter.getValue().port());
        assertEquals(5000, adapter.getValue().pollIntervalMs());
    }
}
ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java
@@ -62,6 +62,26 @@
    }
    @Test
    void upgradesLegacyCollectorDiscoveryStateWithSamplingDefaults()
    {
        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\":2,\"online\":true}],"
                + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}");
        SimulatorState restored = store(stored).loadOrInitialize(state(1, "127.0.0.2"));
        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(List.of(), collector.history());
    }
    @Test
    void rejectsIncompatibleVersionWithoutOverwritingIt()
    {
        AtomicReference<Object> stored = new AtomicReference<>("{\"version\":2,\"devices\":[],"
ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
@@ -6,6 +6,7 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.net.InetAddress;
import java.time.Instant;
import java.util.List;
@@ -95,6 +96,34 @@
    }
    @Test
    void controllerExposesCollectorRealtimeAndPagedHistory() 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 virtualIpMustBeUniqueAcrossCabinetsAndCollectors() throws Exception
    {
        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
@@ -12,11 +12,18 @@
import com.ruoyi.device.simulator.BtsSimulatorStateStore;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
public final class BtsDevelopmentSimulator implements AutoCloseable
@@ -138,8 +145,7 @@
        CollectorState current = requireCollector(collectorId);
        List<CollectorState> collectors = new ArrayList<>(state.collectors());
        collectors.replaceAll(item -> item.collectorId() == collectorId
                ? new CollectorState(collectorId, current.deviceCode(), ip.trim(), current.mac(), current.name(),
                        current.manufacturer(), current.model(), channelCount, online)
                ? DevelopmentCollectorSimulation.resize(current, ip, channelCount, online, Instant.now())
                : item);
        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones()));
@@ -155,6 +161,45 @@
                state.nextCollectorId(), state.tombstones()));
    }
    /** 配置采集器独立采样频率和历史下载故障场景。 */
    public synchronized void configureCollectorSampling(int collectorId, int sampleIntervalMs,
            CollectorHistoryFaults historyFaults)
    {
        CollectorState current = requireCollector(collectorId);
        replaceCollector(DevelopmentCollectorSimulation.configureSampling(current, sampleIntervalMs, historyFaults));
    }
    /** 配置一个稳定 S 通道的固定或动态演示指标。 */
    public synchronized void configureCollectorChannel(int collectorId, String channelCode,
            List<CollectorMetricState> metrics)
    {
        CollectorState current = requireCollector(collectorId);
        replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
    }
    /** 按每台采集器自己的频率推进实时和历史采样。 */
    public synchronized void advanceCollectors(Instant now)
    {
        List<CollectorState> collectors = state.collectors().stream()
                .map(collector -> DevelopmentCollectorSimulation.advance(collector, now))
                .toList();
        if (!collectors.equals(state.collectors()))
            persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                    state.nextCollectorId(), state.tombstones()));
    }
    /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */
    public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId)
    {
        return DevelopmentCollectorSimulation.realtime(requireCollector(collectorId));
    }
    /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */
    public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit)
    {
        return DevelopmentCollectorSimulation.history(requireCollector(collectorId), offset, limit);
    }
    /** 返回控制页需要的运行状态快照。 */
    public synchronized StateSnapshot snapshot()
    {
@@ -168,7 +213,9 @@
        List<CollectorSnapshot> collectorSnapshots = state.collectors().stream()
                .map(collector -> new CollectorSnapshot(collector.collectorId(), collector.deviceCode(),
                        collector.ip(), collector.mac(), collector.name(), collector.manufacturer(),
                        collector.model(), collector.channelCount(), collector.online()))
                        collector.model(), collector.channelCount(), collector.online(), collector.sampleIntervalMs(),
                        collector.channels(), collector.historyFaults(), collector.history().size(),
                        DevelopmentCollectorSimulation.realtime(collector).measurements()))
                .toList();
        return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
                stateStore.loadRecentRequests());
@@ -202,6 +249,14 @@
        return new CollectorState(collectorId, "dev-sim-collector-" + collectorId,
                ip.trim(), simulatorCollectorMac(collectorId), "dev-sim 采集器 " + collectorId,
                "开发模拟器", "Virtual Collector", channelCount, true);
    }
    private void replaceCollector(CollectorState replacement)
    {
        List<CollectorState> collectors = new ArrayList<>(state.collectors());
        collectors.replaceAll(item -> item.collectorId() == replacement.collectorId() ? replacement : item);
        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
                state.nextCollectorId(), state.tombstones()));
    }
    private String respond(String request)
@@ -442,6 +497,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);
            maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId());
        }
        java.util.Set<String> tombstoneIds = new java.util.HashSet<>();
@@ -471,6 +527,15 @@
    public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { }
    public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
            String manufacturer, String model, int channelCount, boolean online) { }
            String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
            List<CollectorChannelState> channels, CollectorHistoryFaults historyFaults, int historyRecordCount,
            List<CollectorMeasurementRecord> latestMeasurements)
    {
        public CollectorSnapshot
        {
            channels = List.copyOf(channels);
            latestMeasurements = List.copyOf(latestMeasurements);
        }
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
@@ -1,5 +1,6 @@
package com.ruoyi.device.simulator;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.ruoyi.device.model.DeviceWorkStatus;
@@ -43,7 +44,52 @@
    }
    record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
            String manufacturer, String model, int channelCount, boolean online) { }
            String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
            long nextSequence, Instant lastSampledAt, List<CollectorChannelState> channels,
            List<CollectorMeasurementRecord> history, CollectorHistoryFaults historyFaults)
    {
        public CollectorState
        {
            channels = List.copyOf(channels);
            history = List.copyOf(history);
            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(),
                    CollectorHistoryFaults.none());
        }
    }
    record CollectorChannelState(String channelCode, List<CollectorMetricState> metrics)
    {
        public CollectorChannelState { metrics = List.copyOf(metrics); }
    }
    record CollectorMetricState(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 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) { }
ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java
New file
@@ -0,0 +1,249 @@
package com.ruoyi.device.simulator;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState;
/** 实现第一版开发采集器的独立采样与故障注入合同。 */
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 = 1000;
    private static final int MAX_HISTORY_RECORDS = 50000;
    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 DevelopmentCollectorSimulation() { }
    /** 创建带稳定 S 通道和三项固定演示指标的采集器状态。 */
    public static List<CollectorChannelState> defaultChannels(int channelCount)
    {
        List<CollectorChannelState> channels = new ArrayList<>(channelCount);
        for (int index = 1; index <= channelCount; index++)
        {
            channels.add(new CollectorChannelState(channelCode(index), List.of(
                    new CollectorMetricState("temperature", new BigDecimal("25.0")
                            .add(BigDecimal.valueOf(index, 1)), "℃"),
                    new CollectorMetricState("positive_potential", new BigDecimal("3.700"), "V"),
                    new CollectorMetricState("negative_potential", new BigDecimal("-0.100"), "V"))));
        }
        return List.copyOf(channels);
    }
    /** 调整通道数时保留已有通道配置,并为新增通道生成稳定默认配置。 */
    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);
        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;
        return copy(current, ip, channelCount, online, current.sampleIntervalMs(), current.nextSequence(),
                lastSampledAt, channels, current.history(), current.historyFaults());
    }
    /** 更新采样频率和历史下载故障;测量原始历史保持只追加。 */
    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.history(), historyFaults);
    }
    /** 替换一个演示通道的指标配置;动态添加项与固定指标共用指标编码合同。 */
    public static CollectorState configureChannel(CollectorState current, String channelCode,
            List<CollectorMetricState> metrics)
    {
        validateMetrics(metrics);
        int channelIndex = parseChannelIndex(channelCode, current.channelCount());
        List<CollectorChannelState> channels = new ArrayList<>(current.channels());
        channels.set(channelIndex, new CollectorChannelState(channelCode(channelIndex + 1), metrics));
        return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
                current.nextSequence(), current.lastSampledAt(), channels, current.history(), current.historyFaults());
    }
    /** 按采集器自身频率推进采样;离线期间不生成记录,恢复后从下一序号继续。 */
    public static CollectorState advance(CollectorState current, Instant now)
    {
        if (!current.online()) return current;
        Instant sampleAt = current.lastSampledAt() == null
                ? now
                : current.lastSampledAt().plusMillis(current.sampleIntervalMs());
        if (sampleAt.isAfter(now)) return current;
        List<CollectorMeasurementRecord> history = new ArrayList<>(current.history());
        long sequence = current.nextSequence();
        int generated = 0;
        Instant lastSampledAt = current.lastSampledAt();
        while (!sampleAt.isAfter(now) && generated < MAX_SAMPLES_PER_ADVANCE)
        {
            for (CollectorChannelState channel : current.channels())
            {
                for (CollectorMetricState metric : channel.metrics())
                {
                    history.add(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()));
                }
            }
            lastSampledAt = sampleAt;
            sequence++;
            generated++;
            sampleAt = sampleAt.plusMillis(current.sampleIntervalMs());
        }
        if (history.size() > MAX_HISTORY_RECORDS)
            history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_RECORDS, history.size()));
        return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
                sequence, lastSampledAt, current.channels(), history, current.historyFaults());
    }
    /** 返回每个通道和指标的最新实时快照,不将快照写入另一条历史路径。 */
    public static CollectorRealtimeSnapshot realtime(CollectorState current)
    {
        Map<String, CollectorMeasurementRecord> latest = new LinkedHashMap<>();
        for (CollectorMeasurementRecord record : current.history())
            latest.put(record.channelCode() + "\u0000" + record.metricCode(), record);
        return new CollectorRealtimeSnapshot(current.deviceCode(), current.online(), current.sampleIntervalMs(),
                List.copyOf(latest.values()));
    }
    /** 对只追加原始模拟历史应用可重复的下载故障视图,再按偏移量分页。 */
    public static CollectorHistoryPage history(CollectorState current, 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);
        List<CollectorMeasurementRecord> download = faultedHistory(current);
        int from = (int) Math.min(offset, download.size());
        int to = Math.min(from + limit, download.size());
        return new CollectorHistoryPage(current.deviceCode(), offset, to, to < download.size(),
                download.size(), download.subList(from, to));
    }
    /** 校验 Redis 恢复的采集器采样状态,拒绝损坏状态被静默替换。 */
    public static void validate(CollectorState state)
    {
        validateSampleInterval(state.sampleIntervalMs());
        if (state.nextSequence() < 1 || state.channels().size() != state.channelCount())
            throw new IllegalStateException("Invalid collector sampling state in Redis");
        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");
            validateMetrics(channel.metrics());
        }
        validateFaults(state.historyFaults());
    }
    private static List<CollectorMeasurementRecord> faultedHistory(CollectorState current)
    {
        CollectorHistoryFaults faults = current.historyFaults();
        Set<Long> missing = Set.copyOf(faults.missingSequences());
        List<CollectorMeasurementRecord> records = new ArrayList<>();
        for (CollectorMeasurementRecord record : current.history())
        {
            if (missing.contains(record.sequenceId())) continue;
            records.add(record);
            if (Long.valueOf(record.sequenceId()).equals(faults.duplicateSequence())) records.add(record);
            if (Long.valueOf(record.sequenceId()).equals(faults.conflictingSequence()))
                records.add(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()));
        }
        if (faults.outOfOrder())
            records.sort(Comparator.comparingLong(CollectorMeasurementRecord::sequenceId).reversed()
                    .thenComparing(CollectorMeasurementRecord::channelCode)
                    .thenComparing(CollectorMeasurementRecord::metricCode));
        return records;
    }
    private static CollectorState copy(CollectorState current, String ip, int channelCount, boolean online,
            int sampleIntervalMs, long nextSequence, Instant lastSampledAt,
            List<CollectorChannelState> channels, List<CollectorMeasurementRecord> history,
            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, history, 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);
    }
    private static void validateMetrics(List<CollectorMetricState> metrics)
    {
        if (metrics == null || metrics.isEmpty())
            throw new IllegalArgumentException("Collector channel must contain at least one metric");
        java.util.HashSet<String> codes = new java.util.HashSet<>();
        for (CollectorMetricState metric : metrics)
        {
            if (metric.metricCode() == null || metric.metricCode().isBlank() || metric.value() == null
                    || metric.unit() == null || metric.unit().isBlank() || !codes.add(metric.metricCode().trim()))
                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)
    {
        if (value == null || !value.matches("S-\\d{2}"))
            throw new IllegalArgumentException("Collector channel must use S-01...S-N identity");
        int index = Integer.parseInt(value.substring(2)) - 1;
        if (index < 0 || index >= channelCount || !channelCode(index + 1).equals(value))
            throw new IllegalArgumentException("Unknown collector channel: " + value);
        return index;
    }
    private static String channelCode(int index)
    {
        return String.format("S-%02d", index);
    }
    public record CollectorRealtimeSnapshot(String collectorCode, boolean online, int sampleIntervalMs,
            List<CollectorMeasurementRecord> measurements)
    {
        public CollectorRealtimeSnapshot { measurements = List.copyOf(measurements); }
    }
    public record CollectorHistoryPage(String collectorCode, long offset, long nextOffset, boolean hasMore,
            long total, List<CollectorMeasurementRecord> records)
    {
        public CollectorHistoryPage { records = List.copyOf(records); }
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
@@ -1,9 +1,14 @@
package com.ruoyi.device.contract;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.ruoyi.device.api.DeviceAdapter;
@@ -16,6 +21,8 @@
import com.ruoyi.device.model.DeviceWorkStatus;
import com.ruoyi.device.simulator.BtsSimulatorStateStore;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
@@ -23,6 +30,89 @@
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.ruoyi.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 simulatorIsReusableOutsideTheBtsPackage() throws Exception
    {
        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();