From bd50f1b756c1da2dbf92f6541407ad40f455e24a Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 05 九月 2026 00:17:52 +0800
Subject: [PATCH] 建立厂商无关设备观测接缝
---
battery-device-api/src/main/java/com/battery/device/api/NoopDeviceAdapter.java | 3
battery-device-api/src/main/java/com/battery/device/model/DeviceObservation.java | 100 ++++++++++
battery-device-api/src/main/java/com/battery/device/model/DeviceWorkStatus.java | 2
battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java | 44 ++++
battery-device-api/src/main/java/com/battery/device/model/PlannedStep.java | 19 +
battery-device-api/src/main/java/com/battery/device/model/DeviceIdentity.java | 21 ++
battery-device-api/src/main/java/com/battery/device/model/ObservationFailureReason.java | 14 +
battery-device-api/src/test/java/com/battery/device/api/DeviceObservationContractTest.java | 27 ++
battery-device-api/src/main/java/com/battery/device/model/RealtimeSnapshot.java | 21 ++
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java | 91 ++++++++
battery-device-bts/src/test/java/com/battery/device/bts/DeviceObservationSeamContractTest.java | 85 ++++++++
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java | 26 ++
battery-device-api/src/main/java/com/battery/device/model/DeviceConnectionStatus.java | 8
battery-device-api/src/main/java/com/battery/device/api/DeviceObservationAdapter.java | 30 +++
battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java | 77 +++++++
15 files changed, 558 insertions(+), 10 deletions(-)
diff --git a/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java b/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java
index 3330443..2afca49 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java
@@ -10,8 +10,10 @@
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();
@@ -25,6 +27,46 @@
/** 批量查询通道实时快照,不形成最终原始采集数据。 */
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);
diff --git a/battery-device-api/src/main/java/com/battery/device/api/DeviceObservationAdapter.java b/battery-device-api/src/main/java/com/battery/device/api/DeviceObservationAdapter.java
new file mode 100644
index 0000000..986e8d0
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/api/DeviceObservationAdapter.java
@@ -0,0 +1,30 @@
+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()
+ {
+ }
+}
diff --git a/battery-device-api/src/main/java/com/battery/device/api/NoopDeviceAdapter.java b/battery-device-api/src/main/java/com/battery/device/api/NoopDeviceAdapter.java
index 454ab46..94603b4 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/NoopDeviceAdapter.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/NoopDeviceAdapter.java
@@ -24,7 +24,8 @@
@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(); }
diff --git a/battery-device-api/src/main/java/com/battery/device/model/DeviceConnectionStatus.java b/battery-device-api/src/main/java/com/battery/device/model/DeviceConnectionStatus.java
new file mode 100644
index 0000000..69e567d
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/model/DeviceConnectionStatus.java
@@ -0,0 +1,8 @@
+package com.battery.device.model;
+
+/** 适配器最近一次观测到的设备连接事实。 */
+public enum DeviceConnectionStatus
+{
+ ONLINE,
+ OFFLINE
+}
diff --git a/battery-device-api/src/main/java/com/battery/device/model/DeviceIdentity.java b/battery-device-api/src/main/java/com/battery/device/model/DeviceIdentity.java
new file mode 100644
index 0000000..94e3806
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/model/DeviceIdentity.java
@@ -0,0 +1,21 @@
+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());
+ }
+}
diff --git a/battery-device-api/src/main/java/com/battery/device/model/DeviceObservation.java b/battery-device-api/src/main/java/com/battery/device/model/DeviceObservation.java
new file mode 100644
index 0000000..0aa6e91
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/model/DeviceObservation.java
@@ -0,0 +1,100 @@
+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;
+ }
+ }
+}
diff --git a/battery-device-api/src/main/java/com/battery/device/model/DeviceWorkStatus.java b/battery-device-api/src/main/java/com/battery/device/model/DeviceWorkStatus.java
index 3379a97..9ee4005 100644
--- a/battery-device-api/src/main/java/com/battery/device/model/DeviceWorkStatus.java
+++ b/battery-device-api/src/main/java/com/battery/device/model/DeviceWorkStatus.java
@@ -14,6 +14,8 @@
WAITING_TIMEOUT,
WAITING_START,
LOCKED,
+ /** 设备离线或请求失败时不得沿用上一次通道状态。 */
+ UNOBSERVABLE,
UNKNOWN;
public static DeviceWorkStatus fromBts(String value)
diff --git a/battery-device-api/src/main/java/com/battery/device/model/ObservationFailureReason.java b/battery-device-api/src/main/java/com/battery/device/model/ObservationFailureReason.java
new file mode 100644
index 0000000..0cc07c1
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/model/ObservationFailureReason.java
@@ -0,0 +1,14 @@
+package com.battery.device.model;
+
+/** 观测失败的稳定分类,不暴露厂商协议异常类型。 */
+public enum ObservationFailureReason
+{
+ CONNECTION_FAILED,
+ TIMEOUT,
+ DISCONNECTED,
+ PARSE_FAILED,
+ INVALID_RESPONSE,
+ TRANSPORT_FAILED,
+ UNSUPPORTED,
+ OFFLINE
+}
diff --git a/battery-device-api/src/main/java/com/battery/device/model/PlannedStep.java b/battery-device-api/src/main/java/com/battery/device/model/PlannedStep.java
new file mode 100644
index 0000000..7638af2
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/model/PlannedStep.java
@@ -0,0 +1,19 @@
+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");
+ }
+}
diff --git a/battery-device-api/src/main/java/com/battery/device/model/RealtimeSnapshot.java b/battery-device-api/src/main/java/com/battery/device/model/RealtimeSnapshot.java
index f5b971f..ede253e 100644
--- a/battery-device-api/src/main/java/com/battery/device/model/RealtimeSnapshot.java
+++ b/battery-device-api/src/main/java/com/battery/device/model/RealtimeSnapshot.java
@@ -2,6 +2,7 @@
import java.math.BigDecimal;
import java.time.Instant;
+import java.util.List;
import java.util.Map;
public record RealtimeSnapshot(
@@ -19,10 +20,28 @@
Long relativeTimeSeconds,
String logCode,
Instant receivedAt,
- Map<String, Measurement> measurements)
+ 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();
}
}
diff --git a/battery-device-api/src/test/java/com/battery/device/api/DeviceObservationContractTest.java b/battery-device-api/src/test/java/com/battery/device/api/DeviceObservationContractTest.java
new file mode 100644
index 0000000..7c0a289
--- /dev/null
+++ b/battery-device-api/src/test/java/com/battery/device/api/DeviceObservationContractTest.java
@@ -0,0 +1,27 @@
+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());
+ }
+ }
+}
diff --git a/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java b/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java
index d9a0468..e5b57ba 100644
--- a/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java
+++ b/battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java
@@ -24,6 +24,7 @@
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;
@@ -31,6 +32,7 @@
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
@@ -38,7 +40,8 @@
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");
@@ -136,12 +139,14 @@
"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);
}
@@ -258,7 +263,14 @@
try
{
// BTS 时间不带时区,按适配器配置的现场时区解释后统一转换为 UTC 时间点。
- return LocalDateTime.parse(value, BTS_TIME).atZone(deviceZone).toInstant();
+ try
+ {
+ return Instant.parse(value);
+ }
+ catch (DateTimeParseException ignored)
+ {
+ return LocalDateTime.parse(value, BTS_TIME).atZone(deviceZone).toInstant();
+ }
}
catch (DateTimeParseException ex)
{
@@ -267,6 +279,65 @@
}
}
+ 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");
diff --git a/battery-device-bts/src/test/java/com/battery/device/bts/DeviceObservationSeamContractTest.java b/battery-device-bts/src/test/java/com/battery/device/bts/DeviceObservationSeamContractTest.java
new file mode 100644
index 0000000..cc79925
--- /dev/null
+++ b/battery-device-bts/src/test/java/com/battery/device/bts/DeviceObservationSeamContractTest.java
@@ -0,0 +1,85 @@
+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());
+ }
+ }
+}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
index 8151eb4..0067285 100644
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
+++ b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -12,6 +12,14 @@
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;
@@ -35,7 +43,7 @@
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;
@@ -58,6 +66,71 @@
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
@@ -721,7 +794,10 @@
.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();
}
@@ -1203,10 +1279,17 @@
}
@Override
- public synchronized void close() throws Exception
+ public synchronized void close()
{
running = false;
- simulator.close();
+ try
+ {
+ simulator.close();
+ }
+ catch (Exception exception)
+ {
+ throw new IllegalStateException("关闭开发模拟器失败", exception);
+ }
}
private record ObservedChannel(int deviceId, ChannelState channel) { }
diff --git a/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java b/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
new file mode 100644
index 0000000..86e331a
--- /dev/null
+++ b/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
@@ -0,0 +1,26 @@
+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());
+ }
+ }
+}
--
Gitblit v1.7.1