yeg
2026-08-25 6ba67935468ff5129f9341da0cf884af2756c0e5
设备模拟器实现
25个文件已添加
6个文件已修改
1283 ■■■■■ 已修改文件
.gitignore 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
AGENTS.md 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/pom.xml 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/FlywayMigrationConfig.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application-druid.yml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application.yml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/logback.xml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/pom.xml 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransport.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/AdapterCapabilities.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/ChannelIdentity.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedChannel.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/DeviceControlCommand.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/DeviceWorkStatus.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryPage.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/HistorySample.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/Measurement.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/model/RealtimeSnapshot.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConfig.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/Build-FlywayBaseline.ps1 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/backups/pre-flyway-empty-20260825.sql 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
New file
@@ -0,0 +1,11 @@
/ruoyi-admin/target/
/ruoyi-common/target/
/ruoyi-framework/target/
/ruoyi-generator/target/
/ruoyi-quartz/target/
/ruoyi-system/target/
/.idea/
/target/
/ruoyi-device/target/
/logs/
/ruoyi-admin/src/main/resources/db/migration/
AGENTS.md
New file
@@ -0,0 +1,50 @@
# 后端开发规则
本文件适用于 `BatteryDetection`。同时遵守父目录 `AGENTS.md` 的项目不变量。
## 模块边界
- `ruoyi-device` 负责厂商无关设备接口、BTS 协议实现、模拟器契约和采集调度。协议 XML、TCP 帧、固定地址和厂商枚举停留在该模块内部。
- `ruoyi-system` 负责测试业务、状态聚合、事务、持久化和审计;它依赖设备抽象,不解析 BTS XML。
- `ruoyi-admin` 只负责应用装配及 REST/SSE 控制器,不承载采集或状态机业务。
- 第一阶段打包为一个后端进程,并明确保持单采集实例。适配器租约字段只为后续高可用预留。
## 设备路径
- 每个设备适配器实例从数据库读取名称、类型、主机、端口、启停、轮询周期、时区和能力,不在代码中固定连接参数。
- 每个实例只有一个串行请求队列;一次 `inquire` 批量查询需监测通道。状态查询默认每 5 秒,历史下载使用较低优先级。
- 连接拒绝、最长约 30 秒响应超时、断线和后端重启均进入对账与恢复流程。恢复时重新查询设备事实并从持久化游标补采,不推断设备已停止。
- 历史补采按测试结束时间和通道公平轮转,每批最多 1000 条;数据保存成功后才推进数据库游标。
- 解析器拒绝 DOCTYPE/外部实体,限制响应大小,并显式处理缺字段、非法数值和未知枚举。
- 协议报文压缩保存到文件系统,数据库仅保存索引、校验值和解析结果;普通报文保留 30 天,解析失败或冲突报文保留 180 天。
## 业务事务
- 开始监测在一个事务内锁定全部绑定通道;任一通道不可用则整体失败。数据库唯一约束必须阻止并发占用。
- 未观测运行前,负责人或管理员可以取消等待并释放通道;已观测运行后走异常处理或终止流程。
- 所有通道正常结束后先进入内部“完成确认中”;只有补采完成且没有未解释数据缺口,才聚合为“检测完成”。
- “检测完成”和“已终止”是不可恢复终态。异常恢复、数据缺口处置、终止和负责人转交都追加审计。
- 相同“测试 + 通道 + 异常类型”在未恢复期间聚合首次时间、最近时间和次数;恢复后关闭,再次发生创建新事件。
## 数据库迁移
- 开发数据库是 WSL 内 Docker 的 MySQL 8,通过 Windows 端口映射 `127.0.0.1:3307/battery_detection` 访问。连接配置保留可覆盖能力,部署环境通过参数提供凭据。
- 已应用的 `V1`、`V2`、`V3` 迁移是不可变基线。任何结构或种子变化新增下一个 `V<n>__description.sql`,不要改写已应用脚本或整库导出文件。
- 迁移必须可从空库顺序执行,并可在已迁移库重复启动。迁移不得导入演示设备、演示测试、采样数据或历史日志。
- 项目排除了默认 `DataSourceAutoConfiguration`;Flyway 在 `FlywayMigrationConfig` 中使用独立 JDBC 连接,并且 `dynamicDataSource` 必须等待迁移完成。
- 修改迁移前先只读检查目标库和 `flyway_schema_history`。清库、覆盖或修复迁移历史需要用户明确授权。
## API 与实时流
- 普通提交和历史区间查询使用 REST;实时快照、状态变化和异常使用 `SseEmitter`。
- SSE 只保留每测试每通道最新快照。状态变化和异常不可合并;发送失败关闭连接。重连先发当前快照,历史由 REST 查询。
- 权限由后端统一执行。创建人、负责人和管理员是独立字段和角色;不要依赖前端隐藏按钮实现授权。
- API 时间输出 ISO 8601 带时区,测量值同时返回明确单位。
## 验证
- 设备协议或调度改动至少覆盖正常多通道、拒绝连接、断线恢复、超时、畸形 XML、重复/乱序/缺失历史和重启补采中受影响的场景。
- 运行设备模块测试:`mvn -pl ruoyi-device test`。
- 跨模块改动运行全量测试:`mvn test`。
- 验证可执行包:`mvn -pl ruoyi-admin -am package -DskipTests`。启动验证时使用可写的 `LOG_PATH`,完成后停止临时进程。
pom.xml
@@ -18,6 +18,7 @@
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>17</java.version>
        <spring-boot.version>4.0.6</spring-boot.version>
        <flyway.version>9.22.3</flyway.version>
        <mybatis-spring-boot.version>4.0.1</mybatis-spring-boot.version>
        <druid.version>1.2.28</druid.version>
        <yauaa.version>8.1.1</yauaa.version>
@@ -163,6 +164,13 @@
                <version>${ruoyi.version}</version>
            </dependency>
            <!-- 设备适配模块 -->
            <dependency>
                <groupId>com.ruoyi</groupId>
                <artifactId>ruoyi-device</artifactId>
                <version>${ruoyi.version}</version>
            </dependency>
            <!-- 通用工具-->
            <dependency>
                <groupId>com.ruoyi</groupId>
@@ -177,6 +185,7 @@
        <module>ruoyi-admin</module>
        <module>ruoyi-framework</module>
        <module>ruoyi-system</module>
        <module>ruoyi-device</module>
        <module>ruoyi-quartz</module>
        <module>ruoyi-generator</module>
        <module>ruoyi-common</module>
@@ -195,6 +204,11 @@
                    <target>${java.version}</target>
                    <encoding>${project.build.sourceEncoding}</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.5</version>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
@@ -229,4 +243,4 @@
        </pluginRepository>
    </pluginRepositories>
</project>
</project>
ruoyi-admin/pom.xml
@@ -36,6 +36,18 @@
            <artifactId>mysql-connector-j</artifactId>
        </dependency>
        <!-- Versioned database migrations -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
            <version>${flyway.version}</version>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-mysql</artifactId>
            <version>${flyway.version}</version>
        </dependency>
        <!-- 单元测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
@@ -47,6 +59,12 @@
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-framework</artifactId>
        </dependency>
        <!-- 设备适配模块 -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-device</artifactId>
        </dependency>
        <!-- 定时任务-->
@@ -108,4 +126,4 @@
        <finalName>${project.artifactId}</finalName>
    </build>
</project>
</project>
ruoyi-admin/src/main/java/com/ruoyi/config/FlywayMigrationConfig.java
New file
@@ -0,0 +1,54 @@
package com.ruoyi.config;
import javax.sql.DataSource;
import com.alibaba.druid.pool.DruidDataSource;
import org.flywaydb.core.Flyway;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
 * Runs versioned migrations against the same master datasource used by MyBatis.
 * The application uses a custom Druid datasource, so Spring Boot's standard
 * Flyway auto-configuration is not available here.
 */
@Configuration
public class FlywayMigrationConfig
{
    @Bean(name = "flywayMigrationInitializer", initMethod = "migrate")
    public FlywayMigrationInitializer flywayMigrationInitializer(@Qualifier("masterDataSource") DataSource dataSource)
    {
        if (!(dataSource instanceof DruidDataSource druidDataSource))
        {
            throw new IllegalStateException("Flyway requires the configured Druid master datasource");
        }
        Flyway flyway = Flyway.configure()
                // Flyway must not use a pooled Druid connection here. Druid disables
                // the connection when MySQL denies Flyway's optional performance_schema probe.
                .dataSource(druidDataSource.getUrl(), druidDataSource.getUsername(), druidDataSource.getPassword())
                .locations("classpath:db/migration")
                .baselineOnMigrate(false)
                .validateOnMigrate(true)
                .outOfOrder(false)
                .cleanDisabled(true)
                .load();
        return new FlywayMigrationInitializer(flyway);
    }
    /** A small lifecycle bean keeps migration execution explicit and testable. */
    public static final class FlywayMigrationInitializer
    {
        private final Flyway flyway;
        public FlywayMigrationInitializer(Flyway flyway)
        {
            this.flyway = flyway;
        }
        public void migrate()
        {
            flyway.migrate();
        }
    }
}
ruoyi-admin/src/main/resources/application-druid.yml
@@ -6,7 +6,7 @@
        druid:
            # 主库数据源
            master:
                url: jdbc:mysql://192.168.110.74:3306/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
                url: jdbc:mysql://127.0.0.1:3307/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
                username: root
                password: 123456
            # 从库数据源
@@ -58,4 +58,4 @@
                    merge-sql: true
                wall:
                    config:
                        multi-statement-allow: true
                        multi-statement-allow: true
ruoyi-admin/src/main/resources/application.yml
@@ -47,6 +47,13 @@
# Spring配置
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: false
    validate-on-migrate: true
    out-of-order: false
    clean-disabled: true
  # 资源信息
  messages:
    # 国际化资源文件路径
@@ -78,7 +85,7 @@
      # 数据库索引
      database: 0
      # 密码
      password: 123456
      password: 7ff0af25668a40172ca3d8da0f4520fe547a835767218c7b
      # 连接超时时间
      timeout: 10s
      lettuce:
ruoyi-admin/src/main/resources/logback.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 日志存放路径 -->
    <property name="log.path" value="/home/ruoyi/logs" />
    <property name="log.path" value="${LOG_PATH:-./logs}" />
    <!-- 日志输出格式 -->
    <property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
@@ -90,4 +90,4 @@
    <logger name="sys-user" level="info">
        <appender-ref ref="sys-user"/>
    </logger>
</configuration>
</configuration>
ruoyi-device/pom.xml
New file
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.ruoyi</groupId>
        <artifactId>ruoyi</artifactId>
        <version>3.9.2</version>
    </parent>
    <artifactId>ruoyi-device</artifactId>
    <description>厂商无关设备适配、BTS 协议与采集调度</description>
    <dependencies>
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>
ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapter.java
New file
@@ -0,0 +1,34 @@
package com.ruoyi.device.api;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.ruoyi.device.model.AdapterCapabilities;
import com.ruoyi.device.model.ChannelIdentity;
import com.ruoyi.device.model.DetectedChannel;
import com.ruoyi.device.model.DeviceControlCommand;
import com.ruoyi.device.model.HistoryPage;
import com.ruoyi.device.model.RealtimeSnapshot;
public interface DeviceAdapter extends AutoCloseable
{
    String adapterCode();
    AdapterCapabilities capabilities();
    List<DetectedChannel> discoverChannels();
    Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels);
    HistoryPage downloadHistory(ChannelIdentity channel, String btsTestId, long startPosition, int limit);
    default void control(ChannelIdentity channel, DeviceControlCommand command)
    {
        throw new UnsupportedOperationException("Device control is not enabled for this adapter");
    }
    @Override
    default void close()
    {
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java
New file
@@ -0,0 +1,16 @@
package com.ruoyi.device.api;
public class DeviceAdapterException extends RuntimeException
{
    private static final long serialVersionUID = 1L;
    public DeviceAdapterException(String message)
    {
        super(message);
    }
    public DeviceAdapterException(String message, Throwable cause)
    {
        super(message, cause);
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java
New file
@@ -0,0 +1,64 @@
package com.ruoyi.device.bts;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.ruoyi.device.api.DeviceAdapter;
import com.ruoyi.device.model.AdapterCapabilities;
import com.ruoyi.device.model.ChannelIdentity;
import com.ruoyi.device.model.DetectedChannel;
import com.ruoyi.device.model.HistoryPage;
import com.ruoyi.device.model.RealtimeSnapshot;
public final class BtsDeviceAdapter implements DeviceAdapter
{
    private final String adapterCode;
    private final BtsTransport transport;
    private final BtsXmlCodec codec;
    public BtsDeviceAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec)
    {
        this.adapterCode = adapterCode;
        this.transport = transport;
        this.codec = codec;
    }
    @Override
    public String adapterCode()
    {
        return adapterCode;
    }
    @Override
    public AdapterCapabilities capabilities()
    {
        return AdapterCapabilities.observeOnly();
    }
    @Override
    public List<DetectedChannel> discoverChannels()
    {
        return codec.parseDeviceInfo(transport.exchange(codec.getDeviceInfoRequest()));
    }
    @Override
    public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
    {
        if (channels == null || channels.isEmpty()) return Map.of();
        return codec.parseSnapshots(transport.exchange(codec.inquireRequest(channels, false)), Instant.now());
    }
    @Override
    public HistoryPage downloadHistory(ChannelIdentity channel, String btsTestId, long startPosition, int limit)
    {
        return codec.parseHistory(transport.exchange(codec.downloadRequest(channel, btsTestId, startPosition, limit)),
                channel, startPosition);
    }
    @Override
    public void close()
    {
        transport.close();
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java
New file
@@ -0,0 +1,18 @@
package com.ruoyi.device.bts;
import com.ruoyi.device.api.DeviceAdapterException;
public class BtsProtocolException extends DeviceAdapterException
{
    private static final long serialVersionUID = 1L;
    public BtsProtocolException(String message)
    {
        super(message);
    }
    public BtsProtocolException(String message, Throwable cause)
    {
        super(message, cause);
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTcpTransport.java
New file
@@ -0,0 +1,132 @@
package com.ruoyi.device.bts;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
import com.ruoyi.device.api.DeviceAdapterException;
public final class BtsTcpTransport implements BtsTransport
{
    private static final byte[] FRAME_END = "#\r\n".getBytes(StandardCharsets.UTF_8);
    private static final int DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
    private final String host;
    private final int port;
    private final int connectTimeoutMs;
    private final int responseTimeoutMs;
    private final int maxResponseBytes;
    private final ReentrantLock requestLock = new ReentrantLock(true);
    private Socket socket;
    public BtsTcpTransport(String host, int port, Duration connectTimeout, Duration responseTimeout)
    {
        this(host, port, connectTimeout, responseTimeout, DEFAULT_MAX_RESPONSE_BYTES);
    }
    BtsTcpTransport(String host, int port, Duration connectTimeout, Duration responseTimeout, int maxResponseBytes)
    {
        this.host = Objects.requireNonNull(host, "host");
        this.port = port;
        this.connectTimeoutMs = Math.toIntExact(connectTimeout.toMillis());
        this.responseTimeoutMs = Math.toIntExact(responseTimeout.toMillis());
        this.maxResponseBytes = maxResponseBytes;
        if (host.isBlank() || port < 1 || port > 65535) throw new IllegalArgumentException("Invalid BTS TCP endpoint");
        if (connectTimeoutMs < 1 || responseTimeoutMs < 1) throw new IllegalArgumentException("Timeouts must be positive");
        if (maxResponseBytes < 1) throw new IllegalArgumentException("maxResponseBytes must be positive");
    }
    @Override
    public String exchange(String request)
    {
        Objects.requireNonNull(request, "request");
        requestLock.lock();
        try
        {
            ensureConnected();
            OutputStream output = socket.getOutputStream();
            output.write(request.getBytes(StandardCharsets.UTF_8));
            output.write(FRAME_END);
            output.flush();
            return readFrame(socket.getInputStream());
        }
        catch (IOException ex)
        {
            closeSocket();
            throw new DeviceAdapterException("BTS TCP exchange failed for " + host + ":" + port, ex);
        }
        finally
        {
            requestLock.unlock();
        }
    }
    private void ensureConnected() throws IOException
    {
        if (socket != null && socket.isConnected() && !socket.isClosed()) return;
        Socket connected = new Socket();
        connected.connect(new InetSocketAddress(host, port), connectTimeoutMs);
        connected.setSoTimeout(responseTimeoutMs);
        socket = connected;
    }
    private String readFrame(InputStream input) throws IOException
    {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        int matched = 0;
        while (buffer.size() < maxResponseBytes)
        {
            int value = input.read();
            if (value < 0) throw new IOException("BTS closed the connection before the response frame ended");
            if ((byte) value == FRAME_END[matched])
            {
                matched++;
                if (matched == FRAME_END.length) return buffer.toString(StandardCharsets.UTF_8);
                continue;
            }
            if (matched > 0)
            {
                buffer.write(FRAME_END, 0, matched);
                matched = 0;
            }
            buffer.write(value);
        }
        throw new IOException("BTS response exceeded " + maxResponseBytes + " bytes");
    }
    @Override
    public void close()
    {
        requestLock.lock();
        try
        {
            closeSocket();
        }
        finally
        {
            requestLock.unlock();
        }
    }
    private void closeSocket()
    {
        if (socket == null) return;
        try
        {
            socket.close();
        }
        catch (IOException ignored)
        {
        }
        finally
        {
            socket = null;
        }
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransport.java
New file
@@ -0,0 +1,11 @@
package com.ruoyi.device.bts;
public interface BtsTransport extends AutoCloseable
{
    String exchange(String request);
    @Override
    default void close()
    {
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java
New file
@@ -0,0 +1,309 @@
package com.ruoyi.device.bts;
import java.io.StringReader;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import com.ruoyi.device.model.ChannelIdentity;
import com.ruoyi.device.model.DetectedChannel;
import com.ruoyi.device.model.DeviceWorkStatus;
import com.ruoyi.device.model.HistoryPage;
import com.ruoyi.device.model.HistorySample;
import com.ruoyi.device.model.Measurement;
import com.ruoyi.device.model.RealtimeSnapshot;
public final class BtsXmlCodec
{
    private static final DateTimeFormatter BTS_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH.mm.ss");
    private static final Set<String> SNAPSHOT_ATTRIBUTES = Set.of(
            "dev", "cycle_id", "step_id", "step_type", "workstatus", "barcode", "current", "voltage",
            "capacity", "energy", "totaltime", "relativetime", "auxtemp", "auxvol", "open_or_close", "log_code");
    private static final Set<String> HISTORY_ATTRIBUTES = Set.of(
            "seqid", "stepid", "cycleid", "steptype", "testtime", "atime", "volt", "curr", "cap", "eng");
    private final String adapterCode;
    private final ZoneId deviceZone;
    public BtsXmlCodec(String adapterCode, ZoneId deviceZone)
    {
        if (adapterCode == null || adapterCode.isBlank()) throw new IllegalArgumentException("adapterCode must not be blank");
        this.adapterCode = adapterCode.trim();
        this.deviceZone = deviceZone;
    }
    public String getDeviceInfoRequest()
    {
        return envelope("<cmd>getdevinfo</cmd>");
    }
    public String inquireRequest(Set<ChannelIdentity> channels, boolean includeBarcode)
    {
        StringBuilder list = new StringBuilder("<cmd>inquire</cmd><list count=\"").append(channels.size()).append("\">");
        for (ChannelIdentity channel : channels)
        {
            requireOwned(channel);
            list.append("<inquire ip=\"127.0.0.1\" devtype=\"").append(channel.devtype())
                    .append("\" devid=\"").append(channel.devid())
                    .append("\" subdevid=\"").append(channel.subdevid())
                    .append("\" chlid=\"").append(channel.chlid())
                    .append("\" aux=\"0\" barcode=\"").append(includeBarcode ? 1 : 0).append("\">true</inquire>");
        }
        return envelope(list.append("</list>").toString());
    }
    public String downloadRequest(ChannelIdentity channel, String btsTestId, long startPosition, int limit)
    {
        requireOwned(channel);
        if (btsTestId == null || btsTestId.isBlank()) throw new IllegalArgumentException("btsTestId must not be blank");
        if (startPosition < 1) throw new IllegalArgumentException("startPosition must be positive");
        if (limit < 1 || limit > 1000) throw new IllegalArgumentException("BTS history limit must be between 1 and 1000");
        return envelope("<cmd>download</cmd><download devtype=\"" + channel.devtype()
                + "\" devid=\"" + channel.devid() + "\" subdevid=\"" + channel.subdevid()
                + "\" chlid=\"" + channel.chlid() + "\" auxid=\"0\" testid=\"" + escape(btsTestId)
                + "\" startpos=\"" + startPosition + "\" count=\"" + limit + "\"/>");
    }
    public List<DetectedChannel> parseDeviceInfo(String xml)
    {
        Document document = parse(xml, "getdevinfo_resp");
        NodeList nodes = document.getElementsByTagName("channel");
        List<DetectedChannel> channels = new ArrayList<>(nodes.getLength());
        for (int i = 0; i < nodes.getLength(); i++)
        {
            Element element = (Element) nodes.item(i);
            int chlid = integerAttribute(element, element.hasAttribute("Channelid") ? "Channelid" : "chlid");
            ChannelIdentity identity = new ChannelIdentity(adapterCode, integerAttribute(element, "devtype"),
                    integerAttribute(element, "devid"), integerAttribute(element, "subdevid"), chlid);
            channels.add(new DetectedChannel(identity, requiredAttribute(element, "ip"),
                    Boolean.parseBoolean(element.getTextContent().trim())));
        }
        return List.copyOf(channels);
    }
    public Map<ChannelIdentity, RealtimeSnapshot> parseSnapshots(String xml, Instant receivedAt)
    {
        Document document = parse(xml, "inquire_resp");
        NodeList nodes = document.getElementsByTagName("inquire");
        Map<ChannelIdentity, RealtimeSnapshot> snapshots = new LinkedHashMap<>();
        for (int i = 0; i < nodes.getLength(); i++)
        {
            Element element = (Element) nodes.item(i);
            String[] device = requiredAttribute(element, "dev").split("-");
            if (device.length < 4) throw new BtsProtocolException("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"));
            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)));
        }
        return Map.copyOf(snapshots);
    }
    public HistoryPage parseHistory(String xml, ChannelIdentity channel, long requestedStart)
    {
        Document document = parse(xml, "download_resp");
        Element download = firstElement(document, "download");
        String btsTestId = requiredAttribute(download, "testid");
        NodeList nodes = document.getElementsByTagName("data");
        List<HistorySample> samples = new ArrayList<>(nodes.getLength());
        long next = requestedStart;
        for (int i = 0; i < nodes.getLength(); i++)
        {
            Element element = (Element) nodes.item(i);
            long sequence = longAttribute(element, "seqid");
            String rawTime = requiredAttribute(element, "atime");
            samples.add(new HistorySample(sequence, integerAttribute(element, "stepid"), integerAttribute(element, "cycleid"),
                    requiredAttribute(element, "steptype"), optionalLong(element, "testtime"), parseDeviceTime(rawTime), rawTime,
                    optionalDecimal(element, "volt"), optionalDecimal(element, "curr"), optionalDecimal(element, "cap"),
                    optionalDecimal(element, "eng"), dynamicMeasurements(element, HISTORY_ATTRIBUTES)));
            next = Math.max(next, sequence + 1);
        }
        return new HistoryPage(channel, btsTestId, requestedStart, next, samples);
    }
    private Document parse(String xml, String expectedCommand)
    {
        try
        {
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
            factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
            factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
            factory.setXIncludeAware(false);
            factory.setExpandEntityReferences(false);
            Document document = factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
            String command = firstElement(document, "cmd").getTextContent().trim();
            if (!expectedCommand.equals(command))
                throw new BtsProtocolException("Expected BTS command " + expectedCommand + " but received " + command);
            return document;
        }
        catch (BtsProtocolException ex)
        {
            throw ex;
        }
        catch (Exception ex)
        {
            throw new BtsProtocolException("Invalid BTS XML response", ex);
        }
    }
    private Map<String, Measurement> dynamicMeasurements(Element element, Set<String> knownAttributes)
    {
        Map<String, Measurement> measurements = new HashMap<>();
        NamedNodeMap attributes = element.getAttributes();
        for (int i = 0; i < attributes.getLength(); i++)
        {
            Node attribute = attributes.item(i);
            if (!knownAttributes.contains(attribute.getNodeName()))
            {
                BigDecimal value = decimal(attribute.getNodeValue(), attribute.getNodeName());
                measurements.put(attribute.getNodeName(), new Measurement(attribute.getNodeName(), value, null, "", "AUX"));
            }
        }
        NodeList dbcNodes = element.getElementsByTagName("DBC");
        for (int i = 0; i < dbcNodes.getLength(); i++)
        {
            Element dbc = (Element) dbcNodes.item(i);
            String code = "DBC:" + requiredAttribute(dbc, "message_name") + ":" + requiredAttribute(dbc, "signal_name");
            measurements.put(code, new Measurement(code, decimal(requiredAttribute(dbc, "value"), code), null,
                    optionalAttribute(dbc, "unit"), "DBC"));
        }
        return Map.copyOf(measurements);
    }
    private Instant parseDeviceTime(String value)
    {
        try
        {
            return LocalDateTime.parse(value, BTS_TIME).atZone(deviceZone).toInstant();
        }
        catch (DateTimeParseException ex)
        {
            throw new BtsProtocolException("Invalid BTS device time: " + value, ex);
        }
    }
    private void requireOwned(ChannelIdentity channel)
    {
        if (!adapterCode.equals(channel.adapterCode())) throw new IllegalArgumentException("Channel belongs to another adapter");
    }
    private static String envelope(String body)
    {
        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bts version=\"1.0\">" + body + "</bts>";
    }
    private static String escape(String value)
    {
        return value.replace("&", "&amp;").replace("\"", "&quot;").replace("<", "&lt;").replace(">", "&gt;");
    }
    private static Element firstElement(Document document, String tag)
    {
        NodeList elements = document.getElementsByTagName(tag);
        if (elements.getLength() == 0) throw new BtsProtocolException("Missing BTS element: " + tag);
        return (Element) elements.item(0);
    }
    private static String requiredAttribute(Element element, String name)
    {
        String value = element.getAttribute(name);
        if (value == null || value.isBlank()) throw new BtsProtocolException("Missing BTS attribute: " + name);
        return value.trim();
    }
    private static String optionalAttribute(Element element, String name)
    {
        String value = element.getAttribute(name);
        return value == null || value.isBlank() ? null : value.trim();
    }
    private static int integerAttribute(Element element, String name)
    {
        return integer(requiredAttribute(element, name), name);
    }
    private static long longAttribute(Element element, String name)
    {
        try
        {
            return Long.parseLong(requiredAttribute(element, name));
        }
        catch (NumberFormatException ex)
        {
            throw new BtsProtocolException("Invalid integer BTS attribute: " + name, ex);
        }
    }
    private static Integer optionalInteger(Element element, String name)
    {
        String value = optionalAttribute(element, name);
        return value == null ? null : integer(value, name);
    }
    private static Long optionalLong(Element element, String name)
    {
        String value = optionalAttribute(element, name);
        if (value == null) return null;
        try
        {
            return Long.parseLong(value);
        }
        catch (NumberFormatException ex)
        {
            throw new BtsProtocolException("Invalid integer BTS attribute: " + name, ex);
        }
    }
    private static int integer(String value, String name)
    {
        try
        {
            return Integer.parseInt(value);
        }
        catch (NumberFormatException ex)
        {
            throw new BtsProtocolException("Invalid integer BTS attribute: " + name, ex);
        }
    }
    private static BigDecimal optionalDecimal(Element element, String name)
    {
        String value = optionalAttribute(element, name);
        return value == null ? null : decimal(value, name);
    }
    private static BigDecimal decimal(String value, String name)
    {
        try
        {
            return new BigDecimal(value);
        }
        catch (NumberFormatException ex)
        {
            throw new BtsProtocolException("Invalid decimal BTS attribute: " + name, ex);
        }
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/model/AdapterCapabilities.java
New file
@@ -0,0 +1,13 @@
package com.ruoyi.device.model;
public record AdapterCapabilities(
        boolean discovery,
        boolean realtimeObservation,
        boolean historyDownload,
        boolean control)
{
    public static AdapterCapabilities observeOnly()
    {
        return new AdapterCapabilities(true, true, true, false);
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/model/ChannelIdentity.java
New file
@@ -0,0 +1,24 @@
package com.ruoyi.device.model;
import java.util.Objects;
public record ChannelIdentity(
        String adapterCode,
        int devtype,
        int devid,
        int subdevid,
        int chlid)
{
    public ChannelIdentity
    {
        adapterCode = Objects.requireNonNull(adapterCode, "adapterCode").trim();
        if (adapterCode.isEmpty()) throw new IllegalArgumentException("adapterCode must not be blank");
        if (devtype < 0 || devid < 0 || subdevid < 0 || chlid < 0)
            throw new IllegalArgumentException("BTS channel identity values must be non-negative");
    }
    public String vendorKey()
    {
        return devtype + "-" + devid + "-" + subdevid + "-" + chlid;
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedChannel.java
New file
@@ -0,0 +1,5 @@
package com.ruoyi.device.model;
public record DetectedChannel(ChannelIdentity identity, String serverIp, boolean available)
{
}
ruoyi-device/src/main/java/com/ruoyi/device/model/DeviceControlCommand.java
New file
@@ -0,0 +1,9 @@
package com.ruoyi.device.model;
public enum DeviceControlCommand
{
    START,
    PAUSE,
    CONTINUE,
    STOP
}
ruoyi-device/src/main/java/com/ruoyi/device/model/DeviceWorkStatus.java
New file
@@ -0,0 +1,35 @@
package com.ruoyi.device.model;
import java.util.Locale;
public enum DeviceWorkStatus
{
    WORKING,
    STOPPED,
    PROTECTED,
    PAUSED,
    SYNCHRONIZING,
    LIT,
    WAITING_TIMEOUT,
    WAITING_START,
    LOCKED,
    UNKNOWN;
    public static DeviceWorkStatus fromBts(String value)
    {
        if (value == null) return UNKNOWN;
        return switch (value.trim().toLowerCase(Locale.ROOT))
        {
            case "working" -> WORKING;
            case "stop" -> STOPPED;
            case "protect" -> PROTECTED;
            case "pause" -> PAUSED;
            case "synctrl" -> SYNCHRONIZING;
            case "ligth", "light" -> LIT;
            case "waittimeout" -> WAITING_TIMEOUT;
            case "waitstart" -> WAITING_START;
            case "lock" -> LOCKED;
            default -> UNKNOWN;
        };
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/model/HistoryPage.java
New file
@@ -0,0 +1,16 @@
package com.ruoyi.device.model;
import java.util.List;
public record HistoryPage(
        ChannelIdentity channel,
        String btsTestId,
        long startPosition,
        long nextPosition,
        List<HistorySample> samples)
{
    public HistoryPage
    {
        samples = samples == null ? List.of() : List.copyOf(samples);
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/model/HistorySample.java
New file
@@ -0,0 +1,25 @@
package com.ruoyi.device.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record HistorySample(
        long sequenceId,
        int stepNo,
        int cycleNo,
        String stepType,
        Long testTimeMs,
        Instant deviceTime,
        String deviceTimeRaw,
        BigDecimal voltageV,
        BigDecimal currentA,
        BigDecimal capacityAh,
        BigDecimal energyWh,
        Map<String, Measurement> measurements)
{
    public HistorySample
    {
        measurements = measurements == null ? Map.of() : Map.copyOf(measurements);
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/model/Measurement.java
New file
@@ -0,0 +1,7 @@
package com.ruoyi.device.model;
import java.math.BigDecimal;
public record Measurement(String code, BigDecimal value, String textValue, String unit, String sourceType)
{
}
ruoyi-device/src/main/java/com/ruoyi/device/model/RealtimeSnapshot.java
New file
@@ -0,0 +1,28 @@
package com.ruoyi.device.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record 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)
{
    public RealtimeSnapshot
    {
        measurements = measurements == null ? Map.of() : Map.copyOf(measurements);
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsTcpTransportTest.java
New file
@@ -0,0 +1,43 @@
package com.ruoyi.device.bts;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
class BtsTcpTransportTest
{
    @Test
    void framesRequestsAndSerializesConcurrentExchanges() throws Exception
    {
        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(), Duration.ofSeconds(1), Duration.ofSeconds(2)))
        {
            ExecutorService callers = Executors.newFixedThreadPool(2);
            try
            {
                simulator.enqueue("<bts><cmd>first_resp</cmd></bts>");
                simulator.enqueue("<bts><cmd>second_resp</cmd></bts>");
                Callable<String> first = () -> transport.exchange("<bts><cmd>first</cmd></bts>");
                Callable<String> second = () -> transport.exchange("<bts><cmd>second</cmd></bts>");
                Future<String> firstResult = callers.submit(first);
                Future<String> secondResult = callers.submit(second);
                Set<String> responses = Set.of(firstResult.get(), secondResult.get());
                assertEquals(Set.of("<bts><cmd>first_resp</cmd></bts>", "<bts><cmd>second_resp</cmd></bts>"), responses);
                assertEquals(2, simulator.requests().size());
                assertTrue(simulator.requests().stream().allMatch(request -> request.startsWith("<bts>")));
            }
            finally
            {
                callers.shutdownNow();
            }
        }
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/bts/BtsXmlCodecTest.java
New file
@@ -0,0 +1,95 @@
package com.ruoyi.device.bts;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Map;
import org.junit.jupiter.api.Test;
import com.ruoyi.device.model.ChannelIdentity;
import com.ruoyi.device.model.DeviceWorkStatus;
import com.ruoyi.device.model.HistoryPage;
import com.ruoyi.device.model.RealtimeSnapshot;
class BtsXmlCodecTest
{
    private final BtsXmlCodec codec = new BtsXmlCodec("lab-bts", ZoneId.of("Asia/Hong_Kong"));
    @Test
    void parsesDeviceDiscoveryAndChannelIdentity()
    {
        String xml = "<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd><middle count=\"1\">"
                + "<channel ip=\"192.168.1.35\" devtype=\"22\" devid=\"1\" subdevid=\"2\" Channelid=\"3\">true</channel>"
                + "</middle></bts>";
        var channels = codec.parseDeviceInfo(xml);
        assertEquals(1, channels.size());
        assertEquals(new ChannelIdentity("lab-bts", 22, 1, 2, 3), channels.get(0).identity());
        assertEquals("192.168.1.35", channels.get(0).serverIp());
    }
    @Test
    void parsesRealtimeCoreDynamicAndDbcMeasurements()
    {
        String xml = "<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"1\">"
                + "<inquire dev=\"22-3-2-7-0\" cycle_id=\"2\" step_id=\"4\" step_type=\"cc\" workstatus=\"working\" "
                + "current=\"1.2\" voltage=\"3.5\" capacity=\"0.1\" energy=\"0.35\" T1=\"23.4\" log_code=\"0\">"
                + "<DBC message_name=\"Frame1\" signal_name=\"Soc\" value=\"88.5\" unit=\"%\"/></inquire>"
                + "</list></bts>";
        Map<ChannelIdentity, RealtimeSnapshot> result = codec.parseSnapshots(xml, Instant.parse("2026-08-25T10:00:00Z"));
        RealtimeSnapshot snapshot = result.values().iterator().next();
        assertEquals(DeviceWorkStatus.WORKING, snapshot.workStatus());
        assertEquals(new BigDecimal("1.2"), snapshot.currentA());
        assertEquals(new BigDecimal("23.4"), snapshot.measurements().get("T1").value());
        assertEquals("%", snapshot.measurements().get("DBC:Frame1:Soc").unit());
    }
    @Test
    void mapsUnknownWorkStatusWithoutDiscardingSnapshot()
    {
        String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"22-1-1-1-0\" workstatus=\"futureState\"/></list></bts>";
        RealtimeSnapshot snapshot = codec.parseSnapshots(xml, Instant.EPOCH).values().iterator().next();
        assertEquals(DeviceWorkStatus.UNKNOWN, snapshot.workStatus());
    }
    @Test
    void parsesHistoryPageAndHongKongDeviceTime()
    {
        ChannelIdentity channel = new ChannelIdentity("lab-bts", 24, 1, 1, 1);
        String xml = "<bts><cmd>download_resp</cmd><download testid=\"42\"/><list count=\"1\">"
                + "<data seqid=\"7\" stepid=\"1\" cycleid=\"2\" steptype=\"cc\" testtime=\"1000\" "
                + "atime=\"2026-08-25 18.00.00\" volt=\"3.5\" curr=\"1.2\" cap=\"0.1\" eng=\"0.35\" T1=\"25\"/>"
                + "</list></bts>";
        HistoryPage page = codec.parseHistory(xml, channel, 7);
        assertEquals("42", page.btsTestId());
        assertEquals(8, page.nextPosition());
        assertEquals(Instant.parse("2026-08-25T10:00:00Z"), page.samples().get(0).deviceTime());
    }
    @Test
    void rejectsIllegalNumericData()
    {
        String xml = "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"22-1-1-1-0\" current=\"bad\"/></list></bts>";
        assertThrows(BtsProtocolException.class, () -> codec.parseSnapshots(xml, Instant.EPOCH));
    }
    @Test
    void rejectsDoctypeAndExternalEntities()
    {
        String xml = "<!DOCTYPE bts [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><bts><cmd>inquire_resp</cmd>&xxe;</bts>";
        BtsProtocolException exception = assertThrows(BtsProtocolException.class,
                () -> codec.parseSnapshots(xml, Instant.EPOCH));
        assertFalse(exception.getMessage().isBlank());
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java
New file
@@ -0,0 +1,97 @@
package com.ruoyi.device.bts;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
final class ScriptedBtsTcpSimulator implements AutoCloseable
{
    private static final byte[] FRAME_END = "#\r\n".getBytes(StandardCharsets.UTF_8);
    private final ServerSocket serverSocket;
    private final ExecutorService executor = Executors.newSingleThreadExecutor();
    private final BlockingQueue<String> responses = new LinkedBlockingQueue<>();
    private final List<String> requests = new CopyOnWriteArrayList<>();
    ScriptedBtsTcpSimulator() throws IOException
    {
        serverSocket = new ServerSocket(0);
        executor.submit(this::serve);
    }
    int port()
    {
        return serverSocket.getLocalPort();
    }
    void enqueue(String response)
    {
        responses.add(response);
    }
    List<String> requests()
    {
        return List.copyOf(requests);
    }
    private void serve()
    {
        try (Socket socket = serverSocket.accept())
        {
            while (!socket.isClosed())
            {
                String request = readFrame(socket.getInputStream());
                requests.add(request);
                String response = responses.poll(5, TimeUnit.SECONDS);
                if (response == null) throw new IOException("No scripted BTS response available");
                socket.getOutputStream().write(response.getBytes(StandardCharsets.UTF_8));
                socket.getOutputStream().write(FRAME_END);
                socket.getOutputStream().flush();
            }
        }
        catch (Exception ignored)
        {
        }
    }
    private String readFrame(InputStream input) throws IOException
    {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        int matched = 0;
        while (true)
        {
            int value = input.read();
            if (value < 0) throw new IOException("Client disconnected");
            if ((byte) value == FRAME_END[matched])
            {
                matched++;
                if (matched == FRAME_END.length) return buffer.toString(StandardCharsets.UTF_8);
                continue;
            }
            if (matched > 0)
            {
                buffer.write(FRAME_END, 0, matched);
                matched = 0;
            }
            buffer.write(value);
        }
    }
    @Override
    public void close() throws Exception
    {
        serverSocket.close();
        executor.shutdownNow();
        executor.awaitTermination(2, TimeUnit.SECONDS);
    }
}
ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConfig.java
@@ -9,6 +9,7 @@
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.spring.boot4.autoconfigure.DruidDataSourceBuilder;
@@ -50,6 +51,7 @@
    }
    @Bean(name = "dynamicDataSource")
    @DependsOn("flywayMigrationInitializer")
    @Primary
    public DynamicDataSource dataSource(DataSource masterDataSource)
    {
scripts/Build-FlywayBaseline.ps1
New file
@@ -0,0 +1,63 @@
param(
    [string]$Source = (Join-Path $PSScriptRoot '..\battery_detection.sql'),
    [string]$Output = (Join-Path $PSScriptRoot '..\ruoyi-admin\src\main\resources\db\migration\V1__ruoyi_platform_baseline.sql')
)
$sourcePath = [System.IO.Path]::GetFullPath($Source)
$outputPath = [System.IO.Path]::GetFullPath($Output)
$sql = [System.IO.File]::ReadAllText($sourcePath)
$tablePattern = '(?ms)^CREATE TABLE `(?<name>[^`]+)`\s+\(.*?^\).*?;\s*$'
$platformTables = [regex]::Matches($sql, $tablePattern) |
    Where-Object {
        $name = $_.Groups['name'].Value
        $name.StartsWith('QRTZ_') -or $name.StartsWith('gen_') -or $name.StartsWith('sys_')
    }
if ($platformTables.Count -eq 0) {
    throw "No platform table definitions found in $sourcePath"
}
$seedRules = @{
    'sys_config' = '^INSERT INTO `sys_config`'
    'sys_dept' = '^INSERT INTO `sys_dept` VALUES \((100|101|103),'
    'sys_dict_data' = '^INSERT INTO `sys_dict_data`'
    'sys_dict_type' = '^INSERT INTO `sys_dict_type`'
    'sys_menu' = '^INSERT INTO `sys_menu`'
    'sys_post' = '^INSERT INTO `sys_post` VALUES \(1,'
    'sys_role' = '^INSERT INTO `sys_role` VALUES \(1,'
    'sys_user' = '^INSERT INTO `sys_user` VALUES \(1,'
    'sys_user_post' = '^INSERT INTO `sys_user_post` VALUES \(1,'
    'sys_user_role' = '^INSERT INTO `sys_user_role` VALUES \(1,'
}
$sourceLines = [System.IO.File]::ReadAllLines($sourcePath)
$seedLines = foreach ($rule in $seedRules.GetEnumerator() | Sort-Object Name) {
    $sourceLines | Where-Object { $_ -match $rule.Value }
}
$parts = [System.Collections.Generic.List[string]]::new()
$parts.Add('-- Generated by scripts/Build-FlywayBaseline.ps1 from the checked-in schema dump.')
$parts.Add('-- Contains platform structure and minimal development seed data only.')
$parts.Add('SET NAMES utf8mb4;')
$parts.Add('SET FOREIGN_KEY_CHECKS = 0;')
$parts.Add('')
foreach ($table in $platformTables) {
    $parts.Add($table.Value.Trim())
    $parts.Add('')
}
$parts.Add('-- Minimal development seed data')
foreach ($line in $seedLines) {
    $parts.Add($line)
}
$parts.Add('')
$parts.Add('SET FOREIGN_KEY_CHECKS = 1;')
$parts.Add('')
$outputDirectory = [System.IO.Path]::GetDirectoryName($outputPath)
[System.IO.Directory]::CreateDirectory($outputDirectory) | Out-Null
[System.IO.File]::WriteAllText($outputPath, ($parts -join "`r`n"), [System.Text.UTF8Encoding]::new($false))
Write-Output $outputPath
sql/backups/pre-flyway-empty-20260825.sql
New file
@@ -0,0 +1,43 @@
-- MySQL dump 10.13  Distrib 5.7.39, for Win64 (x86_64)
--
-- Host: 127.0.0.1    Database: battery_detection
-- ------------------------------------------------------
-- Server version    8.0.36
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Current Database: `battery_detection`
--
CREATE DATABASE /*!32312 IF NOT EXISTS*/ `battery_detection` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `battery_detection`;
--
-- Dumping events for database 'battery_detection'
--
--
-- Dumping routines for database 'battery_detection'
--
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-08-25 18:43:36