| | |
| | | |
| | | import java.io.IOException; |
| | | import java.io.StringReader; |
| | | import java.math.BigDecimal; |
| | | import java.time.Instant; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | |
| | | import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement; |
| | | import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState; |
| | | import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState; |
| | | import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile; |
| | | import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest; |
| | | import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState; |
| | | import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore; |
| | |
| | | public static final String ADAPTER_CODE = "dev-sim"; |
| | | public static final String ADAPTER_NAME = "dev-sim BTS 模拟器"; |
| | | public static final int POLL_INTERVAL_MS = 5000; |
| | | public static final String NEWARE_CT_4008T_5V6A_S1_F = "CT-4008T-5V6A-S1-F"; |
| | | public static final String NEWARE_CT_4008TN_5V12A_S1_F = "CT-4008Tn-5V12A-S1-F"; |
| | | |
| | | private static final int DEVICE_TYPE = 22; |
| | | private static final int SUB_DEVICE_ID = 1; |
| | | private static final int MAX_CHANNEL_COUNT = 99; |
| | | private static final int MAX_RECENT_REQUESTS = 20; |
| | | private static final int TOMBSTONE_DISCOVERY_COUNT = 3; |
| | |
| | | private final BtsSimulatorStateStore stateStore; |
| | | private SimulatorState state; |
| | | private boolean running = true; |
| | | private long observationSequence; |
| | | |
| | | /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */ |
| | | public BtsDevelopmentSimulator(int port) throws IOException |
| | |
| | | { |
| | | this.stateStore = stateStore; |
| | | state = stateStore.loadOrInitialize(defaultState()); |
| | | state = stateStore.resetHistoryForDemoStartup(state); |
| | | validateRestoredState(); |
| | | stateStore.completeValidatedRestore(state); |
| | | simulator = ScriptedBtsTcpSimulator.responding(port, this::respond); |
| | |
| | | validateDeviceConfiguration(ip, channelCount, null); |
| | | int deviceId = state.nextDeviceId(); |
| | | List<DeviceState> devices = new ArrayList<>(state.devices()); |
| | | devices.add(newDevice(deviceId, ip, channelCount)); |
| | | devices.add(newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount))); |
| | | persist(new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, devices, state.collectors(), |
| | | deviceId + 1, state.nextCollectorId(), state.tombstones())); |
| | | return deviceId; |
| | |
| | | ? current.channels().get(channelId - 1) |
| | | : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); |
| | | } |
| | | List<DeviceState> devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels)); |
| | | List<DeviceState> devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels, |
| | | resizeProfile(current.profile(), channelCount))); |
| | | List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones()); |
| | | for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++) |
| | | upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId); |
| | | tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() <= channelCount); |
| | | persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(), |
| | | state.nextCollectorId(), tombstones)); |
| | | } |
| | | |
| | | /** 使用现场确认的新威检测柜 profile 新增设备,型号之外的控制语义仍保持只读。 */ |
| | | public synchronized int addNewareDevice(String ip, String model) |
| | | { |
| | | BtsDeviceProfile profile = newareProfile(model); |
| | | validateDeviceConfiguration(ip, profile.channelCount(), null); |
| | | int deviceId = state.nextDeviceId(); |
| | | List<DeviceState> devices = new ArrayList<>(state.devices()); |
| | | devices.add(newDevice(deviceId, ip, profile)); |
| | | persist(new SimulatorState(state.version(), devices, state.collectors(), deviceId + 1, |
| | | state.nextCollectorId(), state.tombstones())); |
| | | return deviceId; |
| | | } |
| | | |
| | | /** 将已有模拟设备切换为新威 profile,并按 profile 通道数调整拓扑。 */ |
| | | public synchronized void configureNewareProfile(int deviceId, String model) |
| | | { |
| | | DeviceState current = requireDevice(deviceId); |
| | | BtsDeviceProfile profile = newareProfile(model); |
| | | updateDeviceWithProfile(current, profile); |
| | | } |
| | | |
| | | /** 删除指定开发设备;后续发现响应不再返回它。 */ |
| | |
| | | List<ChannelState> channels = new ArrayList<>(device.channels()); |
| | | channels.set(channelId - 1, new ChannelState(channelId, online, workStatus)); |
| | | persist(new SimulatorState(state.version(), |
| | | replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels)), state.collectors(), |
| | | replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(), |
| | | state.nextDeviceId(), state.nextCollectorId(), state.tombstones())); |
| | | } |
| | | |
| | |
| | | device.channels().stream() |
| | | .map(channel -> new ChannelState(channel.channelId(), poweredOn, |
| | | DeviceWorkStatus.STOPPED)) |
| | | .toList())) |
| | | .toList(), device.profile())) |
| | | .toList(); |
| | | Instant changedAt = Instant.now(); |
| | | List<CollectorState> collectors = state.collectors().stream() |
| | |
| | | public synchronized StateSnapshot snapshot() |
| | | { |
| | | List<DeviceSnapshot> deviceSnapshots = state.devices().stream() |
| | | .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), |
| | | .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())) |
| | |
| | | |
| | | private DeviceState newDevice(int deviceId, String ip, int channelCount) |
| | | { |
| | | List<ChannelState> channels = new ArrayList<>(channelCount); |
| | | for (int channelId = 1; channelId <= channelCount; channelId++) |
| | | return newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount)); |
| | | } |
| | | |
| | | private DeviceState newDevice(int deviceId, String ip, BtsDeviceProfile profile) |
| | | { |
| | | List<ChannelState> channels = new ArrayList<>(profile.channelCount()); |
| | | for (int channelId = 1; channelId <= profile.channelCount(); channelId++) |
| | | channels.add(new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); |
| | | return new DeviceState(deviceId, ip.trim(), true, channels); |
| | | return new DeviceState(deviceId, ip.trim(), true, channels, profile); |
| | | } |
| | | |
| | | private CollectorState newCollector(int collectorId, String ip, int channelCount) |
| | |
| | | return switch (command) |
| | | { |
| | | case "getdevinfo" -> deviceInfoResponse(); |
| | | case "inquire" -> observationResponse(); |
| | | case "inquire" -> observationResponse(request); |
| | | default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>"; |
| | | }; |
| | | } |
| | |
| | | boolean available) |
| | | { |
| | | // 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。 |
| | | response.append("<channel ip=\"").append(ip).append("\" devtype=\"").append(DEVICE_TYPE) |
| | | .append("\" devid=\"").append(deviceId).append("\" subdevid=\"").append(SUB_DEVICE_ID) |
| | | .append("\" Channelid=\"").append(channelId).append("\" name=\"dev-sim 设备 ") |
| | | .append(deviceId).append("\" manufacturer=\"开发模拟器\" model=\"BTS Virtual Cabinet\" mac=\"") |
| | | .append(simulatorMac(DEVICE_TYPE, deviceId)).append("\">") |
| | | DeviceState device = state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst().orElse(null); |
| | | BtsDeviceProfile profile = device == null ? BtsDeviceProfile.virtual(channelId) : profileFor(device); |
| | | response.append("<channel ip=\"").append(ip).append("\" devtype=\"").append(profile.devtype()) |
| | | .append("\" devid=\"").append(deviceId).append("\" subdevid=\"").append(profile.subDeviceId()) |
| | | .append("\" Channelid=\"").append(channelId).append("\" name=\"").append(profile.name()) |
| | | .append("\" manufacturer=\"").append(profile.manufacturer()).append("\" model=\"") |
| | | .append(profile.model()).append("\" serialNumber=\"").append(profile.serialNumber()) |
| | | .append("\" firmwareVersion=\"").append(profile.firmwareVersion()).append("\" mac=\"") |
| | | .append(simulatorMac(profile.devtype(), deviceId)).append("\">") |
| | | .append(available).append("</channel>"); |
| | | } |
| | | |
| | | private synchronized String observationResponse() |
| | | private synchronized String observationResponse(String request) |
| | | { |
| | | long tick = ++observationSequence; |
| | | java.util.Set<String> requested = requestedChannels(request); |
| | | List<ObservedChannel> available = new ArrayList<>(); |
| | | for (DeviceState device : state.devices()) |
| | | for (ChannelState channel : device.channels()) |
| | | if (device.online() && channel.online()) available.add(new ObservedChannel(device.deviceId(), channel)); |
| | | if (device.online() && channel.online() |
| | | && (requested.isEmpty() || requested.contains(device.deviceId() + ":" + channel.channelId()))) |
| | | available.add(new ObservedChannel(device.deviceId(), channel)); |
| | | |
| | | StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"") |
| | | .append(available.size()).append("\">"); |
| | | for (ObservedChannel observed : available) |
| | | { |
| | | ChannelState channel = observed.channel(); |
| | | response.append("<inquire dev=\"").append(DEVICE_TYPE).append('-').append(observed.deviceId()) |
| | | .append('-').append(SUB_DEVICE_ID).append('-').append(channel.channelId()) |
| | | DeviceState device = requireDevice(observed.deviceId()); |
| | | BtsDeviceProfile profile = profileFor(device); |
| | | boolean working = channel.workStatus() == DeviceWorkStatus.WORKING; |
| | | double wave = Math.sin((tick + channel.channelId()) * 0.7d); |
| | | double current = working ? clamp(profile.currentMaxA().doubleValue() * (0.18d + wave * 0.02d), |
| | | profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d; |
| | | double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(), |
| | | profile.voltageMaxV().doubleValue()); |
| | | double capacity = working ? Math.max(0d, tick * 0.001d * current) : 0d; |
| | | double energy = capacity * voltage; |
| | | response.append("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId()) |
| | | .append('-').append(profile.subDeviceId()).append('-').append(channel.channelId()) |
| | | .append("-0\" cycle_id=\"1\" step_id=\"1\" step_type=\"cc\" workstatus=\"") |
| | | .append(toBtsWorkStatus(channel.workStatus())) |
| | | .append("\" current=\"0\" voltage=\"3.700\" capacity=\"0\" energy=\"0\" ") |
| | | .append("totaltime=\"0\" relativetime=\"0\" open_or_close=\"0\" log_code=\"0\"/>"); |
| | | .append("\" current=\"").append(decimal(current)).append("\" voltage=\"") |
| | | .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity)) |
| | | .append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"") |
| | | .append(working ? tick : 0).append("\" relativetime=\"").append(working ? tick : 0) |
| | | .append("\" open_or_close=\"0\" log_code=\"0\"/>"); |
| | | } |
| | | return response.append("</list></bts>").toString(); |
| | | } |
| | | |
| | | private java.util.Set<String> requestedChannels(String request) |
| | | { |
| | | java.util.Set<String> channels = new java.util.HashSet<>(); |
| | | try |
| | | { |
| | | XMLInputFactory factory = XMLInputFactory.newFactory(); |
| | | factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); |
| | | factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); |
| | | XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request)); |
| | | while (reader.hasNext()) |
| | | { |
| | | if (reader.next() == XMLStreamConstants.START_ELEMENT && "inquire".equals(reader.getLocalName())) |
| | | { |
| | | String devid = reader.getAttributeValue(null, "devid"); |
| | | String chlid = reader.getAttributeValue(null, "chlid"); |
| | | if (devid != null && chlid != null) channels.add(devid + ":" + chlid); |
| | | } |
| | | } |
| | | return channels; |
| | | } |
| | | catch (Exception exception) |
| | | { |
| | | throw new IllegalArgumentException("Invalid BTS inquire XML", exception); |
| | | } |
| | | } |
| | | |
| | | private BtsDeviceProfile profileFor(DeviceState device) |
| | | { |
| | | return device.profile() == null ? BtsDeviceProfile.virtual(device.channels().size()) : device.profile(); |
| | | } |
| | | |
| | | private BtsDeviceProfile resizeProfile(BtsDeviceProfile profile, int channelCount) |
| | | { |
| | | return new BtsDeviceProfile(profile.devtype(), profile.subDeviceId(), profile.name(), profile.manufacturer(), |
| | | profile.model(), profile.serialNumber(), profile.firmwareVersion(), channelCount, |
| | | profile.currentMinA(), profile.currentMaxA(), profile.voltageMinV(), profile.voltageMaxV(), |
| | | profile.sampleIntervalMs()); |
| | | } |
| | | |
| | | private void updateDeviceWithProfile(DeviceState current, BtsDeviceProfile profile) |
| | | { |
| | | int channelCount = profile.channelCount(); |
| | | List<ChannelState> channels = new ArrayList<>(channelCount); |
| | | for (int channelId = 1; channelId <= channelCount; channelId++) |
| | | channels.add(channelId <= current.channels().size() ? current.channels().get(channelId - 1) |
| | | : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED)); |
| | | List<DeviceState> devices = replaceDevice(new DeviceState(current.deviceId(), current.ip(), current.online(), |
| | | channels, profile)); |
| | | List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones()); |
| | | for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++) |
| | | upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId); |
| | | tombstones.removeIf(item -> item.deviceId() == current.deviceId() && item.channelId() <= channelCount); |
| | | persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(), |
| | | state.nextCollectorId(), tombstones)); |
| | | } |
| | | |
| | | private BtsDeviceProfile newareProfile(String model) |
| | | { |
| | | if (NEWARE_CT_4008T_5V6A_S1_F.equals(model)) |
| | | return new BtsDeviceProfile(22, 1, "新威 CT-4008T", "新威", model, "SIM-CT4008T-5V6A", |
| | | "BTS7.5.4", 8, new BigDecimal("-6.000"), new BigDecimal("6.000"), BigDecimal.ZERO, |
| | | new BigDecimal("5.000"), 100); |
| | | if (NEWARE_CT_4008TN_5V12A_S1_F.equals(model)) |
| | | return new BtsDeviceProfile(22, 1, "新威 CT-4008Tn", "新威", model, "SIM-CT4008TN-5V12A", |
| | | "BTS8.0", 8, new BigDecimal("-12.000"), new BigDecimal("12.000"), BigDecimal.ZERO, |
| | | new BigDecimal("5.000"), 100); |
| | | 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); |
| | | } |
| | | |
| | | private String toBtsWorkStatus(DeviceWorkStatus status) |
| | |
| | | || !ips.add(device.ip()) || device.channels().isEmpty() |
| | | || device.channels().size() > MAX_CHANNEL_COUNT) |
| | | throw new IllegalStateException("Invalid BTS simulator device state in Redis"); |
| | | if (profileFor(device).channelCount() != device.channels().size()) |
| | | throw new IllegalStateException("BTS simulator device profile channel count does not match topology"); |
| | | maximumDeviceId = Math.max(maximumDeviceId, device.deviceId()); |
| | | for (int index = 0; index < device.channels().size(); index++) |
| | | { |
| | |
| | | public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices, |
| | | List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { } |
| | | |
| | | public record DeviceSnapshot(int deviceId, String ip, boolean online, List<ChannelSnapshot> channels) { } |
| | | public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile, |
| | | List<ChannelSnapshot> channels) { } |
| | | |
| | | public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { } |
| | | |