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