package com.battery.device.simulator;
|
|
import java.io.IOException;
|
import java.io.StringReader;
|
import java.math.BigDecimal;
|
import java.time.Instant;
|
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.DeviceObservation;
|
import com.battery.device.api.model.DetectedChannel;
|
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.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
|
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
|
import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
|
import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile;
|
import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest;
|
import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
|
import com.battery.device.simulator.InMemoryBtsSimulatorStateStore;
|
import com.battery.device.simulator.DevelopmentCollectorSimulation;
|
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
|
|
/**
|
* 提供由开发后端托管的临时只读 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;
|
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 MAX_CHANNEL_COUNT = 99;
|
private static final int MAX_RECENT_REQUESTS = 20;
|
private static final int TOMBSTONE_DISCOVERY_COUNT = 3;
|
|
private final ScriptedBtsTcpSimulator simulator;
|
private final BtsSimulatorStateStore stateStore;
|
private SimulatorState state;
|
private boolean running = true;
|
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");
|
|
@Override
|
public String adapterCode()
|
{
|
return ADAPTER_CODE;
|
}
|
|
@Override
|
public AdapterCapabilities capabilities()
|
{
|
return AdapterCapabilities.observeOnly();
|
}
|
|
/** 直接提供模拟器当前拓扑,供不需要 TCP/XML 的契约测试使用。 */
|
@Override
|
public synchronized List<DetectedChannel> discoverChannels()
|
{
|
List<DetectedChannel> result = new ArrayList<>();
|
for (DeviceState device : state.devices())
|
{
|
BtsDeviceProfile profile = profileFor(device);
|
for (ChannelState channel : device.channels())
|
{
|
ChannelIdentity identity = new ChannelIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(),
|
profile.subDeviceId(), channel.channelId());
|
result.add(new DetectedChannel(identity, device.ip(), state.softwareAccessible() && device.online() && channel.online(),
|
new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(),
|
simulatorMac(profile.devtype(), device.deviceId()))));
|
}
|
}
|
recordRequest("discover", ADAPTER_CODE, result.toString());
|
return List.copyOf(result);
|
}
|
|
/** 直接提供模拟器观测;离线通道不保留上一次测量或工步事实。 */
|
@Override
|
public synchronized java.util.Map<ChannelIdentity, DeviceObservation> observe(
|
java.util.Set<ChannelIdentity> channels)
|
{
|
if (channels == null || channels.isEmpty()) return java.util.Map.of();
|
java.util.Map<ChannelIdentity, DeviceObservation> result = new java.util.LinkedHashMap<>();
|
for (ChannelIdentity requested : channels) result.put(requested, observation(requested));
|
recordRequest("observe", channels.toString(), result.toString());
|
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())
|
.findFirst().orElse(null);
|
ChannelState channel = device == null ? null : device.channels().stream()
|
.filter(item -> item.channelId() == requested.chlid()).findFirst().orElse(null);
|
if (!state.softwareAccessible() || device == null || channel == null
|
|| !ADAPTER_CODE.equals(requested.adapterCode()) || !device.online() || !channel.online()
|
|| requested.devtype() != profileFor(device).devtype()
|
|| requested.subdevid() != profileFor(device).subDeviceId())
|
return DeviceObservation.offline(requested, failureReason());
|
return new DeviceObservation(new DeviceIdentity(ADAPTER_CODE, requested.devtype(), requested.devid(),
|
requested.subdevid()), requested, DeviceConnectionStatus.ONLINE, channel.workStatus(),
|
List.of(), null, java.util.Map.of(), null);
|
}
|
|
/** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */
|
public BtsDevelopmentSimulator(int port) throws IOException
|
{
|
this(port, new InMemoryBtsSimulatorStateStore());
|
}
|
|
/** 从长期状态恢复后再启动 TCP 服务,避免服务先暴露默认拓扑。 */
|
public BtsDevelopmentSimulator(int port, BtsSimulatorStateStore stateStore) throws IOException
|
{
|
this.stateStore = stateStore;
|
state = stateStore.loadOrInitialize(defaultState());
|
validateRestoredState();
|
simulator = ScriptedBtsTcpSimulator.responding(port, this::respond);
|
}
|
|
public int port()
|
{
|
return simulator.port();
|
}
|
|
/** 新增一台具有独立虚拟 IP 和通道数的开发设备。 */
|
public synchronized int addDevice(String ip, int channelCount)
|
{
|
validateDeviceConfiguration(ip, channelCount, null);
|
int deviceId = state.nextDeviceId();
|
List<DeviceState> devices = new ArrayList<>(state.devices());
|
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;
|
}
|
|
/** 修改开发设备的虚拟地址、通道数和在线状态。 */
|
public synchronized void updateDevice(int deviceId, String ip, int channelCount, boolean online)
|
{
|
DeviceState current = requireDevice(deviceId);
|
validateDeviceConfiguration(ip, channelCount, deviceId);
|
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(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));
|
}
|
|
/** 按预设新威型号元数据新增模拟设备;真实协议和拓扑仍待现场验证。 */
|
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);
|
}
|
|
/** 删除指定开发设备;后续发现响应不再返回它。 */
|
public synchronized void deleteDevice(int deviceId)
|
{
|
DeviceState removed = requireDevice(deviceId);
|
List<DeviceState> devices = new ArrayList<>(state.devices());
|
devices.removeIf(item -> item.deviceId() == deviceId);
|
List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
|
for (ChannelState channel : removed.channels())
|
{
|
upsertTombstone(tombstones, removed.deviceId(), removed.ip(), channel.channelId());
|
}
|
persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
|
state.nextCollectorId(), tombstones));
|
}
|
|
/** 修改指定开发设备的单个通道状态。 */
|
public synchronized void updateChannel(int deviceId, int channelId, boolean online,
|
DeviceWorkStatus workStatus)
|
{
|
requireControllableStatus(workStatus);
|
DeviceState device = requireDevice(deviceId);
|
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.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 检测柜拓扑隔离的开发采集器。 */
|
public synchronized int addCollector(String ip, int channelCount)
|
{
|
validateCollectorConfiguration(ip, channelCount, null);
|
int collectorId = state.nextCollectorId();
|
List<CollectorState> collectors = new ArrayList<>(state.collectors());
|
collectors.add(newCollector(collectorId, ip, channelCount));
|
persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
|
collectorId + 1, state.tombstones()));
|
return collectorId;
|
}
|
|
/** 修改开发采集器的虚拟地址、通道数和在线状态。 */
|
public synchronized void updateCollector(int collectorId, String ip, int channelCount, boolean online)
|
{
|
validateCollectorConfiguration(ip, channelCount, collectorId);
|
CollectorState current = requireCollector(collectorId);
|
List<CollectorState> collectors = new ArrayList<>(state.collectors());
|
collectors.replaceAll(item -> item.collectorId() == collectorId
|
? DevelopmentCollectorSimulation.resize(current, ip, channelCount, online, Instant.now())
|
: item);
|
persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
|
state.nextCollectorId(), state.tombstones()));
|
}
|
|
/** 删除指定开发采集器,不影响检测柜 TCP 发现结果。 */
|
public synchronized void deleteCollector(int collectorId)
|
{
|
CollectorState collector = requireCollector(collectorId);
|
List<CollectorState> collectors = new ArrayList<>(state.collectors());
|
collectors.removeIf(item -> item.collectorId() == collectorId);
|
SimulatorState next = new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
|
state.nextCollectorId(), state.tombstones());
|
persist(next);
|
}
|
|
/** 保存稳定 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 setSoftwareAccessible(boolean accessible)
|
{
|
SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
|
state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), accessible, state.observationSequence());
|
stateStore.save(next);
|
state = next;
|
}
|
|
/** 批量修改设备在线事实,保留各通道工作状态。 */
|
public synchronized void setAllDevicesPoweredOn(boolean poweredOn)
|
{
|
List<DeviceState> devices = state.devices().stream()
|
.map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn, device.channels(), device.profile()))
|
.toList();
|
List<CollectorState> collectors = state.collectors().stream()
|
.map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(),
|
collector.channelCount(), poweredOn, Instant.now())).toList();
|
persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(),
|
state.nextCollectorId(), state.tombstones()));
|
}
|
|
/** 修改采集器单个通道工作状态;不代表真实采集器控制能力。 */
|
public synchronized void updateCollectorChannelStatus(int collectorId, String channelCode, DeviceWorkStatus status)
|
{
|
requireControllableStatus(status);
|
replaceCollector(DevelopmentCollectorSimulation.configureStatus(requireCollector(collectorId), channelCode, status));
|
}
|
|
/** 返回通道已配置的开发方案及当前工步位置,不自动推进或生成历史。 */
|
@Override
|
public synchronized TestPlan testPlan(ChannelIdentity channel)
|
{
|
if (!canObserve(channel)) return 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);
|
}
|
|
/** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */
|
@Override
|
public synchronized CabinetRealtime latestCabinet(ChannelIdentity channel)
|
{
|
CabinetRealtime result = canObserve(channel) ? cabinetMeasurement(channel) : null;
|
recordRequest("cabinet-realtime", String.valueOf(channel),
|
result == null ? failureReason() : result.toString());
|
return result;
|
}
|
|
private CabinetRealtime cabinetMeasurement(ChannelIdentity identity)
|
{
|
DeviceState device = requireDevice(identity.devid());
|
ChannelState channel = device.channels().get(identity.chlid() - 1);
|
if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) return null;
|
Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
|
long sequence = nextObservationSequence();
|
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 adapterCode, String collectorCode)
|
{
|
CollectorState collector = state.collectors().stream().filter(item -> item.deviceCode().equals(collectorCode))
|
.findFirst().orElse(null);
|
if (collector == null) return null;
|
var snapshot = collectorRealtime(collector.collectorId());
|
return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(),
|
snapshot.measurements().stream().map(item -> new CollectorMeasurement(item.channelCode(), item.sequenceId(),
|
item.deviceTime(), item.deviceTimeRaw(), item.serverReceivedAt(), item.metricCode(), item.value(),
|
item.unit())).toList(), DEVICE_ZONE.getId());
|
}
|
|
/** 模拟器不提供历史工步统计。 */
|
@Override
|
public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); }
|
|
/** 模拟器不提供历史循环统计。 */
|
@Override
|
public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); }
|
|
/** 使用后端正常持久化周期,不提供开发倍速。 */
|
@Override
|
public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; }
|
|
private boolean canObserve(ChannelIdentity channel)
|
{
|
// 完整来源身份限定数据归属;不可访问和离线只改变观测结果。
|
return channel != null && observation(channel).connectionStatus() == DeviceConnectionStatus.ONLINE
|
&& observation(channel).workStatus() != DeviceWorkStatus.UNOBSERVABLE;
|
}
|
|
/** 请求采集器当前观测,读取状态页面本身不会触发此请求。 */
|
public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId)
|
{
|
CollectorState collector = requireCollector(collectorId);
|
boolean available = state.softwareAccessible() && collector.online();
|
if (available)
|
{
|
collector = DevelopmentCollectorSimulation.sample(collector, Instant.now(), nextObservationSequence());
|
replaceCollector(collector);
|
}
|
var result = new CollectorRealtimeSnapshot(collector.deviceCode(), available, collector.sampleIntervalMs(),
|
available ? collector.latestMeasurements() : List.of());
|
recordRequest("collector-realtime", collector.deviceCode(), available ? result.toString() : failureReason());
|
return result;
|
}
|
|
private String failureReason()
|
{
|
return state.softwareAccessible() ? "UNOBSERVABLE" : "SOFTWARE_UNAVAILABLE";
|
}
|
|
private long nextObservationSequence()
|
{
|
long sequence = Math.max(state.observationSequence() + 1, Instant.now().toEpochMilli());
|
SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
|
state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), state.softwareAccessible(), sequence);
|
stateStore.save(next);
|
state = next;
|
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.configuration()))
|
.toList()))
|
.toList();
|
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.sampleIntervalMs(),
|
collector.channels(), state.softwareAccessible() && collector.online() ? collector.latestMeasurements() : List.of()))
|
.toList();
|
return new StateSnapshot(running, state.softwareAccessible(), HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
|
stateStore.loadRecentRequests());
|
}
|
|
/** 人工恢复默认拓扑;这是正常运行中唯一清空长期模拟状态的入口。 */
|
public synchronized void reset()
|
{
|
SimulatorState defaultState = defaultState();
|
stateStore.reset(defaultState);
|
state = defaultState;
|
}
|
|
private SimulatorState defaultState()
|
{
|
return new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
|
List.of(newDevice(1, "127.0.0.2", 4)),
|
List.of(newCollector(1, "127.0.1.2", 4)), 2, 2, List.of());
|
}
|
|
private DeviceState newDevice(int deviceId, String ip, int channelCount)
|
{
|
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, profile);
|
}
|
|
private CollectorState newCollector(int collectorId, String ip, int channelCount)
|
{
|
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 synchronized String respond(String request)
|
{
|
String command = readCommand(request);
|
String response = state.softwareAccessible() ? switch (command)
|
{
|
case "getdevinfo" -> deviceInfoResponse();
|
case "inquire" -> observationResponse(request);
|
default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
|
} : "<bts version=\"1.0\"><cmd>error</cmd></bts>";
|
recordRequest(command, request, state.softwareAccessible() ? response : "SOFTWARE_UNAVAILABLE");
|
return response;
|
}
|
|
private void recordRequest(String command, String request, String response)
|
{
|
List<RecentRequest> recentRequests = new ArrayList<>(stateStore.loadRecentRequests());
|
recentRequests.add(0, new RecentRequest(Instant.now(), command, request, response));
|
if (recentRequests.size() > MAX_RECENT_REQUESTS)
|
recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS));
|
stateStore.saveRecentRequests(recentRequests);
|
}
|
|
private String readCommand(String request)
|
{
|
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 && "cmd".equals(reader.getLocalName()))
|
return reader.getElementText().trim();
|
}
|
}
|
catch (Exception exception)
|
{
|
throw new IllegalArgumentException("Invalid BTS request XML", exception);
|
}
|
throw new IllegalArgumentException("BTS request does not contain cmd");
|
}
|
|
private synchronized String deviceInfoResponse()
|
{
|
int channelCount = state.devices().stream().mapToInt(device -> device.channels().size()).sum()
|
+ state.tombstones().size();
|
StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>")
|
.append("<serverip count=\"0\"/><middle count=\"").append(channelCount).append("\">");
|
for (DeviceState device : state.devices())
|
{
|
for (ChannelState channel : device.channels())
|
{
|
boolean available = device.online() && channel.online();
|
appendDiscoveredChannel(response, device.deviceId(), device.ip(), channel.channelId(), available);
|
}
|
}
|
List<ChannelTombstone> remaining = new ArrayList<>();
|
for (ChannelTombstone tombstone : state.tombstones())
|
{
|
appendDiscoveredChannel(response, tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), false);
|
if (tombstone.remainingDiscoveries() > 1)
|
remaining.add(new ChannelTombstone(tombstone.deviceId(), tombstone.ip(), tombstone.channelId(),
|
tombstone.remainingDiscoveries() - 1));
|
}
|
if (!remaining.equals(state.tombstones()))
|
persist(new SimulatorState(state.version(), state.devices(), state.collectors(), state.nextDeviceId(),
|
state.nextCollectorId(), remaining));
|
return response.append("</middle></bts>").toString();
|
}
|
|
private void appendDiscoveredChannel(StringBuilder response, int deviceId, String ip, int channelId,
|
boolean available)
|
{
|
// 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。
|
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(String request)
|
{
|
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() && channel.workStatus() != DeviceWorkStatus.UNOBSERVABLE
|
&& requested.contains(profileFor(device).devtype() + ":" + device.deviceId()
|
+ ":" + profileFor(device).subDeviceId() + ":" + 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();
|
DeviceState device = requireDevice(observed.deviceId());
|
BtsDeviceProfile profile = profileFor(device);
|
CabinetRealtime measurement = cabinetMeasurement(new ChannelIdentity(ADAPTER_CODE, profile.devtype(),
|
device.deviceId(), profile.subDeviceId(), channel.channelId()));
|
double current = measurement.currentA(), voltage = measurement.voltageV();
|
double capacity = measurement.capacityAh(), energy = measurement.energyWh();
|
int cycleNo = measurement.cycleNo(), stepNo = measurement.stepNo();
|
String stepType = measurement.stepType();
|
response.append("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId())
|
.append('-').append(profile.subDeviceId()).append('-').append(channel.channelId())
|
.append("-0\" cycle_id=\"").append(cycleNo).append("\" step_id=\"").append(stepNo)
|
.append("\" step_type=\"").append(stepType).append("\" workstatus=\"")
|
.append(toBtsWorkStatus(channel.workStatus()))
|
.append("\" current=\"").append(decimal(current)).append("\" voltage=\"")
|
.append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity))
|
.append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"")
|
.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=\"")
|
.append(stepType).append("\" name=\"").append(stepType).append("\"/></plan></inquire>");
|
}
|
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");
|
String devtype = reader.getAttributeValue(null, "devtype");
|
String subdevid = reader.getAttributeValue(null, "subdevid");
|
if (devtype != null && devid != null && subdevid != null && chlid != null)
|
channels.add(devtype + ":" + devid + ":" + subdevid + ":" + 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 String decimal(double value)
|
{
|
return java.math.BigDecimal.valueOf(value).toPlainString();
|
}
|
|
private String toBtsWorkStatus(DeviceWorkStatus status)
|
{
|
return switch (status)
|
{
|
case STOPPED -> "stop";
|
case WORKING -> "working";
|
case PAUSED -> "pause";
|
case FINISHED -> "finish";
|
case PROTECTED -> "protect";
|
default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status);
|
};
|
}
|
|
private void validateDeviceConfiguration(String ip, int channelCount, Integer currentDeviceId)
|
{
|
if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator device IP must be a valid IPv4 address");
|
if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT)
|
throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT);
|
boolean duplicate = state.devices().stream()
|
.anyMatch(device -> device.ip().equals(ip.trim())
|
&& device.deviceId() != (currentDeviceId == null ? -1 : currentDeviceId));
|
duplicate = duplicate || state.collectors().stream().anyMatch(collector -> collector.ip().equals(ip.trim()));
|
if (duplicate) throw new IllegalArgumentException("Simulator device IP must be unique");
|
}
|
|
private void validateCollectorConfiguration(String ip, int channelCount, Integer currentCollectorId)
|
{
|
if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator collector IP must be a valid IPv4 address");
|
if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT)
|
throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT);
|
boolean duplicate = state.collectors().stream()
|
.anyMatch(collector -> collector.ip().equals(ip.trim())
|
&& collector.collectorId() != (currentCollectorId == null ? -1 : currentCollectorId));
|
duplicate = duplicate || state.devices().stream().anyMatch(device -> device.ip().equals(ip.trim()));
|
if (duplicate) throw new IllegalArgumentException("Simulator collector IP must be unique");
|
}
|
|
private boolean isIpv4(String ip)
|
{
|
if (ip == null) return false;
|
String[] parts = ip.trim().split("\\.", -1);
|
if (parts.length != 4) return false;
|
for (String part : parts)
|
{
|
try
|
{
|
int value = Integer.parseInt(part);
|
if (part.isEmpty() || value < 0 || value > 255) return false;
|
}
|
catch (NumberFormatException exception)
|
{
|
return false;
|
}
|
}
|
return true;
|
}
|
|
private DeviceState requireDevice(int deviceId)
|
{
|
return state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst()
|
.orElseThrow(() -> new IllegalArgumentException("Unknown simulator device: " + deviceId));
|
}
|
|
private CollectorState requireCollector(int collectorId)
|
{
|
return state.collectors().stream().filter(item -> item.collectorId() == collectorId).findFirst()
|
.orElseThrow(() -> new IllegalArgumentException("Unknown simulator collector: " + collectorId));
|
}
|
|
private List<DeviceState> replaceDevice(DeviceState replacement)
|
{
|
List<DeviceState> devices = new ArrayList<>(state.devices());
|
devices.replaceAll(item -> item.deviceId() == replacement.deviceId() ? replacement : item);
|
return devices;
|
}
|
|
private void upsertTombstone(List<ChannelTombstone> tombstones, int deviceId, String ip, int channelId)
|
{
|
tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() == channelId);
|
tombstones.add(new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT));
|
}
|
|
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());
|
stateStore.save(configured);
|
state = configured;
|
}
|
|
private String simulatorMac(int devtype, int devid)
|
{
|
return String.format("02:44:%02X:%02X:%02X:%02X",
|
(devtype >>> 8) & 0xFF, devtype & 0xFF, (devid >>> 8) & 0xFF, devid & 0xFF);
|
}
|
|
private String simulatorCollectorMac(int collectorId)
|
{
|
return String.format("02:43:00:00:%02X:%02X", (collectorId >>> 8) & 0xFF, collectorId & 0xFF);
|
}
|
|
private void requireControllableStatus(DeviceWorkStatus status)
|
{
|
if (status == null || (status != DeviceWorkStatus.STOPPED && status != DeviceWorkStatus.WORKING
|
&& status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED
|
&& status != DeviceWorkStatus.PROTECTED && status != DeviceWorkStatus.UNOBSERVABLE))
|
throw new IllegalArgumentException("Unsupported simulator work status");
|
}
|
|
private void validateRestoredState()
|
{
|
java.util.Set<Integer> deviceIds = new java.util.HashSet<>();
|
java.util.Set<Integer> collectorIds = new java.util.HashSet<>();
|
java.util.Set<String> ips = new java.util.HashSet<>();
|
int maximumDeviceId = 0;
|
for (DeviceState device : state.devices())
|
{
|
if (device.deviceId() < 1 || !deviceIds.add(device.deviceId()) || !isIpv4(device.ip())
|
|| !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++)
|
{
|
ChannelState channel = device.channels().get(index);
|
if (channel.channelId() != index + 1)
|
throw new IllegalStateException("Invalid BTS simulator channel state in Redis");
|
requireControllableStatus(channel.workStatus());
|
}
|
}
|
int maximumCollectorId = 0;
|
for (CollectorState collector : state.collectors())
|
{
|
if (collector.collectorId() < 1 || !collectorIds.add(collector.collectorId())
|
|| !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<>();
|
boolean invalidTombstone = state.tombstones().stream().anyMatch(item -> item.deviceId() < 1
|
|| !isIpv4(item.ip()) || item.channelId() < 1 || item.channelId() > MAX_CHANNEL_COUNT
|
|| item.remainingDiscoveries() < 1 || item.remainingDiscoveries() > TOMBSTONE_DISCOVERY_COUNT
|
|| !tombstoneIds.add(item.deviceId() + ":" + item.channelId()));
|
if (state.nextDeviceId() <= maximumDeviceId || state.nextCollectorId() <= maximumCollectorId
|
|| invalidTombstone)
|
throw new IllegalStateException("Invalid BTS simulator numbering or tombstone state in Redis");
|
}
|
|
@Override
|
public synchronized void close()
|
{
|
running = false;
|
try
|
{
|
simulator.close();
|
}
|
catch (Exception exception)
|
{
|
throw new IllegalStateException("关闭开发模拟器失败", exception);
|
}
|
}
|
|
private record ObservedChannel(int deviceId, ChannelState channel) { }
|
|
public record StateSnapshot(boolean running, boolean softwareAccessible, String listenAddress, List<DeviceSnapshot> devices,
|
List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { }
|
|
public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile,
|
List<ChannelSnapshot> channels) { }
|
|
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,
|
List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements)
|
{
|
public CollectorSnapshot
|
{
|
channels = List.copyOf(channels);
|
latestMeasurements = List.copyOf(latestMeasurements);
|
}
|
}
|
|
}
|