| | |
| | | import java.time.ZoneId; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import javax.xml.stream.XMLInputFactory; |
| | | import javax.xml.stream.XMLStreamConstants; |
| | | import javax.xml.stream.XMLStreamReader; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.api.model.DeviceConnectionStatus; |
| | | import com.battery.device.api.model.DeviceIdentity; |
| | |
| | | import com.battery.device.api.model.DetectedDeviceMetadata; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | | import com.battery.device.api.DeviceObservationAdapter; |
| | | 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; |
| | |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation; |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot; |
| | | |
| | | /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */ |
| | | public final class BtsDevelopmentSimulator implements TestDataObservationAdapter |
| | | /** |
| | | * 提供由开发后端托管的临时只读 BTS 模拟端点。 |
| | | * 同时实现发现与测试数据观测契约:模拟器直接暴露自身状态,因此发现与取数按同一实例解析, |
| | | * 不再需要单独注入一个扩展观测 Bean。TCP 端点仍保留给协议层测试使用。 |
| | | */ |
| | | public final class BtsDevelopmentSimulator implements DeviceAdapter, TestDataObservationAdapter |
| | | { |
| | | public static final String HOST = "127.0.0.1"; |
| | | public static final int DEFAULT_PORT = 15020; |
| | |
| | | private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong"); |
| | | private static final DateTimeFormatter DEVICE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); |
| | | |
| | | /** 返回固定的适配器实例代码,用于把模拟观测归属到 dev-sim 这一实例。 */ |
| | | @Override |
| | | public String adapterCode() |
| | | { |
| | | return ADAPTER_CODE; |
| | | } |
| | | |
| | | /** 返回只观测能力:支持发现与实时观测,不支持历史下载,也不支持向设备下发控制命令。 */ |
| | | @Override |
| | | public AdapterCapabilities capabilities() |
| | | { |
| | |
| | | return java.util.Map.copyOf(result); |
| | | } |
| | | |
| | | /** 直接提供模拟器实时快照;不可观测通道不出现在结果里,与 BTS 协议解析后的语义一致。 */ |
| | | @Override |
| | | public synchronized Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels) |
| | | { |
| | | if (channels == null || channels.isEmpty()) return Map.of(); |
| | | Map<ChannelIdentity, RealtimeSnapshot> result = new LinkedHashMap<>(); |
| | | for (ChannelIdentity requested : channels) |
| | | { |
| | | if (!canObserve(requested)) continue; |
| | | CabinetRealtime measurement = cabinetMeasurement(requested); |
| | | if (measurement == null) continue; |
| | | DeviceObservation observed = observation(requested); |
| | | result.put(requested, new RealtimeSnapshot(requested, observed.workStatus(), measurement.cycleNo(), |
| | | measurement.stepNo(), measurement.stepType(), null, BigDecimal.valueOf(measurement.currentA()), |
| | | BigDecimal.valueOf(measurement.voltageV()), BigDecimal.valueOf(measurement.capacityAh()), |
| | | BigDecimal.valueOf(measurement.energyWh()), totalTimeSeconds(requested), |
| | | measurement.relativeTimeSeconds(), null, measurement.serverReceivedAt(), Map.of(), |
| | | DeviceConnectionStatus.ONLINE, measurement.deviceTime(), List.of(), null)); |
| | | } |
| | | recordRequest("inquire", channels.toString(), result.toString()); |
| | | return Map.copyOf(result); |
| | | } |
| | | |
| | | private long totalTimeSeconds(ChannelIdentity identity) |
| | | { |
| | | return requireDevice(identity.devid()).channels().get(identity.chlid() - 1).configuration().totalTimeSeconds(); |
| | | } |
| | | |
| | | private DeviceObservation observation(ChannelIdentity requested) |
| | | { |
| | | DeviceState device = state.devices().stream().filter(item -> item.deviceId() == requested.devid()) |
| | |
| | | simulator = ScriptedBtsTcpSimulator.responding(port, this::respond); |
| | | } |
| | | |
| | | /** 返回模拟器 TCP 端点实际监听的端口。 */ |
| | | public int port() |
| | | { |
| | | return simulator.port(); |
| | |
| | | state.nextCollectorId(), tombstones)); |
| | | } |
| | | |
| | | /** 使用现场确认的新威检测柜 profile 新增设备,型号之外的控制语义仍保持只读。 */ |
| | | /** 按预设新威型号元数据新增模拟设备;真实协议和拓扑仍待现场验证。 */ |
| | | public synchronized int addNewareDevice(String ip, String model) |
| | | { |
| | | BtsDeviceProfile profile = newareProfile(model); |
| | |
| | | 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 检测柜拓扑隔离的开发采集器。 */ |
| | |
| | | persist(next); |
| | | } |
| | | |
| | | /** 配置一个稳定 S 通道的固定或动态演示指标。 */ |
| | | /** 保存稳定 S 通道的固定测量值及自定义指标,下一次请求时形成观测。 */ |
| | | public synchronized void configureCollectorChannel(int collectorId, String channelCode, |
| | | List<CollectorMetricState> metrics) |
| | | { |
| | |
| | | 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(), configuration.relativeTimeSeconds()); |
| | | return result; |
| | | } |
| | | |
| | | /** 请求采集器当前配置值;软件不可访问或设备离线时返回空测量。 */ |
| | | @Override |
| | | public synchronized CollectorRealtime latestCollector(String collectorCode) |
| | | public synchronized CollectorRealtime latestCollector(String adapterCode, String collectorCode) |
| | | { |
| | | CollectorState collector = state.collectors().stream().filter(item -> item.deviceCode().equals(collectorCode)) |
| | | .findFirst().orElse(null); |
| | |
| | | return sequence; |
| | | } |
| | | |
| | | /** 返回控制页需要的运行状态快照。 */ |
| | | /** 返回模拟器配置、当前状态和最近请求,不触发新的设备观测。 */ |
| | | public synchronized StateSnapshot snapshot() |
| | | { |
| | | List<DeviceSnapshot> deviceSnapshots = state.devices().stream() |
| | | .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), device.profile(), |
| | | device.channels().stream() |
| | | .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(), |
| | | channel.workStatus())) |
| | | 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) |
| | |
| | | |
| | | private void persist(SimulatorState nextState) |
| | | { |
| | | // 写穿失败时保留当前内存副本,避免控制页显示未持久化状态。 |
| | | // 写穿失败时保留当前内存副本,避免模拟器页面显示未持久化状态。 |
| | | SimulatorState configured = new SimulatorState(nextState.version(), nextState.devices(), nextState.collectors(), |
| | | nextState.nextDeviceId(), nextState.nextCollectorId(), nextState.tombstones(), |
| | | state.softwareAccessible(), state.observationSequence()); |
| | |
| | | throw new IllegalStateException("Invalid BTS simulator numbering or tombstone state in Redis"); |
| | | } |
| | | |
| | | /** 停止模拟器:标记为不再运行并释放 TCP 端点;释放失败时以运行时异常报告。 */ |
| | | @Override |
| | | public synchronized void close() |
| | | { |
| | |
| | | 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, |