建立厂商无关设备观测接缝
新增统一设备观测模型、连接状态、计划工步和失败原因。让 BTS、模拟器与 Noop 通过同一观测接口输出在线或不可观测事实,并补充契约测试。
| | |
| | | import com.battery.device.model.HistoryAvailability; |
| | | import com.battery.device.model.HistoryPage; |
| | | import com.battery.device.model.RealtimeSnapshot; |
| | | import com.battery.device.model.DeviceObservation; |
| | | import com.battery.device.model.ObservationFailureReason; |
| | | |
| | | public interface DeviceAdapter extends AutoCloseable |
| | | public interface DeviceAdapter extends DeviceObservationAdapter |
| | | { |
| | | /** 返回适配器实例的稳定代码。 */ |
| | | String adapterCode(); |
| | |
| | | /** 批量查询通道实时快照,不形成最终原始采集数据。 */ |
| | | Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels); |
| | | |
| | | /** 返回厂商无关的观测结果;失败也以离线事实返回,不把协议异常泄漏给业务。 */ |
| | | default Map<ChannelIdentity, DeviceObservation> observe(Set<ChannelIdentity> channels) |
| | | { |
| | | if (channels == null || channels.isEmpty()) return Map.of(); |
| | | try |
| | | { |
| | | Map<ChannelIdentity, RealtimeSnapshot> snapshots = inquire(channels); |
| | | Map<ChannelIdentity, DeviceObservation> observations = new java.util.LinkedHashMap<>(); |
| | | channels.forEach(channel -> { |
| | | RealtimeSnapshot snapshot = snapshots.get(channel); |
| | | observations.put(channel, snapshot == null |
| | | ? DeviceObservation.offline(channel, "OFFLINE") : DeviceObservation.from(snapshot)); |
| | | }); |
| | | return Map.copyOf(observations); |
| | | } |
| | | catch (DeviceAdapterException exception) |
| | | { |
| | | ObservationFailureReason reason = normalizeFailureReason(exception.failureCode()); |
| | | Map<ChannelIdentity, DeviceObservation> offline = new java.util.LinkedHashMap<>(); |
| | | channels.forEach(channel -> offline.put(channel, DeviceObservation.offline(channel, reason))); |
| | | return Map.copyOf(offline); |
| | | } |
| | | } |
| | | |
| | | /** 将适配器实现细节收敛为业务可识别的失败分类。 */ |
| | | private static ObservationFailureReason normalizeFailureReason(String failureCode) |
| | | { |
| | | if (failureCode == null || failureCode.isBlank()) return ObservationFailureReason.INVALID_RESPONSE; |
| | | return switch (failureCode.trim().toUpperCase(java.util.Locale.ROOT)) |
| | | { |
| | | case "CONNECTION_FAILED" -> ObservationFailureReason.CONNECTION_FAILED; |
| | | case "TIMEOUT" -> ObservationFailureReason.TIMEOUT; |
| | | case "DISCONNECTED" -> ObservationFailureReason.DISCONNECTED; |
| | | case "UNSUPPORTED" -> ObservationFailureReason.UNSUPPORTED; |
| | | case "MALFORMED_XML", "MISSING_FIELD", "INVALID_VALUE", "IDENTITY_MISMATCH", |
| | | "UNEXPECTED_COMMAND" -> ObservationFailureReason.PARSE_FAILED; |
| | | default -> ObservationFailureReason.TRANSPORT_FAILED; |
| | | }; |
| | | } |
| | | |
| | | /** 查询通道当前 BTS 测试的历史数据上传进度。 */ |
| | | List<HistoryAvailability> inquireHistory(Set<ChannelIdentity> channels); |
| | | |
| New file |
| | |
| | | package com.battery.device.api; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import com.battery.device.model.AdapterCapabilities; |
| | | import com.battery.device.model.ChannelIdentity; |
| | | import com.battery.device.model.DetectedChannel; |
| | | import com.battery.device.model.DeviceObservation; |
| | | |
| | | /** 业务依赖的最小厂商无关设备观测契约。 */ |
| | | public interface DeviceObservationAdapter extends AutoCloseable |
| | | { |
| | | /** 返回适配器实例的稳定编码。 */ |
| | | String adapterCode(); |
| | | |
| | | /** 返回当前运行时能力;业务不得据此向设备发送未授权命令。 */ |
| | | AdapterCapabilities capabilities(); |
| | | |
| | | /** 发现当前端点可见的通道和设备身份。 */ |
| | | List<DetectedChannel> discoverChannels(); |
| | | |
| | | /** 批量返回厂商无关观测;不可观测通道仍返回离线事实。 */ |
| | | Map<ChannelIdentity, DeviceObservation> observe(Set<ChannelIdentity> channels); |
| | | |
| | | @Override |
| | | default void close() |
| | | { |
| | | } |
| | | } |
| | |
| | | |
| | | @Override public AdapterCapabilities capabilities() |
| | | { |
| | | return new AdapterCapabilities(true, true, false, false); |
| | | // Noop 不宣称发现或观测能力,避免业务把空结果误认为设备离线快照之外的成功事实。 |
| | | return new AdapterCapabilities(false, false, false, false); |
| | | } |
| | | |
| | | @Override public List<DetectedChannel> discoverChannels() { return List.of(); } |
| New file |
| | |
| | | package com.battery.device.model; |
| | | |
| | | /** 适配器最近一次观测到的设备连接事实。 */ |
| | | public enum DeviceConnectionStatus |
| | | { |
| | | ONLINE, |
| | | OFFLINE |
| | | } |
| New file |
| | |
| | | package com.battery.device.model; |
| | | |
| | | import java.util.Objects; |
| | | |
| | | /** 设备身份;通道身份在此基础上增加通道号。 */ |
| | | public record DeviceIdentity(String adapterCode, int devtype, int devid, int subdevid) |
| | | { |
| | | public DeviceIdentity |
| | | { |
| | | adapterCode = Objects.requireNonNull(adapterCode, "adapterCode").trim(); |
| | | if (adapterCode.isEmpty()) throw new IllegalArgumentException("adapterCode must not be blank"); |
| | | if (devtype < 0 || devid < 0 || subdevid < 0) |
| | | throw new IllegalArgumentException("device identity values must be non-negative"); |
| | | } |
| | | |
| | | public static DeviceIdentity from(ChannelIdentity channel) |
| | | { |
| | | Objects.requireNonNull(channel, "channel"); |
| | | return new DeviceIdentity(channel.adapterCode(), channel.devtype(), channel.devid(), channel.subdevid()); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.model; |
| | | |
| | | import java.time.Instant; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.LinkedHashMap; |
| | | |
| | | /** 厂商无关的一次通道观测结果。不得携带测试、持久化或传输层对象。 */ |
| | | public record DeviceObservation( |
| | | DeviceIdentity device, |
| | | ChannelIdentity channel, |
| | | DeviceConnectionStatus connectionStatus, |
| | | DeviceWorkStatus channelWorkStatus, |
| | | List<PlannedStep> plannedSteps, |
| | | Instant deviceTime, |
| | | Map<String, Measurement> measurements, |
| | | ObservationFailureReason failureReason) |
| | | { |
| | | public DeviceObservation |
| | | { |
| | | device = Objects.requireNonNull(device, "device"); |
| | | channel = Objects.requireNonNull(channel, "channel"); |
| | | if (!device.equals(DeviceIdentity.from(channel))) |
| | | throw new IllegalArgumentException("device and channel identities do not match"); |
| | | connectionStatus = Objects.requireNonNull(connectionStatus, "connectionStatus"); |
| | | channelWorkStatus = channelWorkStatus == null ? DeviceWorkStatus.UNOBSERVABLE : channelWorkStatus; |
| | | plannedSteps = plannedSteps == null ? List.of() : List.copyOf(plannedSteps); |
| | | measurements = measurements == null ? Map.of() : Map.copyOf(measurements); |
| | | failureReason = failureReason; |
| | | if (connectionStatus == DeviceConnectionStatus.ONLINE && failureReason != null) |
| | | throw new IllegalArgumentException("online observation cannot contain a failure reason"); |
| | | } |
| | | |
| | | /** 将已有实时快照提升为统一观测模型,兼容旧适配器实现。 */ |
| | | public static DeviceObservation from(RealtimeSnapshot snapshot) |
| | | { |
| | | Objects.requireNonNull(snapshot, "snapshot"); |
| | | DeviceConnectionStatus status = snapshot.connectionStatus() == null |
| | | ? DeviceConnectionStatus.ONLINE : snapshot.connectionStatus(); |
| | | if (status == DeviceConnectionStatus.OFFLINE) |
| | | return offline(snapshot.channel(), snapshot.failureReason() == null ? "OFFLINE" : snapshot.failureReason()); |
| | | Map<String, Measurement> measurements = new LinkedHashMap<>(snapshot.measurements()); |
| | | addScalar(measurements, "current", snapshot.currentA(), "A"); |
| | | addScalar(measurements, "voltage", snapshot.voltageV(), "V"); |
| | | addScalar(measurements, "capacity", snapshot.capacityAh(), "Ah"); |
| | | addScalar(measurements, "energy", snapshot.energyWh(), "Wh"); |
| | | return new DeviceObservation(DeviceIdentity.from(snapshot.channel()), snapshot.channel(), status, |
| | | snapshot.workStatus(), |
| | | snapshot.plannedSteps(), snapshot.deviceTime(), measurements, |
| | | parseFailureReason(snapshot.failureReason())); |
| | | } |
| | | |
| | | private static void addScalar(Map<String, Measurement> measurements, String code, |
| | | java.math.BigDecimal value, String unit) |
| | | { |
| | | if (value != null) measurements.putIfAbsent(code, new Measurement(code, value, null, unit, "DEVICE")); |
| | | } |
| | | |
| | | public boolean online() |
| | | { |
| | | return connectionStatus == DeviceConnectionStatus.ONLINE; |
| | | } |
| | | |
| | | /** 兼容调用方以通道工作状态命名的读取方式。 */ |
| | | public DeviceWorkStatus workStatus() |
| | | { |
| | | return channelWorkStatus; |
| | | } |
| | | |
| | | public static DeviceObservation offline(ChannelIdentity channel, String failureReason) |
| | | { |
| | | Objects.requireNonNull(channel, "channel"); |
| | | ObservationFailureReason reason = parseFailureReason(failureReason); |
| | | return new DeviceObservation(DeviceIdentity.from(channel), channel, DeviceConnectionStatus.OFFLINE, |
| | | DeviceWorkStatus.UNOBSERVABLE, List.of(), null, Map.of(), |
| | | reason == null ? ObservationFailureReason.OFFLINE : reason); |
| | | } |
| | | |
| | | public static DeviceObservation offline(ChannelIdentity channel, ObservationFailureReason failureReason) |
| | | { |
| | | Objects.requireNonNull(channel, "channel"); |
| | | return new DeviceObservation(DeviceIdentity.from(channel), channel, DeviceConnectionStatus.OFFLINE, |
| | | DeviceWorkStatus.UNOBSERVABLE, List.of(), null, Map.of(), |
| | | failureReason == null ? ObservationFailureReason.OFFLINE : failureReason); |
| | | } |
| | | |
| | | private static ObservationFailureReason parseFailureReason(String value) |
| | | { |
| | | if (value == null || value.isBlank()) return null; |
| | | try |
| | | { |
| | | return ObservationFailureReason.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)); |
| | | } |
| | | catch (IllegalArgumentException ignored) |
| | | { |
| | | return ObservationFailureReason.INVALID_RESPONSE; |
| | | } |
| | | } |
| | | } |
| | |
| | | WAITING_TIMEOUT, |
| | | WAITING_START, |
| | | LOCKED, |
| | | /** 设备离线或请求失败时不得沿用上一次通道状态。 */ |
| | | UNOBSERVABLE, |
| | | UNKNOWN; |
| | | |
| | | public static DeviceWorkStatus fromBts(String value) |
| New file |
| | |
| | | package com.battery.device.model; |
| | | |
| | | /** 观测失败的稳定分类,不暴露厂商协议异常类型。 */ |
| | | public enum ObservationFailureReason |
| | | { |
| | | CONNECTION_FAILED, |
| | | TIMEOUT, |
| | | DISCONNECTED, |
| | | PARSE_FAILED, |
| | | INVALID_RESPONSE, |
| | | TRANSPORT_FAILED, |
| | | UNSUPPORTED, |
| | | OFFLINE |
| | | } |
| New file |
| | |
| | | package com.battery.device.model; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.Objects; |
| | | |
| | | /** 设备返回的计划工步,只用于观测和展示,不属于测试配置。 */ |
| | | public record PlannedStep(int stepNo, String stepType, String name, BigDecimal targetVoltageV, |
| | | BigDecimal targetCurrentA, Long durationSeconds) |
| | | { |
| | | public PlannedStep |
| | | { |
| | | if (stepNo < 1) throw new IllegalArgumentException("stepNo must be positive"); |
| | | stepType = Objects.requireNonNull(stepType, "stepType").trim(); |
| | | if (stepType.isEmpty()) throw new IllegalArgumentException("stepType must not be blank"); |
| | | name = name == null ? null : name.trim(); |
| | | if (durationSeconds != null && durationSeconds < 0) |
| | | throw new IllegalArgumentException("durationSeconds must be non-negative"); |
| | | } |
| | | } |
| | |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.Instant; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | public record RealtimeSnapshot( |
| | |
| | | Long relativeTimeSeconds, |
| | | String logCode, |
| | | Instant receivedAt, |
| | | Map<String, Measurement> measurements, |
| | | DeviceConnectionStatus connectionStatus, |
| | | Instant deviceTime, |
| | | List<PlannedStep> plannedSteps, |
| | | String failureReason) |
| | | { |
| | | /** 兼容已有适配器调用的实时快照构造方式。 */ |
| | | public RealtimeSnapshot(ChannelIdentity channel, DeviceWorkStatus workStatus, Integer cycleNo, Integer stepNo, |
| | | String stepType, String barcode, BigDecimal currentA, BigDecimal voltageV, BigDecimal capacityAh, |
| | | BigDecimal energyWh, Long totalTimeSeconds, Long relativeTimeSeconds, String logCode, Instant receivedAt, |
| | | Map<String, Measurement> measurements) |
| | | { |
| | | this(channel, workStatus, cycleNo, stepNo, stepType, barcode, currentA, voltageV, capacityAh, energyWh, |
| | | totalTimeSeconds, relativeTimeSeconds, logCode, receivedAt, measurements, |
| | | DeviceConnectionStatus.ONLINE, null, List.of(), null); |
| | | } |
| | | |
| | | public RealtimeSnapshot |
| | | { |
| | | measurements = measurements == null ? Map.of() : Map.copyOf(measurements); |
| | | plannedSteps = plannedSteps == null ? List.of() : List.copyOf(plannedSteps); |
| | | connectionStatus = connectionStatus == null ? DeviceConnectionStatus.ONLINE : connectionStatus; |
| | | failureReason = failureReason == null || failureReason.isBlank() ? null : failureReason.trim(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.api; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import java.util.Set; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.model.ChannelIdentity; |
| | | import com.battery.device.model.DeviceConnectionStatus; |
| | | import com.battery.device.model.DeviceWorkStatus; |
| | | import com.battery.device.model.ObservationFailureReason; |
| | | |
| | | class DeviceObservationContractTest |
| | | { |
| | | @Test |
| | | void noopUsesTheObservationContractWithoutExternalSideEffects() |
| | | { |
| | | ChannelIdentity channel = new ChannelIdentity("noop", 1, 2, 3, 4); |
| | | try (DeviceObservationAdapter adapter = new NoopDeviceAdapter("noop")) |
| | | { |
| | | var observation = adapter.observe(Set.of(channel)).get(channel); |
| | | assertEquals(DeviceConnectionStatus.OFFLINE, observation.connectionStatus()); |
| | | assertEquals(DeviceWorkStatus.UNOBSERVABLE, observation.workStatus()); |
| | | assertEquals(ObservationFailureReason.OFFLINE, observation.failureReason()); |
| | | assertFalse(adapter.capabilities().control()); |
| | | } |
| | | } |
| | | } |
| | |
| | | import org.xml.sax.InputSource; |
| | | import org.xml.sax.helpers.DefaultHandler; |
| | | import com.battery.device.model.ChannelIdentity; |
| | | import com.battery.device.model.DeviceConnectionStatus; |
| | | import com.battery.device.model.DetectedChannel; |
| | | import com.battery.device.model.DetectedDeviceMetadata; |
| | | import com.battery.device.model.DeviceWorkStatus; |
| | |
| | | import com.battery.device.model.HistoryPage; |
| | | import com.battery.device.model.HistorySample; |
| | | import com.battery.device.model.Measurement; |
| | | import com.battery.device.model.PlannedStep; |
| | | import com.battery.device.model.RealtimeSnapshot; |
| | | |
| | | public final class BtsXmlCodec |
| | |
| | | private static final DateTimeFormatter BTS_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss"); |
| | | private static final Set<String> SNAPSHOT_ATTRIBUTES = Set.of( |
| | | "dev", "cycle_id", "step_id", "step_type", "workstatus", "barcode", "current", "voltage", |
| | | "capacity", "energy", "totaltime", "relativetime", "auxtemp", "auxvol", "open_or_close", "log_code"); |
| | | "capacity", "energy", "totaltime", "relativetime", "auxtemp", "auxvol", "open_or_close", "log_code", |
| | | "device_time", "deviceTime", "time", "atime", "testtime"); |
| | | private static final Set<String> HISTORY_ATTRIBUTES = Set.of( |
| | | "seqid", "stepid", "cycleid", "steptype", "testtime", "atime", "volt", "curr", "cap", "eng"); |
| | | |
| | |
| | | "Invalid BTS dev identity: " + element.getAttribute("dev")); |
| | | ChannelIdentity identity = new ChannelIdentity(adapterCode, integer(device[0], "devtype"), integer(device[1], "devid"), |
| | | integer(device[2], "subdevid"), integer(device[3], "chlid")); |
| | | Instant deviceTime = optionalDeviceTime(element); |
| | | snapshots.put(identity, new RealtimeSnapshot(identity, |
| | | DeviceWorkStatus.fromBts(element.getAttribute("workstatus")), optionalInteger(element, "cycle_id"), |
| | | optionalInteger(element, "step_id"), optionalAttribute(element, "step_type"), optionalAttribute(element, "barcode"), |
| | | optionalDecimal(element, "current"), optionalDecimal(element, "voltage"), optionalDecimal(element, "capacity"), |
| | | optionalDecimal(element, "energy"), optionalLong(element, "totaltime"), optionalLong(element, "relativetime"), |
| | | optionalAttribute(element, "log_code"), receivedAt, dynamicMeasurements(element, SNAPSHOT_ATTRIBUTES))); |
| | | optionalAttribute(element, "log_code"), receivedAt, dynamicMeasurements(element, SNAPSHOT_ATTRIBUTES), |
| | | DeviceConnectionStatus.ONLINE, deviceTime, parsePlannedSteps(element), null)); |
| | | } |
| | | return Map.copyOf(snapshots); |
| | | } |
| | |
| | | try |
| | | { |
| | | // BTS 时间不带时区,按适配器配置的现场时区解释后统一转换为 UTC 时间点。 |
| | | try |
| | | { |
| | | return Instant.parse(value); |
| | | } |
| | | catch (DateTimeParseException ignored) |
| | | { |
| | | return LocalDateTime.parse(value, BTS_TIME).atZone(deviceZone).toInstant(); |
| | | } |
| | | } |
| | | catch (DateTimeParseException ex) |
| | | { |
| | |
| | | } |
| | | } |
| | | |
| | | private Instant optionalDeviceTime(Element element) |
| | | { |
| | | for (String name : List.of("device_time", "deviceTime", "time", "atime", "testtime")) |
| | | { |
| | | String value = optionalAttribute(element, name); |
| | | if (value != null) return parseDeviceTime(value); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private List<PlannedStep> parsePlannedSteps(Element channel) |
| | | { |
| | | List<PlannedStep> steps = new ArrayList<>(); |
| | | NodeList nodes = channel.getElementsByTagName("plannedStep"); |
| | | if (nodes.getLength() == 0) nodes = channel.getElementsByTagName("planStep"); |
| | | if (nodes.getLength() == 0) nodes = channel.getElementsByTagName("step"); |
| | | for (int i = 0; i < nodes.getLength(); i++) |
| | | { |
| | | Element step = (Element) nodes.item(i); |
| | | String number = firstOptionalAttribute(step, "step_no", "stepNo", "step_id", "stepid", "id"); |
| | | String type = firstOptionalAttribute(step, "step_type", "stepType", "steptype", "type"); |
| | | if (number == null || type == null) continue; |
| | | steps.add(new PlannedStep(integer(number, "planned step number"), type, |
| | | firstOptionalAttribute(step, "name", "step_name", "stepName"), |
| | | optionalDecimalFromNames(step, "target_voltage", "targetVoltage", "voltage"), |
| | | optionalDecimalFromNames(step, "target_current", "targetCurrent", "current"), |
| | | optionalLongFromNames(step, "duration_seconds", "durationSeconds", "duration"))); |
| | | } |
| | | return List.copyOf(steps); |
| | | } |
| | | |
| | | private static String firstOptionalAttribute(Element element, String... names) |
| | | { |
| | | for (String name : names) |
| | | { |
| | | String value = optionalAttribute(element, name); |
| | | if (value != null) return value; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private static BigDecimal optionalDecimalFromNames(Element element, String... names) |
| | | { |
| | | String value = firstOptionalAttribute(element, names); |
| | | return value == null ? null : decimal(value, names[0]); |
| | | } |
| | | |
| | | private static Long optionalLongFromNames(Element element, String... names) |
| | | { |
| | | String value = firstOptionalAttribute(element, names); |
| | | if (value == null) return null; |
| | | try { return Long.parseLong(value); } |
| | | catch (NumberFormatException ex) |
| | | { |
| | | throw new BtsProtocolException(BtsProtocolException.Reason.INVALID_VALUE, |
| | | "Invalid planned step duration: " + value, ex); |
| | | } |
| | | } |
| | | |
| | | private void requireOwned(ChannelIdentity channel) |
| | | { |
| | | if (!adapterCode.equals(channel.adapterCode())) throw new IllegalArgumentException("Channel belongs to another adapter"); |
| New file |
| | |
| | | package com.battery.device.bts; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import java.time.Duration; |
| | | import java.time.ZoneId; |
| | | import java.util.Set; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.model.ChannelIdentity; |
| | | import com.battery.device.model.DeviceConnectionStatus; |
| | | import com.battery.device.model.DeviceWorkStatus; |
| | | import com.battery.device.model.ObservationFailureReason; |
| | | |
| | | class DeviceObservationSeamContractTest |
| | | { |
| | | private static final ChannelIdentity CHANNEL = new ChannelIdentity("lab-bts", 22, 1, 1, 1); |
| | | |
| | | @Test |
| | | void successfulResponseIsOnlineAndCarriesDeviceTimeAndPlan() throws Exception |
| | | { |
| | | try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator(); |
| | | BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), |
| | | Duration.ofSeconds(1), Duration.ofSeconds(1)); |
| | | DeviceAdapter adapter = new BtsDeviceAdapter("lab-bts", transport, |
| | | new BtsXmlCodec("lab-bts", ZoneId.of("Asia/Hong_Kong")))) |
| | | { |
| | | simulator.enqueue("<bts><cmd>inquire_resp</cmd><list><inquire dev=\"22-1-1-1-0\"" |
| | | + " workstatus=\"working\" device_time=\"2026-09-04 18.00.00\"><plan>" |
| | | + "<step step_no=\"1\" step_type=\"cc\" name=\"恒流\" duration_seconds=\"60\"/>" |
| | | + "</plan></inquire></list></bts>"); |
| | | |
| | | var observation = adapter.observe(Set.of(CHANNEL)).get(CHANNEL); |
| | | |
| | | assertEquals(DeviceConnectionStatus.ONLINE, observation.connectionStatus()); |
| | | assertEquals(DeviceWorkStatus.WORKING, observation.workStatus()); |
| | | assertEquals("2026-09-04T10:00:00Z", observation.deviceTime().toString()); |
| | | assertEquals(1, observation.plannedSteps().size()); |
| | | assertEquals("cc", observation.plannedSteps().get(0).stepType()); |
| | | assertEquals(null, observation.failureReason()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void missingChannelResponseIsOfflineAndUnobservable() throws Exception |
| | | { |
| | | try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator(); |
| | | BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), |
| | | Duration.ofSeconds(1), Duration.ofSeconds(1)); |
| | | DeviceAdapter adapter = new BtsDeviceAdapter("lab-bts", transport, |
| | | new BtsXmlCodec("lab-bts", ZoneId.of("Asia/Hong_Kong")))) |
| | | { |
| | | simulator.enqueue("<bts><cmd>inquire_resp</cmd><list count=\"0\"/></bts>"); |
| | | |
| | | var observation = adapter.observe(Set.of(CHANNEL)).get(CHANNEL); |
| | | |
| | | assertEquals(DeviceConnectionStatus.OFFLINE, observation.connectionStatus()); |
| | | assertEquals(DeviceWorkStatus.UNOBSERVABLE, observation.workStatus()); |
| | | assertEquals(ObservationFailureReason.OFFLINE, observation.failureReason()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | void timeoutAndParseFailureBecomeStableOfflineReasons() throws Exception |
| | | { |
| | | try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator(); |
| | | BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), |
| | | Duration.ofSeconds(1), Duration.ofMillis(50)); |
| | | DeviceAdapter adapter = new BtsDeviceAdapter("lab-bts", transport, |
| | | new BtsXmlCodec("lab-bts", ZoneId.of("Asia/Hong_Kong")))) |
| | | { |
| | | simulator.enqueueDelayed(Duration.ofMillis(200), "<bts><cmd>inquire_resp</cmd><list/></bts>"); |
| | | assertEquals(ObservationFailureReason.TIMEOUT, adapter.observe(Set.of(CHANNEL)).get(CHANNEL).failureReason()); |
| | | } |
| | | |
| | | try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator(); |
| | | BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), |
| | | Duration.ofSeconds(1), Duration.ofSeconds(1)); |
| | | DeviceAdapter adapter = new BtsDeviceAdapter("lab-bts", transport, |
| | | new BtsXmlCodec("lab-bts", ZoneId.of("Asia/Hong_Kong")))) |
| | | { |
| | | simulator.enqueue("<bts><cmd>inquire_resp</cmd>"); |
| | | assertEquals(ObservationFailureReason.PARSE_FAILED, adapter.observe(Set.of(CHANNEL)).get(CHANNEL).failureReason()); |
| | | } |
| | | } |
| | | } |
| | |
| | | import javax.xml.stream.XMLStreamConstants; |
| | | import javax.xml.stream.XMLStreamReader; |
| | | import com.battery.device.model.DeviceWorkStatus; |
| | | import com.battery.device.model.DeviceConnectionStatus; |
| | | import com.battery.device.model.DeviceIdentity; |
| | | import com.battery.device.model.DeviceObservation; |
| | | import com.battery.device.model.DetectedChannel; |
| | | import com.battery.device.model.DetectedDeviceMetadata; |
| | | import com.battery.device.model.ChannelIdentity; |
| | | import com.battery.device.model.AdapterCapabilities; |
| | | import com.battery.device.api.DeviceObservationAdapter; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState; |
| | | import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone; |
| | |
| | | import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot; |
| | | |
| | | /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */ |
| | | public final class BtsDevelopmentSimulator implements AutoCloseable |
| | | public final class BtsDevelopmentSimulator implements DeviceObservationAdapter |
| | | { |
| | | public static final String HOST = "127.0.0.1"; |
| | | public static final int DEFAULT_PORT = 15020; |
| | |
| | | private static final long SIMULATED_RECORD_INTERVAL_SECONDS = 60L; |
| | | private static final DateTimeFormatter BTS_HISTORY_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss") |
| | | .withZone(ZoneId.of("Asia/Hong_Kong")); |
| | | |
| | | @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(), device.online() && channel.online(), |
| | | new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(), |
| | | simulatorMac(profile.devtype(), device.deviceId())))); |
| | | } |
| | | } |
| | | 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) |
| | | { |
| | | 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 (device == null || channel == null || !ADAPTER_CODE.equals(requested.adapterCode()) |
| | | || !device.online() || !channel.online()) |
| | | { |
| | | result.put(requested, DeviceObservation.offline(requested, "OFFLINE")); |
| | | continue; |
| | | } |
| | | BtsDeviceProfile profile = profileFor(device); |
| | | if (requested.devtype() != profile.devtype() || requested.subdevid() != profile.subDeviceId()) |
| | | { |
| | | result.put(requested, DeviceObservation.offline(requested, "OFFLINE")); |
| | | continue; |
| | | } |
| | | result.put(requested, new DeviceObservation( |
| | | new DeviceIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(), profile.subDeviceId()), |
| | | requested, DeviceConnectionStatus.ONLINE, channel.workStatus(), List.of(), null, |
| | | java.util.Map.of(), null)); |
| | | } |
| | | return java.util.Map.copyOf(result); |
| | | } |
| | | |
| | | /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */ |
| | | public BtsDevelopmentSimulator(int port) throws IOException |
| | |
| | | .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\"/>"); |
| | | .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(); |
| | | } |
| | |
| | | } |
| | | |
| | | @Override |
| | | public synchronized void close() throws Exception |
| | | public synchronized void close() |
| | | { |
| | | running = false; |
| | | try |
| | | { |
| | | simulator.close(); |
| | | } |
| | | catch (Exception exception) |
| | | { |
| | | throw new IllegalStateException("关闭开发模拟器失败", exception); |
| | | } |
| | | } |
| | | |
| | | private record ObservedChannel(int deviceId, ChannelState channel) { } |
| | | |
| New file |
| | |
| | | package com.battery.device.simulator; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.model.ChannelIdentity; |
| | | import com.battery.device.model.DeviceConnectionStatus; |
| | | import com.battery.device.model.DeviceWorkStatus; |
| | | |
| | | class SimulatorObservationAdapterContractTest |
| | | { |
| | | @Test |
| | | void simulatorCanBeUsedDirectlyThroughTheVendorNeutralContract() throws Exception |
| | | { |
| | | try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0)) |
| | | { |
| | | ChannelIdentity channel = simulator.discoverChannels().get(0).identity(); |
| | | simulator.updateChannel(channel.devid(), channel.chlid(), true, DeviceWorkStatus.WORKING); |
| | | |
| | | var observation = simulator.observe(java.util.Set.of(channel)).get(channel); |
| | | |
| | | assertEquals("dev-sim", simulator.adapterCode()); |
| | | assertEquals(DeviceConnectionStatus.ONLINE, observation.connectionStatus()); |
| | | assertEquals(DeviceWorkStatus.WORKING, observation.workStatus()); |
| | | } |
| | | } |
| | | } |