实现采集器独立实时与历史采样模拟
扩展开发采集器的稳定通道、独立序号、测量配置、历史分页和故障场景,并将模拟状态兼容恢复到 Redis。补充本机控制接口、观察页面、隔离端口配置及设备合同测试。
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | @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)); |
| | | } |
| | |
| | | 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; |
| | |
| | | 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) |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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 |
| | |
| | | } |
| | | } |
| | | |
| | | /** 配置采集器采样频率和可重复的历史下载故障场景。 */ |
| | | @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) |
| | |
| | | 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) { } |
| | | } |
| | |
| | | .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; } |
| | |
| | | .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; } |
| | |
| | | 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; } |
| | |
| | | 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'); |
| | |
| | | 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'); |
| | |
| | | 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'); |
| | |
| | | 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])); |
| | | |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | @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\":[]," |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | @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)) |
| | |
| | | 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 |
| | |
| | | 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())); |
| | |
| | | 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() |
| | | { |
| | |
| | | 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()); |
| | |
| | | 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) |
| | |
| | | || !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<>(); |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | } |
| | |
| | | package com.ruoyi.device.simulator; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.Instant; |
| | | import java.util.List; |
| | | import com.ruoyi.device.model.DeviceWorkStatus; |
| | |
| | | } |
| | | |
| | | 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) { } |
| | | |
| New file |
| | |
| | | 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); } |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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(); |