| New file |
| | |
| | | /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/ |
| New file |
| | |
| | | # 后端开发规则 |
| | | |
| | | 本文件适用于 `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`,完成后停止临时进程。 |
| | | |
| | |
| | | <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> |
| | |
| | | <version>${ruoyi.version}</version> |
| | | </dependency> |
| | | |
| | | <!-- 设备适配模块 --> |
| | | <dependency> |
| | | <groupId>com.ruoyi</groupId> |
| | | <artifactId>ruoyi-device</artifactId> |
| | | <version>${ruoyi.version}</version> |
| | | </dependency> |
| | | |
| | | <!-- 通用工具--> |
| | | <dependency> |
| | | <groupId>com.ruoyi</groupId> |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | </pluginRepository> |
| | | </pluginRepositories> |
| | | |
| | | </project> |
| | | </project> |
| | |
| | | <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> |
| | |
| | | <dependency> |
| | | <groupId>com.ruoyi</groupId> |
| | | <artifactId>ruoyi-framework</artifactId> |
| | | </dependency> |
| | | |
| | | <!-- 设备适配模块 --> |
| | | <dependency> |
| | | <groupId>com.ruoyi</groupId> |
| | | <artifactId>ruoyi-device</artifactId> |
| | | </dependency> |
| | | |
| | | <!-- 定时任务--> |
| | |
| | | <finalName>${project.artifactId}</finalName> |
| | | </build> |
| | | |
| | | </project> |
| | | </project> |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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 |
| | | # 从库数据源 |
| | |
| | | merge-sql: true |
| | | wall: |
| | | config: |
| | | multi-statement-allow: true |
| | | multi-statement-allow: true |
| | |
| | | |
| | | # 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: |
| | | # 国际化资源文件路径 |
| | |
| | | # 数据库索引 |
| | | database: 0 |
| | | # 密码 |
| | | password: 123456 |
| | | password: 7ff0af25668a40172ca3d8da0f4520fe547a835767218c7b |
| | | # 连接超时时间 |
| | | timeout: 10s |
| | | lettuce: |
| | |
| | | <?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" /> |
| | | |
| | |
| | | <logger name="sys-user" level="info"> |
| | | <appender-ref ref="sys-user"/> |
| | | </logger> |
| | | </configuration> |
| | | </configuration> |
| New file |
| | |
| | | <?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> |
| New file |
| | |
| | | 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() |
| | | { |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.device.bts; |
| | | |
| | | public interface BtsTransport extends AutoCloseable |
| | | { |
| | | String exchange(String request); |
| | | |
| | | @Override |
| | | default void close() |
| | | { |
| | | } |
| | | } |
| New file |
| | |
| | | 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("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"); |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.device.model; |
| | | |
| | | public record DetectedChannel(ChannelIdentity identity, String serverIp, boolean available) |
| | | { |
| | | } |
| New file |
| | |
| | | package com.ruoyi.device.model; |
| | | |
| | | public enum DeviceControlCommand |
| | | { |
| | | START, |
| | | PAUSE, |
| | | CONTINUE, |
| | | STOP |
| | | } |
| New file |
| | |
| | | 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; |
| | | }; |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.device.model; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | | public record Measurement(String code, BigDecimal value, String textValue, String unit, String sourceType) |
| | | { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | @Bean(name = "dynamicDataSource") |
| | | @DependsOn("flywayMigrationInitializer") |
| | | @Primary |
| | | public DynamicDataSource dataSource(DataSource masterDataSource) |
| | | { |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | -- 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 |