yeg
2026-09-05 bd50f1b756c1da2dbf92f6541407ad40f455e24a
建立厂商无关设备观测接缝

新增统一设备观测模型、连接状态、计划工步和失败原因。让 BTS、模拟器与 Noop 通过同一观测接口输出在线或不可观测事实,并补充契约测试。
9个文件已添加
6个文件已修改
562 ■■■■■ 已修改文件
battery-device-api/src/main/java/com/battery/device/api/DeviceAdapter.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/DeviceObservationAdapter.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/NoopDeviceAdapter.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/model/DeviceConnectionStatus.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/model/DeviceIdentity.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/model/DeviceObservation.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/model/DeviceWorkStatus.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/model/ObservationFailureReason.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/model/PlannedStep.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/model/RealtimeSnapshot.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/test/java/com/battery/device/api/DeviceObservationContractTest.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/main/java/com/battery/device/bts/BtsXmlCodec.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-bts/src/test/java/com/battery/device/bts/DeviceObservationSeamContractTest.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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);
battery-device-api/src/main/java/com/battery/device/api/DeviceObservationAdapter.java
New file
@@ -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()
    {
    }
}
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(); }
battery-device-api/src/main/java/com/battery/device/model/DeviceConnectionStatus.java
New file
@@ -0,0 +1,8 @@
package com.battery.device.model;
/** 适配器最近一次观测到的设备连接事实。 */
public enum DeviceConnectionStatus
{
    ONLINE,
    OFFLINE
}
battery-device-api/src/main/java/com/battery/device/model/DeviceIdentity.java
New file
@@ -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());
    }
}
battery-device-api/src/main/java/com/battery/device/model/DeviceObservation.java
New file
@@ -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;
        }
    }
}
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)
battery-device-api/src/main/java/com/battery/device/model/ObservationFailureReason.java
New file
@@ -0,0 +1,14 @@
package com.battery.device.model;
/** 观测失败的稳定分类,不暴露厂商协议异常类型。 */
public enum ObservationFailureReason
{
    CONNECTION_FAILED,
    TIMEOUT,
    DISCONNECTED,
    PARSE_FAILED,
    INVALID_RESPONSE,
    TRANSPORT_FAILED,
    UNSUPPORTED,
    OFFLINE
}
battery-device-api/src/main/java/com/battery/device/model/PlannedStep.java
New file
@@ -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");
    }
}
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,
        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();
    }
}
battery-device-api/src/test/java/com/battery/device/api/DeviceObservationContractTest.java
New file
@@ -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());
        }
    }
}
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 时间点。
            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");
battery-device-bts/src/test/java/com/battery/device/bts/DeviceObservationSeamContractTest.java
New file
@@ -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());
        }
    }
}
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,11 +1279,18 @@
    }
    @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) { }
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
New file
@@ -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());
        }
    }
}