支持模拟器逐通道参数与工步方案配置
按通道保存固定测量、循环工步位置和方案,保留在线及工作状态事实;模拟器页面提供参数、方案和采集器数值编辑。验证恢复持久化、实时查询及控制台多工步展示。
| | |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState; |
| | |
| | | if (channelId < 1 || channelId > device.channels().size()) |
| | | throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size()); |
| | | List<ChannelState> channels = new ArrayList<>(device.channels()); |
| | | channels.set(channelId - 1, new ChannelState(channelId, online, workStatus)); |
| | | channels.set(channelId - 1, new ChannelState(channelId, online, workStatus, |
| | | channels.get(channelId - 1).configuration())); |
| | | persist(new SimulatorState(state.version(), |
| | | replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(), |
| | | state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); |
| | | } |
| | | |
| | | /** 保存单通道的当前测量和工步方案,不改写在线或工作状态,不产生观测。 */ |
| | | public synchronized void configureChannel(int deviceId, int channelId, ChannelConfiguration configuration) |
| | | { |
| | | if (configuration == null) throw new IllegalArgumentException("通道配置不能为空"); |
| | | DeviceState device = requireDevice(deviceId); |
| | | if (channelId < 1 || channelId > device.channels().size()) |
| | | throw new IllegalArgumentException("通道不存在"); |
| | | List<ChannelState> channels = new ArrayList<>(device.channels()); |
| | | ChannelState channel = channels.get(channelId - 1); |
| | | channels.set(channelId - 1, new ChannelState(channelId, channel.online(), channel.workStatus(), configuration)); |
| | | persist(new SimulatorState(state.version(), |
| | | replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), |
| | | state.collectors(), state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); |
| | | } |
| | | |
| | | /** 新增一台与 BTS 检测柜拓扑隔离的开发采集器。 */ |
| | |
| | | replaceCollector(DevelopmentCollectorSimulation.configureStatus(requireCollector(collectorId), channelCode, status)); |
| | | } |
| | | |
| | | /** 返回固定的开发计划,不推进工步或生成历史。 */ |
| | | /** 返回通道已配置的开发方案及当前工步位置,不自动推进或生成历史。 */ |
| | | @Override |
| | | public synchronized TestPlan testPlan(ChannelIdentity channel) |
| | | { |
| | | if (!canObserve(channel)) return null; |
| | | return new TestPlan(1, 1, 1, List.of(new PlanRow("STEP", 1, 1, "恒流充电", 60d, 3.7d, |
| | | requireDevice(channel.devid()).channels().get(channel.chlid() - 1).workStatus() == DeviceWorkStatus.FINISHED |
| | | ? "已完成" : "待执行", null, null))); |
| | | ChannelState source = requireDevice(channel.devid()).channels().get(channel.chlid() - 1); |
| | | ChannelConfiguration configuration = source.configuration(); |
| | | List<PlanRow> rows = new ArrayList<>(); |
| | | for (int index = 0; index < configuration.steps().size(); index++) { |
| | | var step = configuration.steps().get(index); |
| | | String status = index + 1 < configuration.stepNo() ? "已完成" : "待执行"; |
| | | if (index + 1 == configuration.stepNo()) status = switch (source.workStatus()) { |
| | | case WORKING -> "进行中"; |
| | | case PAUSED -> "暂停"; |
| | | case FINISHED -> "已完成"; |
| | | case PROTECTED -> "异常"; |
| | | default -> "待执行"; |
| | | }; |
| | | // cyclePosition 表示循环内的工步位置,不能使用当前循环号,否则控制台会合并不同工步。 |
| | | rows.add(new PlanRow("STEP", index + 1, index + 1, step.name(), step.durationSeconds(), |
| | | step.voltageV(), status, null, null)); |
| | | } |
| | | return new TestPlan(configuration.cycleNo(), configuration.stepNo(), configuration.plannedCycleCount(), rows); |
| | | } |
| | | |
| | | /** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */ |
| | |
| | | if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) return null; |
| | | Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); |
| | | long sequence = nextObservationSequence(); |
| | | double wave = Math.sin((sequence + channel.channelId()) * 0.7d); |
| | | BtsDeviceProfile profile = profileFor(device); |
| | | double current = channel.workStatus() == DeviceWorkStatus.WORKING |
| | | ? clamp(profile.currentMaxA().doubleValue() * (0.18d + wave * 0.02d), |
| | | profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d; |
| | | double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(), profile.voltageMaxV().doubleValue()); |
| | | CabinetRealtime result = new CabinetRealtime(sequence, now, current, voltage, 1.2d, 4.44d, |
| | | 1, 1, "恒流充电", "cc", now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId()); |
| | | ChannelConfiguration configuration = channel.configuration(); |
| | | var step = configuration.steps().get(configuration.stepNo() - 1); |
| | | // 保存值直接成为当前观测;工作状态和设备量程不反向覆盖操作员配置。 |
| | | CabinetRealtime result = new CabinetRealtime(sequence, now, configuration.currentA().doubleValue(), |
| | | configuration.voltageV().doubleValue(), configuration.capacityAh().doubleValue(), configuration.energyWh().doubleValue(), |
| | | configuration.cycleNo(), configuration.stepNo(), step.name(), step.stepType(), |
| | | now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId()); |
| | | return result; |
| | | } |
| | | |
| | |
| | | .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), device.profile(), |
| | | device.channels().stream() |
| | | .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(), |
| | | channel.workStatus())) |
| | | channel.workStatus(), channel.configuration())) |
| | | .toList())) |
| | | .toList(); |
| | | List<CollectorSnapshot> collectorSnapshots = state.collectors().stream() |
| | |
| | | .append("\" current=\"").append(decimal(current)).append("\" voltage=\"") |
| | | .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity)) |
| | | .append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"") |
| | | .append(0).append("\" relativetime=\"").append(0) |
| | | .append(channel.configuration().totalTimeSeconds()).append("\" relativetime=\"") |
| | | .append(channel.configuration().relativeTimeSeconds()) |
| | | .append("\" open_or_close=\"0\" log_code=\"0\">") |
| | | // 模拟器返回当前设备方案,供统一观测模型展示;不代表真实 BTS 的完整方案字段。 |
| | | .append("<plan><step step_no=\"").append(stepNo).append("\" step_type=\"") |
| | |
| | | throw new IllegalArgumentException("Unsupported Neware simulator model: " + model); |
| | | } |
| | | |
| | | private double clamp(double value, double minimum, double maximum) |
| | | { |
| | | return Math.max(minimum, Math.min(maximum, value)); |
| | | } |
| | | |
| | | private String decimal(double value) |
| | | { |
| | | return String.format(java.util.Locale.ROOT, "%.3f", value); |
| | | return java.math.BigDecimal.valueOf(value).toPlainString(); |
| | | } |
| | | |
| | | private String toBtsWorkStatus(DeviceWorkStatus status) |
| | |
| | | public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile, |
| | | List<ChannelSnapshot> channels) { } |
| | | |
| | | public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { } |
| | | public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus, |
| | | ChannelConfiguration configuration) { } |
| | | |
| | | public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name, |
| | | String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs, |
| | |
| | | long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt, |
| | | String metricCode, BigDecimal value, String unit) { } |
| | | |
| | | record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { } |
| | | record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus, |
| | | ChannelConfiguration configuration) |
| | | { |
| | | public ChannelState |
| | | { |
| | | configuration = configuration == null ? ChannelConfiguration.defaults() : configuration; |
| | | } |
| | | |
| | | /** BTS 检测柜的可配置厂商 profile;参数用于发现展示和边界内的演示数据生成。 */ |
| | | public ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) |
| | | { this(channelId, online, workStatus, null); } |
| | | } |
| | | |
| | | /** 通道当前观测配置与方案一起保存,避免当前工步引用不存在的方案行。 */ |
| | | record ChannelConfiguration(BigDecimal currentA, BigDecimal voltageV, BigDecimal capacityAh, |
| | | BigDecimal energyWh, int cycleNo, int stepNo, int plannedCycleCount, |
| | | long totalTimeSeconds, long relativeTimeSeconds, List<SimulatorPlanStep> steps) |
| | | { |
| | | public ChannelConfiguration |
| | | { |
| | | for (BigDecimal value : new BigDecimal[] { currentA, voltageV, capacityAh, energyWh }) |
| | | if (value == null || !Double.isFinite(value.doubleValue())) |
| | | throw new IllegalArgumentException("通道测量值必须是有效数值"); |
| | | if (steps == null || steps.isEmpty() || steps.stream().anyMatch(java.util.Objects::isNull)) |
| | | throw new IllegalArgumentException("工步方案至少包含一个有效工步"); |
| | | steps = List.copyOf(steps); |
| | | if (cycleNo < 1 || plannedCycleCount < cycleNo || stepNo < 1 || stepNo > steps.size() |
| | | || totalTimeSeconds < 0 || relativeTimeSeconds < 0 || relativeTimeSeconds > totalTimeSeconds) |
| | | throw new IllegalArgumentException("循环、当前工步或时间配置无效"); |
| | | } |
| | | |
| | | /** 为新增通道和既有配置补齐固定观测值,不产生成功接收记录。 */ |
| | | public static ChannelConfiguration defaults() |
| | | { |
| | | return new ChannelConfiguration(BigDecimal.ZERO, new BigDecimal("3.7"), new BigDecimal("1.2"), |
| | | new BigDecimal("4.44"), 1, 1, 1, 0, 0, |
| | | List.of(new SimulatorPlanStep("恒流充电", "cc", 60d, 3.7d))); |
| | | } |
| | | } |
| | | |
| | | record SimulatorPlanStep(String name, String stepType, double durationSeconds, double voltageV) |
| | | { |
| | | public SimulatorPlanStep |
| | | { |
| | | if (name == null || name.isBlank() || stepType == null |
| | | || !List.of("cc", "dc", "cv", "rest", "end", "cccv").contains(stepType) |
| | | || !Double.isFinite(durationSeconds) || durationSeconds < 0 || !Double.isFinite(voltageV)) |
| | | throw new IllegalArgumentException("工步名称、类型、时间或电压无效"); |
| | | name = name.trim(); |
| | | } |
| | | } |
| | | |
| | | /** BTS 检测柜的可配置厂商 profile,用于设备发现元数据展示。 */ |
| | | record BtsDeviceProfile(int devtype, int subDeviceId, String name, String manufacturer, String model, |
| | | String serialNumber, String firmwareVersion, int channelCount, BigDecimal currentMinA, |
| | | BigDecimal currentMaxA, BigDecimal voltageMinV, BigDecimal voltageMaxV, int sampleIntervalMs) |
| | |
| | | } |
| | | |
| | | @Test |
| | | void developmentSimulatorSupportsNewareProfilesAndDynamicObservation() throws Exception |
| | | void developmentSimulatorSupportsNewareProfilesAndFixedObservation() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0); |
| | | BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), |
| | |
| | | var first = adapter.inquire(channel).values().iterator().next(); |
| | | var second = adapter.inquire(channel).values().iterator().next(); |
| | | assertEquals(DeviceWorkStatus.WORKING, first.workStatus()); |
| | | assertNotEquals(first.currentA(), second.currentA()); |
| | | assertEquals(first.currentA(), second.currentA()); |
| | | assertTrue(first.voltageV().doubleValue() >= 0.0 && first.voltageV().doubleValue() <= 5.0); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void configuredChannelValuesReachBtsInquireWithoutClampingOrRounding() throws Exception |
| | | { |
| | | try (var simulator = new BtsDevelopmentSimulator(0); |
| | | var transport = new BtsTcpTransport("127.0.0.1", simulator.port(), Duration.ofSeconds(1), Duration.ofSeconds(1)); |
| | | DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport, |
| | | new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong")))) { |
| | | simulator.configureChannel(1, 1, new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration( |
| | | new java.math.BigDecimal("-2.123456"), new java.math.BigDecimal("8.125"), |
| | | new java.math.BigDecimal("7.2"), new java.math.BigDecimal("29.7"), 2, 2, 5, 120, 30, |
| | | List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2), |
| | | new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电 <验证>", "dc", 90, 3.0)))); |
| | | var channel = adapter.discoverChannels().stream().filter(item -> item.identity().chlid() == 1) |
| | | .findFirst().orElseThrow().identity(); |
| | | var observation = adapter.inquire(Set.of(channel)).get(channel); |
| | | assertEquals(-2.123456, observation.currentA().doubleValue()); |
| | | assertEquals(8.125, observation.voltageV().doubleValue()); |
| | | assertEquals(7.2, observation.capacityAh().doubleValue()); |
| | | assertEquals(29.7, observation.energyWh().doubleValue()); |
| | | assertEquals(2, observation.cycleNo()); |
| | | assertEquals(2, observation.stepNo()); |
| | | assertEquals("dc", observation.stepType()); |
| | | assertEquals(120L, observation.totalTimeSeconds()); |
| | | assertEquals(30L, observation.relativeTimeSeconds()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void developmentSimulatorPersistsNewareProfileMetadata() throws Exception |
| | | { |
| | | InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore(); |
| | |
| | | String workStatus = object.getString("workStatus"); |
| | | if (workStatus == null) throw new IllegalStateException("Missing BTS simulator state field: workStatus"); |
| | | return new ChannelState(requiredInteger(object, "channelId"), object.getBooleanValue("online"), |
| | | DeviceWorkStatus.valueOf(workStatus)); |
| | | DeviceWorkStatus.valueOf(workStatus), object.getObject("configuration", ChannelConfiguration.class)); |
| | | } |
| | | |
| | | private ChannelTombstone channelTombstone(Object value) |
| | |
| | | } |
| | | } |
| | | |
| | | /** 保存单通道的当前观测参数和工步方案,不改变设备在线或工作状态。 */ |
| | | @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/configuration") |
| | | public StateSnapshot configureChannel(@PathVariable int deviceId, @PathVariable int channelId, |
| | | @RequestBody com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration configuration) |
| | | { |
| | | try { |
| | | simulator.configureChannel(deviceId, channelId, configuration); |
| | | return simulator.snapshot(); |
| | | } |
| | | catch (IllegalArgumentException exception) { throw badRequest(exception); } |
| | | } |
| | | |
| | | /** 修改软件可访问性,设备配置继续保留。 */ |
| | | @PutMapping("/dev/bts-simulator/software") |
| | | public StateSnapshot software(@RequestBody SoftwareCommand command) |
| | |
| | | .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; } |
| | | dialog { width: min(940px, calc(100% - 24px)); max-height: calc(100% - 32px); padding: 0; border: 1px solid #bdc8d0; border-radius: 4px; color: #26323d; } |
| | | dialog::backdrop { background: rgb(0 0 0 / 35%); } |
| | | .editor-body { padding: 16px; } |
| | | .editor-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; } |
| | | .editor-scroll { overflow-x: auto; margin-top: 12px; } |
| | | .editor-scroll table { min-width: 700px; } |
| | | .editor-scroll td { padding: 6px; } |
| | | .editor-scroll th { padding: 8px 6px; white-space: nowrap; } |
| | | .editor-scroll select { width: 100%; } |
| | | .editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 16px; border-top: 1px solid #d5dde3; } |
| | | .icon-button { width: 30px; padding: 0; color: #52616c; background: #fff; border-color: #bdc8d0; } |
| | | .row-actions { display: flex; gap: 4px; } |
| | | .editor-error { margin: 0 0 12px; } |
| | | @media (max-width: 760px) { |
| | | header { align-items: flex-start; flex-direction: column; padding: 14px 16px; } |
| | | main { width: calc(100% - 20px); margin-top: 12px; } |
| | |
| | | th, td { padding: 8px; } |
| | | .request-time { width: 130px; } |
| | | .request-command { width: 90px; } |
| | | .editor-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| | | } |
| | | </style> |
| | | </head> |
| | |
| | | </table> |
| | | </section> |
| | | </main> |
| | | <dialog id="channel-editor" aria-labelledby="editor-title"> |
| | | <form id="editor-form"> |
| | | <div class="section-title"><h3 id="editor-title"></h3><button class="icon-button" type="button" id="editor-close" aria-label="关闭配置" title="关闭配置">×</button></div> |
| | | <div class="editor-body"><div id="editor-error" class="error editor-error" role="alert"></div><div id="editor-content"></div></div> |
| | | <div class="editor-actions"><button type="button" id="editor-cancel" class="danger">取消</button><button type="submit" id="editor-save">保存通道配置</button></div> |
| | | </form> |
| | | </dialog> |
| | | <script> |
| | | const api = '/dev/bts-simulator'; |
| | | const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' }; |
| | | const errorBox = document.querySelector('#error'); |
| | | const editor = document.querySelector('#channel-editor'); |
| | | const editorForm = document.querySelector('#editor-form'); |
| | | const editorContent = document.querySelector('#editor-content'); |
| | | const stepTypes = { cc: '恒流充电', dc: '恒流放电', cv: '恒压充电', rest: '搁置', end: '结束', cccv: '恒流恒压充电' }; |
| | | const metricLabels = { temperature: '温度', positive_potential: '正电位', negative_potential: '负电位' }; |
| | | let saveEditor = null; |
| | | document.querySelector('#editor-close').onclick = () => editor.close(); |
| | | document.querySelector('#editor-cancel').onclick = () => editor.close(); |
| | | editorForm.addEventListener('submit', async event => { |
| | | event.preventDefault(); |
| | | const save = document.querySelector('#editor-save'); |
| | | save.disabled = true; |
| | | try { const state = await saveEditor(); editor.close(); render(state); } |
| | | catch (error) { showError(error); } |
| | | finally { save.disabled = false; } |
| | | }); |
| | | |
| | | async function request(path, options) { |
| | | const response = await fetch(api + path, { ...options, headers: { 'Content-Type': 'application/json' } }); |
| | |
| | | function showError(error) { |
| | | errorBox.textContent = error instanceof Error ? error.message : '控制页请求失败'; |
| | | errorBox.classList.add('visible'); |
| | | if (editor.open) { |
| | | const message = document.querySelector('#editor-error'); |
| | | message.textContent = errorBox.textContent; |
| | | message.classList.add('visible'); |
| | | } |
| | | } |
| | | |
| | | function render(state) { |
| | |
| | | settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove); |
| | | |
| | | const table = document.createElement('table'); |
| | | table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th></tr></thead>'; |
| | | table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th><th>观测配置</th></tr></thead>'; |
| | | const body = document.createElement('tbody'); |
| | | body.append(...device.channels.map(channel => channelRow(device.deviceId, channel))); |
| | | table.append(body); |
| | |
| | | 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); |
| | | metricsCell.textContent = channel.metrics.map(item => `${metricLabels[item.metricCode] || item.metricCode} ${item.value} ${item.unit}`).join(' / '); |
| | | metricsCell.style.overflowWrap = 'anywhere'; |
| | | 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); } |
| | | }); |
| | | save.textContent = '编辑测量值'; |
| | | save.addEventListener('click', () => editCollectorChannel(collectorId, channel)); |
| | | action.append(save); |
| | | const statusCell = document.createElement('td'); |
| | | const status = document.createElement('select'); |
| | |
| | | statusCell.append(status); |
| | | row.append(identity, statusCell, 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) { |
| | |
| | | input.value = value; |
| | | label.append(text, input); |
| | | return { label, input }; |
| | | } |
| | | |
| | | function openEditor(title) { |
| | | editorContent.replaceChildren(); |
| | | document.querySelector('#editor-title').textContent = title; |
| | | document.querySelector('#editor-error').classList.remove('visible'); |
| | | editor.showModal(); |
| | | } |
| | | |
| | | function numericField(label, value, integer = false) { |
| | | const result = field(label, 'number', value); |
| | | result.input.required = true; |
| | | result.input.step = integer ? '1' : 'any'; |
| | | return result; |
| | | } |
| | | |
| | | function iconButton(symbol, label, action) { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; button.className = 'icon-button'; button.textContent = symbol; |
| | | button.title = label; button.setAttribute('aria-label', label); button.onclick = action; |
| | | return button; |
| | | } |
| | | |
| | | function editCabinetChannel(deviceId, channel) { |
| | | openEditor(`检测柜 ${deviceId} · 通道 ${channel.channelId} 参数与方案`); |
| | | const config = channel.configuration; |
| | | const fields = document.createElement('div'); fields.className = 'editor-fields'; |
| | | const inputs = {}; |
| | | for (const [key, label, integer] of [ |
| | | ['voltageV', '电压 (V)'], ['currentA', '电流 (A)'], ['capacityAh', '容量 (Ah)'], ['energyWh', '能量 (Wh)'], |
| | | ['cycleNo', '当前循环', true], ['stepNo', '当前工步', true], ['plannedCycleCount', '计划循环数', true], |
| | | ['totalTimeSeconds', '总时间 (s)', true], ['relativeTimeSeconds', '工步时间 (s)', true], |
| | | ]) { |
| | | const item = numericField(label, config[key], integer); |
| | | if (integer) item.input.min = key.endsWith('Seconds') ? '0' : '1'; |
| | | inputs[key] = item.input; fields.append(item.label); |
| | | } |
| | | const heading = document.createElement('div'); heading.className = 'section-title'; |
| | | const title = document.createElement('h3'); title.textContent = '工步方案'; |
| | | const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加工步'; |
| | | heading.append(title, add); |
| | | const scroll = document.createElement('div'); scroll.className = 'editor-scroll'; |
| | | const table = document.createElement('table'); |
| | | table.innerHTML = '<thead><tr><th style="width:50px">序号</th><th>工步名称</th><th style="width:140px">工步类型</th><th style="width:110px">时长 (s)</th><th style="width:100px">电压 (V)</th><th style="width:112px">操作</th></tr></thead>'; |
| | | const body = document.createElement('tbody'); table.append(body); scroll.append(table); |
| | | const steps = []; |
| | | function syncRows() { |
| | | body.replaceChildren(...steps.map((step, index) => { |
| | | step.number.textContent = index + 1; |
| | | step.up.disabled = index === 0; step.down.disabled = index === steps.length - 1; |
| | | step.remove.disabled = steps.length === 1; |
| | | return step.row; |
| | | })); |
| | | inputs.stepNo.max = String(steps.length); |
| | | if (Number(inputs.stepNo.value) > steps.length) inputs.stepNo.value = steps.length; |
| | | } |
| | | function addStep(value) { |
| | | const row = document.createElement('tr'); const number = document.createElement('td'); row.append(number); |
| | | const name = field('工步名称', 'text', value.name).input; name.required = true; name.setAttribute('aria-label', '工步名称'); |
| | | const type = document.createElement('select'); type.setAttribute('aria-label', '工步类型'); |
| | | Object.entries(stepTypes).forEach(([code, label]) => type.add(new Option(label, code))); type.value = value.stepType; |
| | | const duration = numericField('时长 (s)', value.durationSeconds).input; duration.min = '0'; duration.setAttribute('aria-label', '时长 (s)'); |
| | | const voltage = numericField('电压 (V)', value.voltageV).input; voltage.setAttribute('aria-label', '工步电压 (V)'); |
| | | for (const input of [name, type, duration, voltage]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); } |
| | | const step = { row, number, name, type, duration, voltage }; |
| | | const move = delta => { const index = steps.indexOf(step); steps.splice(index, 1); steps.splice(index + delta, 0, step); syncRows(); }; |
| | | step.up = iconButton('↑', '上移工步', () => move(-1)); |
| | | step.down = iconButton('↓', '下移工步', () => move(1)); |
| | | step.remove = iconButton('×', '删除工步', () => { steps.splice(steps.indexOf(step), 1); syncRows(); }); |
| | | const actions = document.createElement('td'); const buttons = document.createElement('div'); buttons.className = 'row-actions'; |
| | | buttons.append(step.up, step.down, step.remove); actions.append(buttons); row.append(actions); |
| | | steps.push(step); syncRows(); |
| | | } |
| | | config.steps.forEach(addStep); |
| | | // 初次填充方案行时保留保存过的当前工步位置。 |
| | | inputs.stepNo.value = config.stepNo; |
| | | add.onclick = () => addStep({ name: '新工步', stepType: 'rest', durationSeconds: 60, voltageV: 0 }); |
| | | editorContent.append(fields, heading, scroll); |
| | | saveEditor = () => request(`/devices/${deviceId}/channels/${channel.channelId}/configuration`, { |
| | | method: 'PUT', body: JSON.stringify({ |
| | | ...Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, Number(input.value)])), |
| | | steps: steps.map(step => ({ name: step.name.value, stepType: step.type.value, |
| | | durationSeconds: Number(step.duration.value), voltageV: Number(step.voltage.value) })), |
| | | }), |
| | | }); |
| | | } |
| | | |
| | | function editCollectorChannel(collectorId, channel) { |
| | | openEditor(`采集器 ${collectorId} · ${channel.channelCode} 测量值`); |
| | | const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加测量项'; |
| | | const scroll = document.createElement('div'); scroll.className = 'editor-scroll'; |
| | | const table = document.createElement('table'); table.style.minWidth = '560px'; |
| | | table.innerHTML = '<thead><tr><th>测量项</th><th>数值</th><th style="width:100px">单位</th><th style="width:50px">操作</th></tr></thead>'; |
| | | const body = document.createElement('tbody'); table.append(body); scroll.append(table); |
| | | const metrics = []; |
| | | function addMetric(item) { |
| | | const row = document.createElement('tr'); |
| | | const kind = document.createElement('select'); kind.setAttribute('aria-label', '测量项'); |
| | | Object.entries(metricLabels).forEach(([code, label]) => kind.add(new Option(label, code))); |
| | | kind.add(new Option('自定义', 'custom')); kind.value = metricLabels[item.metricCode] ? item.metricCode : 'custom'; |
| | | const code = field('自定义名称', 'text', item.metricCode).input; code.setAttribute('aria-label', '自定义名称'); |
| | | const value = numericField('数值', item.value).input; value.setAttribute('aria-label', '数值'); |
| | | const unit = field('单位', 'text', item.unit).input; unit.required = true; unit.setAttribute('aria-label', '单位'); |
| | | const updateKind = () => { |
| | | code.hidden = kind.value !== 'custom'; code.required = !code.hidden; |
| | | unit.readOnly = kind.value !== 'custom'; |
| | | if (kind.value !== 'custom') unit.value = kind.value === 'temperature' ? '℃' : 'V'; |
| | | }; |
| | | code.hidden = kind.value !== 'custom'; code.required = !code.hidden; |
| | | kind.onchange = updateKind; |
| | | const identity = document.createElement('td'); identity.append(kind, code); row.append(identity); |
| | | for (const input of [value, unit]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); } |
| | | const metric = { row, kind, code, value, unit }; |
| | | const action = document.createElement('td'); |
| | | metric.remove = iconButton('×', '删除测量项', () => { |
| | | metrics.splice(metrics.indexOf(metric), 1); row.remove(); syncButtons(); |
| | | }); |
| | | action.append(metric.remove); row.append(action); metrics.push(metric); body.append(row); syncButtons(); |
| | | } |
| | | function syncButtons() { metrics.forEach(item => { item.remove.disabled = metrics.length === 1; }); } |
| | | channel.metrics.forEach(addMetric); |
| | | add.onclick = () => addMetric({ metricCode: '', value: 0, unit: 'V' }); |
| | | editorContent.append(add, scroll); |
| | | saveEditor = () => { |
| | | const values = metrics.map(item => ({ metricCode: item.kind.value === 'custom' ? item.code.value.trim() : item.kind.value, |
| | | value: Number(item.value.value), unit: item.unit.value.trim() })); |
| | | if (new Set(values.map(item => item.metricCode)).size !== values.length) throw new Error('测量项不能重复'); |
| | | return request(`/collectors/${collectorId}/channels/${channel.channelCode}`, { |
| | | method: 'PUT', body: JSON.stringify({ metrics: values }), |
| | | }); |
| | | }; |
| | | } |
| | | |
| | | function channelRow(deviceId, channel) { |
| | |
| | | online.addEventListener('change', update); |
| | | status.addEventListener('change', update); |
| | | statusCell.append(status); |
| | | row.append(identity, onlineCell, statusCell); |
| | | const configCell = document.createElement('td'); |
| | | const configure = document.createElement('button'); configure.type = 'button'; configure.textContent = '参数与方案'; |
| | | configure.onclick = () => editCabinetChannel(deviceId, channel); |
| | | configCell.append(configure); |
| | | row.append(identity, onlineCell, statusCell, configCell); |
| | | return row; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | async function refresh() { |
| | | if (editor.open) return; |
| | | if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; |
| | | try { render(await request('/state')); } catch (error) { showError(error); } |
| | | } |
| | |
| | | class RedisStateStoreTest |
| | | { |
| | | @Test |
| | | void channelParametersAndPlanSurviveRedisRestartAndOnlineChanges() throws Exception |
| | | { |
| | | var stored = new AtomicReference<Object>(); |
| | | var configuration = new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration( |
| | | new java.math.BigDecimal("-3.123456"), new java.math.BigDecimal("8.125"), |
| | | new java.math.BigDecimal("7.2"), new java.math.BigDecimal("29.7"), 2, 2, 5, 120, 30, |
| | | List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2), |
| | | new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电 <验证>", "dc", 90, 3.0))); |
| | | try (var simulator = new BtsDevelopmentSimulator(0, store(stored))) { |
| | | simulator.configureChannel(1, 1, configuration); |
| | | simulator.updateChannel(1, 1, false, DeviceWorkStatus.PAUSED); |
| | | simulator.setAllDevicesPoweredOn(false); |
| | | simulator.setSoftwareAccessible(false); |
| | | } |
| | | try (var simulator = new BtsDevelopmentSimulator(0, store(stored))) { |
| | | assertEquals(configuration, simulator.snapshot().devices().get(0).channels().get(0).configuration()); |
| | | simulator.setSoftwareAccessible(true); |
| | | simulator.setAllDevicesPoweredOn(true); |
| | | simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING); |
| | | var identity = simulator.discoverChannels().get(0).identity(); |
| | | assertEquals(-3.123456, simulator.latestCabinet(identity).currentA()); |
| | | assertEquals(8.125, simulator.latestCabinet(identity).voltageV()); |
| | | assertEquals("放电 <验证>", simulator.testPlan(identity).rows().get(1).stepName()); |
| | | assertEquals("进行中", simulator.testPlan(identity).rows().get(1).executionStatus()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void oldChannelStateReceivesDefaultConfigurationWithoutResettingIdentity() |
| | | { |
| | | var object = JSON.parseObject(JSON.toJSONString(state(7, "127.0.0.9"))); |
| | | object.getJSONArray("devices").getJSONObject(0).getJSONArray("channels").getJSONObject(0).remove("configuration"); |
| | | var restored = store(new AtomicReference<>(object.toJSONString())).loadOrInitialize(state(1, "127.0.0.2")); |
| | | assertEquals(7, restored.devices().get(0).deviceId()); |
| | | assertEquals("127.0.0.9", restored.devices().get(0).ip()); |
| | | assertEquals(1, restored.devices().get(0).channels().get(0).configuration().steps().size()); |
| | | } |
| | | |
| | | @Test |
| | | void initializesOnlyWhenStateIsAbsentAndRestoresSavedState() |
| | | { |
| | | AtomicReference<Object> stored = new AtomicReference<>(); |
| | |
| | | class TTestLiveServiceImplTest |
| | | { |
| | | @Test |
| | | void consoleReadsConfiguredSimulatorPlanAtCurrentChannelPosition() throws Exception |
| | | { |
| | | try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0)) { |
| | | var test = test("RECEIVING"); |
| | | var binding = test.getBatteries().get(0).getBinding(); |
| | | binding.setDevtype(22); binding.setSubdevid(1); |
| | | simulator.configureChannel(1, 1, new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration( |
| | | BigDecimal.ONE, new BigDecimal("4.125"), BigDecimal.ONE, BigDecimal.ONE, 2, 2, 3, 120, 30, |
| | | List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2), |
| | | new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电", "dc", 90, 3.0)))); |
| | | simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING); |
| | | var tests = mock(ITTestService.class); |
| | | when(tests.selectTestById(10L)).thenReturn(test); |
| | | var service = new TTestLiveServiceImpl(tests, |
| | | new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), simulator); |
| | | var plan = service.current(10L).testPlan(); |
| | | assertEquals(2, plan.currentCycleNo()); |
| | | assertEquals(2, plan.currentStepNo()); |
| | | assertEquals(3, plan.plannedCycleCount()); |
| | | assertEquals(List.of(1, 2), plan.rows().stream().map(row -> row.cyclePosition()).toList()); |
| | | assertEquals("放电", plan.rows().get(1).stepName()); |
| | | assertEquals(90d, plan.rows().get(1).durationSeconds()); |
| | | assertEquals("进行中", plan.rows().get(1).executionStatus()); |
| | | simulator.updateChannel(1, 1, false, DeviceWorkStatus.WORKING); |
| | | org.junit.jupiter.api.Assertions.assertNull(service.current(10L).testPlan()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void offlineHintIsIndependentOfHistoricalHandlingStatusAndSurvivesRefresh() |
| | | { |
| | | TTest test = test("RECEIVING"); |
| | |
| | | class BtsDevelopmentSimulatorControllerTest |
| | | { |
| | | @Test |
| | | void invalidChannelConfigurationDoesNotReplaceSavedValues() throws Exception |
| | | { |
| | | try (var simulator = new BtsDevelopmentSimulator(0)) { |
| | | var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build(); |
| | | var before = simulator.snapshot().devices().get(0).channels().get(0).configuration(); |
| | | String valid = com.alibaba.fastjson2.JSON.toJSONString(before); |
| | | for (String property : List.of("stepNo", "cycleNo", "currentA", "steps")) { |
| | | var invalid = com.alibaba.fastjson2.JSON.parseObject(valid); |
| | | invalid.put(property, switch (property) { |
| | | case "stepNo" -> 2; |
| | | case "cycleNo" -> 0; |
| | | case "steps" -> List.of(); |
| | | default -> null; |
| | | }); |
| | | mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put( |
| | | "/dev/bts-simulator/devices/1/channels/1/configuration") |
| | | .contentType("application/json").content(invalid.toJSONString())) |
| | | .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest()); |
| | | assertEquals(before, simulator.snapshot().devices().get(0).channels().get(0).configuration()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void channelConfigurationChangesMeasurementsAndPlanOnlyForSelectedChannel() throws Exception |
| | | { |
| | | try (var simulator = new BtsDevelopmentSimulator(0)) { |
| | | var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build(); |
| | | mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put( |
| | | "/dev/bts-simulator/devices/1/channels/1/configuration") |
| | | .contentType("application/json").content(""" |
| | | {"currentA":-2.5,"voltageV":4.125,"capacityAh":8.2,"energyWh":33.8, |
| | | "cycleNo":2,"stepNo":2,"plannedCycleCount":3,"totalTimeSeconds":120,"relativeTimeSeconds":30, |
| | | "steps":[{"name":"充电","stepType":"cc","durationSeconds":60,"voltageV":4.2}, |
| | | {"name":"放电","stepType":"dc","durationSeconds":90,"voltageV":3.0}]} |
| | | """)) |
| | | .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()); |
| | | var identity = simulator.discoverChannels().get(0).identity(); |
| | | var actual = simulator.latestCabinet(identity); |
| | | assertEquals(-2.5, actual.currentA()); |
| | | assertEquals(4.125, actual.voltageV()); |
| | | assertEquals(8.2, actual.capacityAh()); |
| | | assertEquals(33.8, actual.energyWh()); |
| | | assertEquals(2, actual.cycleNo()); |
| | | assertEquals(2, actual.stepNo()); |
| | | assertEquals("dc", actual.stepType()); |
| | | assertEquals("放电", actual.stepName()); |
| | | assertEquals(2, simulator.testPlan(identity).rows().size()); |
| | | assertEquals(3, simulator.testPlan(identity).plannedCycleCount()); |
| | | var other = simulator.discoverChannels().get(1).identity(); |
| | | assertEquals(1, simulator.testPlan(other).rows().size()); |
| | | assertEquals(1, simulator.latestCabinet(other).stepNo()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void restChangesSoftwareAndCollectorChannelFactsAndRetiresPlayback() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |