项目架构

按照顺序类
java
package org.ircm.boot.plc4x.plc.config;
import org.apache.plc4x.java.api.PlcConnection;
import org.apache.plc4x.java.api.PlcDriverManager;
import java.util.Locale;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
* 基于 Apache PLC4X 的 S7 连接池实现。
* 连接串在 rack/slot 基础上可附带 PDU、并行窗口与 TCP 超时等参数(与 {@link PlcProperties} 对齐),
* 便于驱动按协商 PDU 自动拆分大包并限制未确认请求数量。
*/
public final class PlcConnectionPoolManager implements AutoCloseable {
private final String connectionString;
private final Semaphore permits;
private final ArrayBlockingQueue<PlcConnection> idle;
private final long acquireTimeoutMs;
private volatile boolean closed;
/**
* 创建连接池:内部持有 PLC4X 连接 URL,按许可数限制并发借出连接数。
*
* @param host PLC IP 或主机名
* @param rack S7 rack
* @param slot S7 slot
* @param maxPoolSize 最大并行连接数(至少为 1)
* @param acquireTimeoutMs 等待空闲许可的最长时间(毫秒,至少为 1)
* @param pduOptimize 为 true 时在 URL 中附带 pdu-size、max-amq-caller、max-amq-callee
* @param pduSize 协商 PDU 字节数
* @param maxAmqCaller PLC 侧并行未确认请求上限
* @param maxAmqCallee 本侧并行窗口
* @param tcpDefaultTimeoutMs TCP 默认超时(毫秒)
* @param tcpNoDelay 是否设置 tcp.no-delay
*/
public PlcConnectionPoolManager(String host, int rack, int slot, int maxPoolSize, int acquireTimeoutMs,
boolean pduOptimize, int pduSize, int maxAmqCaller, int maxAmqCallee,
int tcpDefaultTimeoutMs, boolean tcpNoDelay) {
this.connectionString = buildS7Url(host, rack, slot, pduOptimize, pduSize, maxAmqCaller, maxAmqCallee,
tcpDefaultTimeoutMs, tcpNoDelay);
int cap = Math.max(1, maxPoolSize);
this.permits = new Semaphore(cap);
this.idle = new ArrayBlockingQueue<>(cap);
this.acquireTimeoutMs = Math.max(1, acquireTimeoutMs);
}
/**
* 拼装 S7 连接 URL(PLC4X 0.13 约定查询参数)。
*
* @param host PLC 地址
* @param rack rack
* @param slot slot
* @param pduOptimize 是否附带 PDU/AMQ 参数
* @param pduSize pdu-size
* @param maxAmqCaller max-amq-caller
* @param maxAmqCallee max-amq-callee
* @param tcpDefaultTimeoutMs tcp.default-timeout
* @param tcpNoDelay 是否追加 tcp.no-delay=true
* @return 完整连接串
*/
static String buildS7Url(String host, int rack, int slot, boolean pduOptimize,
int pduSize, int maxAmqCaller, int maxAmqCallee,
int tcpDefaultTimeoutMs, boolean tcpNoDelay) {
StringBuilder sb = new StringBuilder();
sb.append(String.format(Locale.US, "s7://%s?rack=%d&slot=%d", host, rack, slot));
if (pduOptimize) {
sb.append(String.format(Locale.US, "&pdu-size=%d&max-amq-caller=%d&max-amq-callee=%d",
pduSize, maxAmqCaller, maxAmqCallee));
}
sb.append(String.format(Locale.US, "&tcp.default-timeout=%d", Math.max(1, tcpDefaultTimeoutMs)));
if (tcpNoDelay) {
sb.append("&tcp.no-delay=true");
}
return sb.toString();
}
/**
* 借出一条可用连接:优先复用空闲队列,否则新建;需与 {@link #returnConnection(PlcConnection)} 配对。
*
* @return 已连接的 {@link PlcConnection}
* @throws IllegalStateException 池已关闭
* @throws java.util.concurrent.TimeoutException 在 acquireTimeoutMs 内未取得许可
* @throws Exception PLC4X 建连失败等
*/
public PlcConnection getConnection() throws Exception {
if (closed) {
throw new IllegalStateException("PLC 连接池已关闭");
}
if (!permits.tryAcquire(acquireTimeoutMs, TimeUnit.MILLISECONDS)) {
throw new java.util.concurrent.TimeoutException("获取 PLC 连接超时");
}
try {
PlcConnection c = idle.poll();
while (c != null && !c.isConnected()) {
closeQuietly(c);
c = idle.poll();
}
if (c != null) {
return c;
}
return PlcDriverManager.getDefault().getConnectionManager().getConnection(connectionString);
} catch (Exception e) {
permits.release();
throw e;
}
}
/**
* 归还连接到池中;断开或池关闭时关闭物理连接。始终在 finally 中调用以释放许可。
*
* @param conn 可为 {@code null}(忽略)
*/
public void returnConnection(PlcConnection conn) {
if (conn == null) {
return;
}
try {
if (closed || !conn.isConnected()) {
closeQuietly(conn);
return;
}
if (!idle.offer(conn)) {
closeQuietly(conn);
}
} finally {
permits.release();
}
}
/**
* 关闭池:禁止再借连接,并关闭空闲队列中的连接。
*/
public void closePool() {
closed = true;
PlcConnection c;
while ((c = idle.poll()) != null) {
closeQuietly(c);
}
}
/**
* 关闭连接并忽略异常,用于清理路径。
*/
private static void closeQuietly(PlcConnection conn) {
try {
conn.close();
} catch (Exception ignored) {
// ignore
}
}
/**
* 等价于 {@link #closePool()}。
*/
@Override
public void close() {
closePool();
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.apache.plc4x.java.api.PlcConnection;
import org.apache.plc4x.java.api.messages.PlcReadResponse;
import org.apache.plc4x.java.api.messages.PlcWriteResponse;
import org.apache.plc4x.java.api.types.PlcResponseCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
/**
* 基于 Spring 生命周期与 PLC4X 的 PLC 读写封装组件。
* 资源隔离(推荐):
* - 任务泳道 {@link PlcProperties#getTask()}:关键路径 BOOL 读写及 BYTE/STRING/字节数组读({@link #readByte}、{@link #readString}、{@link #readByteArray}),
* 独立 TCP 连接池 + 线程池,不经负载节流。
* - AGV 泳道 {@link PlcProperties#getAgv()}:关键路径读(BOOL 与 BYTE/STRING/字节数组:{@link #readByteAgv} 等),
* 再一套连接池 + 线程池,不经节流。
* - 批量泳道 {@link PlcProperties#getBatch()}:{@link #batchRead}、{@link #batchWriteBoolean}、周期轮询,
* 连接池 + 轮询调度;{@link PlcLoadGovernor} 仅作用于该泳道。
* 各泳道可指向同一 PLC IP(不同 TCP 连接实例),避免并行负载挤占关键路径。
* 泳道是否生效由 {@link PlcProperties.TaskLaneProperties#enabled}、{@link PlcProperties.BatchLaneProperties#enabled}、
* {@link PlcProperties.AgvLaneProperties#enabled} 控制(默认均为 {@code false},需在配置中显式开启)。
*/
@Component
public class PlcHelperEnhanced implements SmartLifecycle, DisposableBean, HealthIndicator {
private static final Logger log = LoggerFactory.getLogger(PlcHelperEnhanced.class);
/**
* 来自配置文件的全局 PLC 参数
*/
private final PlcProperties properties;
/**
* 任务泳道连接池缓存(关键路径)
*/
private final Map<String, PlcConnectionPoolManager> taskPoolManagers = new ConcurrentHashMap<>();
/**
* 批量泳道连接池缓存(批量读、批量写、轮询)
*/
private final Map<String, PlcConnectionPoolManager> batchPoolManagers = new ConcurrentHashMap<>();
/**
* AGV 关键路径泳道连接池缓存(BOOL 与扩展类型读)
*/
private final Map<String, PlcConnectionPoolManager> agvPoolManagers = new ConcurrentHashMap<>();
/**
* 任务泳道异步线程池(仅 {@link #readBooleanAsync} / {@link #writeBooleanAsync})
*/
private ExecutorService taskExecutorService;
/**
* 批量泳道异步预留(当前主要为架构一致性;批量 API 多为同步,可在扩展异步批量时使用)
*/
private ExecutorService batchExecutorService;
/**
* AGV 泳道异步线程池(仅 {@link #readBooleanAgvAsync})
*/
private ExecutorService agvExecutorService;
/**
* 生命周期运行标记
*/
private volatile boolean running = false;
// ---------- 运行指标(用于健康详情与 getStats) ----------
/**
* 同步读成功次数
*/
private final AtomicLong totalReadCount = new AtomicLong(0);
/**
* 同步读累计耗时(毫秒),用于计算平均延迟
*/
private final AtomicLong totalReadTimeMs = new AtomicLong(0);
/**
* 同步写成功次数
*/
private final AtomicLong totalWriteCount = new AtomicLong(0);
/**
* 同步写累计耗时(毫秒)
*/
private final AtomicLong totalWriteTimeMs = new AtomicLong(0);
/**
* 读写过程中捕获的失败次数(含同步路径抛出的业务异常)
*/
private final AtomicLong errorCount = new AtomicLong(0);
/**
* AGV 泳道同步读成功次数({@link #readBooleanAgv}、{@link #readByteAgv}、{@link #readStringAgv}、{@link #readByteArrayAgv})
*/
private final AtomicLong totalAgvReadCount = new AtomicLong(0);
/**
* AGV 泳道同步读累计耗时(毫秒)
*/
private final AtomicLong totalAgvReadTimeMs = new AtomicLong(0);
/**
* 批量泳道周期轮询调度器(仅调度 {@link #subscribeBooleanPoll} / {@link #subscribeBatchPoll})
*/
private ScheduledExecutorService batchPollScheduler;
/**
* 已注册的轮询任务,key 为订阅 id,便于 {@link #unsubscribe(String)} 取消
*/
private final ConcurrentHashMap<String, ScheduledFuture<?>> pollSubscriptions = new ConcurrentHashMap<>();
private final AtomicLong pollIdSeq = new AtomicLong(0);
/**
* 仅批量泳道使用:任务路径不经节流,避免抖动影响确定性
*/
private final PlcLoadGovernor batchLoadGovernor;
/**
* 构造助手:注入全局 PLC 配置并创建负载节流器。
*
* @param properties 绑定 {@code plc.*} 的配置项
*/
@Autowired
public PlcHelperEnhanced(PlcProperties properties) {
this.properties = properties;
this.batchLoadGovernor = new PlcLoadGovernor(properties);
}
// ==================== SmartLifecycle:启动 / 停止 ====================
/**
* 容器启动完成后调用:创建异步线程池、注册 JVM shutdown hook。
*/
@Override
public void start() {
if (!running) {
log.info("正在启动 PLC 助手服务...");
if (properties.getCpuSeries() != null && !properties.getCpuSeries().isBlank()) {
log.info("PLC CPU 系列配置标识: {}", properties.getCpuSeries());
}
PlcProperties.TaskLaneProperties taskLane = properties.getTask();
PlcProperties.BatchLaneProperties batchLane = properties.getBatch();
PlcProperties.AgvLaneProperties agvLane = properties.getAgv();
boolean taskOn = taskLane.isEnabled();
boolean batchOn = batchLane.isEnabled();
boolean agvOn = agvLane.isEnabled();
log.info("PLC 泳道开关 --- task: {}, batch: {}, agv: {}", taskOn, batchOn, agvOn);
if (taskOn) {
this.taskExecutorService = new ThreadPoolExecutor(
taskLane.getAsyncCoreSize(),
taskLane.getAsyncMaxSize(),
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(256),
r -> {
Thread t = new Thread(r, "plc-task-io");
t.setDaemon(true);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
if (batchOn) {
this.batchExecutorService = new ThreadPoolExecutor(
batchLane.getAsyncCoreSize(),
batchLane.getAsyncMaxSize(),
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
r -> {
Thread t = new Thread(r, "plc-batch-io");
t.setDaemon(true);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
int pollThreads = properties.effectiveBatchPollThreads();
this.batchPollScheduler = Executors.newScheduledThreadPool(pollThreads, r -> {
Thread t = new Thread(r, "plc-batch-poll");
t.setDaemon(true);
return t;
});
}
if (agvOn) {
this.agvExecutorService = new ThreadPoolExecutor(
agvLane.getAsyncCoreSize(),
agvLane.getAsyncMaxSize(),
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(256),
r -> {
Thread t = new Thread(r, "plc-agv-io");
t.setDaemon(true);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
registerShutdownHook();
running = true;
log.info("PLC 助手服务启动完成");
}
}
/**
* 优雅停止:取消周期任务、关闭轮询调度器与异步线程池,再关闭各 PLC 连接池并清空缓存。
*/
@Override
public void stop() {
if (running) {
log.info("正在停止 PLC 助手服务...");
running = false;
for (ScheduledFuture<?> f : new ArrayList<>(pollSubscriptions.values())) {
f.cancel(false);
}
pollSubscriptions.clear();
if (batchPollScheduler != null) {
batchPollScheduler.shutdown();
try {
if (!batchPollScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
batchPollScheduler.shutdownNow();
}
} catch (InterruptedException e) {
batchPollScheduler.shutdownNow();
Thread.currentThread().interrupt();
}
batchPollScheduler = null;
}
shutdownExecutorQuietly(taskExecutorService, "task");
taskExecutorService = null;
shutdownExecutorQuietly(batchExecutorService, "batch");
batchExecutorService = null;
shutdownExecutorQuietly(agvExecutorService, "agv");
agvExecutorService = null;
closePoolMap(taskPoolManagers, "task");
closePoolMap(batchPoolManagers, "batch");
closePoolMap(agvPoolManagers, "agv");
log.info("PLC 助手服务已停止");
}
}
/**
* 是否已完成 {@link #start()} 且尚未 {@link #stop()}。
*
* @return 运行中为 {@code true}
*/
@Override
public boolean isRunning() {
return running;
}
/**
* 返回 {@link Integer#MAX_VALUE},使本 Bean 在生命周期中较晚停止,尽量先于其它组件释放 PLC 连接。
*
* @return 生命周期相位值
*/
@Override
public int getPhase() {
return Integer.MAX_VALUE;
}
/**
* 注册 JVM shutdown hook:进程收到退出信号时仍会调用 {@link #stop()},尽量归还 PLC 连接。
*/
private void registerShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log.info("JVM 关闭钩子触发,释放 PLC 连接资源");
stop();
}));
}
/**
* 关闭指定线程池:先 {@link ExecutorService#shutdown()},最多等待 10 秒,超时则 {@link ExecutorService#shutdownNow()}。
*
* @param exec 任务或批量泳道的线程池,可为 {@code null}(忽略)
* @param laneName 日志用泳道名称,如 {@code task} / {@code batch} / {@code agv}
*/
private void shutdownExecutorQuietly(ExecutorService exec, String laneName) {
if (exec == null) {
return;
}
exec.shutdown();
try {
if (!exec.awaitTermination(10, TimeUnit.SECONDS)) {
exec.shutdownNow();
}
} catch (InterruptedException e) {
exec.shutdownNow();
Thread.currentThread().interrupt();
}
log.debug("{} 线程池已关闭", laneName);
}
/**
* 遍历并关闭某一泳道下所有缓存的 {@link PlcConnectionPoolManager},清空 Map。
*
* @param pools 任务或批量泳道的连接池缓存
* @param laneName 日志标识
*/
private void closePoolMap(Map<String, PlcConnectionPoolManager> pools, String laneName) {
for (Map.Entry<String, PlcConnectionPoolManager> entry : pools.entrySet()) {
try {
entry.getValue().closePool();
log.info("关闭{}泳道连接池: {}", laneName, entry.getKey());
} catch (Exception e) {
log.error("关闭{}泳道连接池失败: {}", laneName, entry.getKey(), e);
}
}
pools.clear();
}
/**
* Spring 容器销毁时调用,等价于 {@link #stop()},释放线程池与 PLC 连接。
*
* @throws Exception 关闭过程中的异常向上传递
*/
@Override
public void destroy() throws Exception {
stop();
}
// ==================== HealthIndicator(Spring Boot 4) ====================
/**
* Actuator {@code /actuator/health} 使用:未启动则 DOWN;启动则尝试默认连接池借还一次判断连通性。
*/
@Override
public Health health() {
if (!isRunning()) {
return Health.down().withDetail("status", "服务未启动").build();
}
boolean taskEn = properties.getTask().isEnabled();
boolean batchEn = properties.getBatch().isEnabled();
boolean agvEn = properties.getAgv().isEnabled();
if (!taskEn && !batchEn && !agvEn) {
return Health.down().withDetail("status", "未启用任何 PLC 泳道(plc.task/agv/batch.enabled)").build();
}
boolean taskReachable = taskEn && checkTaskLaneHealthy();
boolean batchReachable = batchEn && checkBatchLaneHealthy();
boolean agvReachable = agvEn && checkAgvLaneHealthy();
String taskStatus = !taskEn ? "disabled" : (taskReachable ? "up" : "down");
String batchStatus = !batchEn ? "disabled" : (batchReachable ? "up" : "down");
String agvStatus = !agvEn ? "disabled" : (agvReachable ? "up" : "down");
boolean taskOk = !taskEn || taskReachable;
boolean batchOk = !batchEn || batchReachable;
boolean agvOk = !agvEn || agvReachable;
boolean allOk = taskOk && batchOk && agvOk;
Health.Builder builder = allOk ? Health.up() : Health.down();
builder.withDetail("taskPlc", taskStatus)
.withDetail("batchPlc", batchStatus)
.withDetail("agvPlc", agvStatus)
.withDetail("errorCount", errorCount.get());
if (!allOk) {
builder.withDetail("status", "部分已启用泳道不可用");
}
if (taskEn) {
builder.withDetail("totalReadCount", totalReadCount.get())
.withDetail("totalWriteCount", totalWriteCount.get())
.withDetail("avgReadLatencyMs", getAverageReadLatency())
.withDetail("avgWriteLatencyMs", getAverageWriteLatency());
}
return builder.build();
}
/**
* 任务泳道借还连接探活(关键路径不可用则整体 DOWN)。
*/
private boolean checkTaskLaneHealthy() {
try {
PlcConnectionPoolManager pool = getTaskPoolManager();
var conn = pool.getConnection();
pool.returnConnection(conn);
return true;
} catch (Exception e) {
log.error("任务泳道健康检查失败: {}", e.getMessage());
return false;
}
}
/**
* 批量泳道借还连接探活(不影响任务路径判定)。
*/
private boolean checkBatchLaneHealthy() {
try {
PlcConnectionPoolManager pool = getBatchPoolManager();
var conn = pool.getConnection();
pool.returnConnection(conn);
return true;
} catch (Exception e) {
log.warn("批量泳道健康检查失败: {}", e.getMessage());
return false;
}
}
/**
* AGV 泳道借还连接探活(任务路径 DOWN 时仍会写入详情,便于区分 AGV 专线是否可达)。
*/
private boolean checkAgvLaneHealthy() {
try {
PlcConnectionPoolManager pool = getAgvPoolManager();
var conn = pool.getConnection();
pool.returnConnection(conn);
return true;
} catch (Exception e) {
log.warn("AGV 泳道健康检查失败: {}", e.getMessage());
return false;
}
}
/**
* 校验任务泳道已在配置中启用。
*
* @throws IllegalStateException {@code plc.task.enabled=false}
*/
private void requireTaskLane() {
if (!properties.getTask().isEnabled()) {
throw new IllegalStateException("任务泳道未启用(plc.task.enabled=false)");
}
}
/**
* 校验批量泳道已在配置中启用。
*
* @throws IllegalStateException {@code plc.batch.enabled=false}
*/
private void requireBatchLane() {
if (!properties.getBatch().isEnabled()) {
throw new IllegalStateException("批量泳道未启用(plc.batch.enabled=false)");
}
}
/**
* 校验 AGV 泳道已在配置中启用。
*
* @throws IllegalStateException {@code plc.agv.enabled=false}
*/
private void requireAgvLane() {
if (!properties.getAgv().isEnabled()) {
throw new IllegalStateException("AGV 泳道未启用(plc.agv.enabled=false)");
}
}
// ==================== 连接池(多泳道) ====================
/**
* 按「泳道 + PLC 定位 + PDU/TCP 协商参数」生成缓存键,懒创建对应 {@link PlcConnectionPoolManager}。
* 同一键复用同一池;修改 pdu-size 等参数会得到新键与新池。
*
* @param pools {@link #taskPoolManagers}、{@link #batchPoolManagers} 或 {@link #agvPoolManagers}
* @param laneMoniker 键前缀,如 {@code task} / {@code batch} / {@code agv}
* @param logicalName 逻辑数据源名:{@code default}、{@code lane-direct},或 {@link PlcProperties#dataSources} 中的键
* @param host 目标 PLC IP
* @param rack S7 rack
* @param slot S7 slot
* @param maxConnections 该泳道连接池最大并行连接数
* @param laneAcquireTimeoutMs 借连接等待许可超时(毫秒),与泳道 IO Future.get 超时一致(见 {@link PlcProperties#effectiveTaskTimeoutMs()} 等)
* @return 已缓存或新建的连接池管理器
*/
private PlcConnectionPoolManager computeLanePool(Map<String, PlcConnectionPoolManager> pools,
String laneMoniker,
String logicalName,
String host,
int rack,
int slot,
int maxConnections,
int laneAcquireTimeoutMs) {
String key = String.format("%s_%s_%s_%d_%d_o%d_pdu%d_amq%d_tcp%d_laneTo%d",
laneMoniker, logicalName, host, rack, slot,
properties.isS7PduOptimize() ? 1 : 0,
properties.getS7PduSize(),
properties.getS7MaxAmqCaller(),
properties.getTcpDefaultTimeoutMs(),
laneAcquireTimeoutMs);
return pools.computeIfAbsent(key, k ->
new PlcConnectionPoolManager(host, rack, slot, maxConnections,
laneAcquireTimeoutMs,
properties.isS7PduOptimize(),
properties.getS7PduSize(),
properties.getS7MaxAmqCaller(),
properties.getS7MaxAmqCallee(),
properties.getTcpDefaultTimeoutMs(),
properties.isTcpNoDelay()));
}
/**
* 任务泳道连接池(串行关键路径)。
*/
private PlcConnectionPoolManager getTaskPoolManager() {
requireTaskLane();
PlcProperties.ResolvedPlcEndpoint ep = properties.resolvedTaskEndpoint();
return computeLanePool(taskPoolManagers, "task", ep.logicalKey(),
ep.host(),
ep.rack(),
ep.slot(),
properties.getTask().getMaxConnections(),
ep.timeoutMs());
}
/**
* 批量泳道连接池(批量读、写、轮询)。
*/
private PlcConnectionPoolManager getBatchPoolManager() {
requireBatchLane();
PlcProperties.ResolvedPlcEndpoint ep = properties.resolvedBatchEndpoint();
return computeLanePool(batchPoolManagers, "batch", ep.logicalKey(),
ep.host(),
ep.rack(),
ep.slot(),
properties.getBatch().getMaxConnections(),
ep.timeoutMs());
}
/**
* AGV 关键路径连接池。
*/
private PlcConnectionPoolManager getAgvPoolManager() {
requireAgvLane();
PlcProperties.ResolvedPlcEndpoint ep = properties.resolvedAgvEndpoint();
return computeLanePool(agvPoolManagers, "agv", ep.logicalKey(),
ep.host(),
ep.rack(),
ep.slot(),
properties.getAgv().getMaxConnections(),
ep.timeoutMs());
}
// ==================== 地址转换(简化地址 → PLC4X S7 标签) ====================
/**
* 将业务层简化地址转为 PLC4X S7 语法。
* - {@code DB{n}.{byte}} → {@code %DBn.DBB{byte}:TYPE}(字节级,用于 BYTE、批量 {@link #batchRead} 等)
* - {@code DB{n}.{byte}.{bit}} → {@code %DBn.DBX{byte}.{bit}:TYPE}(位,用于 BOOL)
* - 字符串起始字节见 {@link #convertAddressString(String, int)};连续字节数组见 {@link #parseDbByteOffsetOnly(String)} + 多标签 BYTE。
* @param address 如 DB1.10、DB1.10.0
* @param dataType PLC4X 类型后缀,如 BOOL、BYTE
*/
private String convertAddress(String address, String dataType) {
String[] parts = address.split("\\.");
if (parts.length < 2) {
throw new IllegalArgumentException("无效地址格式: " + address);
}
int dbNumber = Integer.parseInt(parts[0].replace("DB", ""));
if (parts.length == 2) {
int byteOffset = Integer.parseInt(parts[1]);
return String.format("%%DB%d.DBB%d:%s", dbNumber, byteOffset, dataType);
} else if (parts.length == 3) {
int byteOffset = Integer.parseInt(parts[1]);
int bitOffset = Integer.parseInt(parts[2]);
return String.format("%%DB%d.DBX%d.%d:%s", dbNumber, byteOffset, bitOffset, dataType);
}
throw new IllegalArgumentException("不支持的地址格式: " + address);
}
/**
* 解析 {@code DB{n}.{byteOffset}}(字节起始,不含位下标)。
*
* @param address 两段式简化地址
* @return {@code [dbNumber, byteOffset]}
*/
private int[] parseDbByteOffsetOnly(String address) {
String[] parts = address.split("\\.");
if (parts.length != 2) {
throw new IllegalArgumentException("须为两段式 DBn.byteOffset(字节/字符串/数组基址): " + address);
}
int dbNumber = Integer.parseInt(parts[0].replace("DB", ""));
int byteOffset = Integer.parseInt(parts[1]);
return new int[]{dbNumber, byteOffset};
}
/**
* S7 STRING:简化两段式地址转为 {@code %DBn.DBBoff:STRING(maxLen)}(maxLen 常见为 PLC 声明长度,不超过 254)。
*
* @param address 两段式 {@code DBn.offset}
* @param maxLen STRING 最大字符长度(会被限制在 1~254)
* @return PLC4X 标签串
*/
private String convertAddressString(String address, int maxLen) {
int[] p = parseDbByteOffsetOnly(address);
int capped = Math.max(1, Math.min(maxLen, 254));
return String.format("%%DB%d.DBB%d:STRING(%d)", p[0], p[1], capped);
}
/**
* 在指定连接池上读单个 BYTE(不经负载节流)。
*
* @param pool 任务 / AGV / 批量等泳道连接池
* @param address 简化地址(两段式字节偏移)
* @param timeoutMs {@link java.util.concurrent.Future#get(long, TimeUnit)} 超时毫秒数
* @return 读取到的字节值
*/
private byte doReadByteLane(PlcConnectionPoolManager pool, String address, int timeoutMs) throws Exception {
String plcAddress = convertAddress(address, "BYTE");
PlcConnection conn = null;
try {
conn = pool.getConnection();
var request = conn.readRequestBuilder()
.addTagAddress("tag", plcAddress)
.build();
var response = request.execute().get(timeoutMs, TimeUnit.MILLISECONDS);
checkResponse(response, "tag");
return Objects.requireNonNullElse(response.getByte("tag"), (byte) 0);
} catch (TimeoutException e) {
throw e;
} finally {
pool.returnConnection(conn);
}
}
/**
* 在指定连接池上读 S7 STRING(不经负载节流)。
*
* @param pool 连接池
* @param address 两段式起始字节地址
* @param maxStringLength STRING 最大长度(参与类型后缀)
* @param timeoutMs Future.get 超时毫秒数
* @return 读取到的字符串
*/
private String doReadStringLane(PlcConnectionPoolManager pool, String address, int maxStringLength, int timeoutMs) throws Exception {
String plcAddress = convertAddressString(address, maxStringLength);
PlcConnection conn = null;
try {
conn = pool.getConnection();
var request = conn.readRequestBuilder()
.addTagAddress("tag", plcAddress)
.build();
var response = request.execute().get(timeoutMs, TimeUnit.MILLISECONDS);
checkResponse(response, "tag");
return Objects.requireNonNullElse(response.getString("tag"), "");
} catch (TimeoutException e) {
throw e;
} finally {
pool.returnConnection(conn);
}
}
/**
* 从起始字节连续读取多个 BYTE,单次请求多标签(PDU 由 PLC4X 协商拆分)。
*
* @param pool 连接池
* @param address 起始两段式地址
* @param length 连续字节个数,≥ 1
* @param timeoutMs Future.get 超时毫秒数
* @return 读取到的字节数组
*/
private byte[] doReadByteArrayLane(PlcConnectionPoolManager pool, String address, int length, int timeoutMs) throws Exception {
if (length < 1) {
throw new IllegalArgumentException("length 必须 >= 1");
}
int[] base = parseDbByteOffsetOnly(address);
PlcConnection conn = null;
try {
conn = pool.getConnection();
var builder = conn.readRequestBuilder();
for (int i = 0; i < length; i++) {
String tag = "b" + i;
String plcAddr = String.format("%%DB%d.DBB%d:BYTE", base[0], base[1] + i);
builder.addTagAddress(tag, plcAddr);
}
var response = builder.build().execute().get(timeoutMs, TimeUnit.MILLISECONDS);
byte[] out = new byte[length];
for (int i = 0; i < length; i++) {
String tag = "b" + i;
checkResponse(response, tag);
out[i] = Objects.requireNonNullElse(response.getByte(tag), (byte) 0);
}
return out;
} catch (TimeoutException e) {
throw e;
} finally {
pool.returnConnection(conn);
}
}
/**
* 批量泳道读单个 BYTE:在 {@link PlcLoadGovernor} 包裹下调用 {@link #doReadByteLane}。
*
* @param pool 批量泳道连接池
* @param address 简化地址
* @param timeoutMs IO 超时毫秒数
* @return 字节值
*/
private byte runBatchGovernedReadByte(PlcConnectionPoolManager pool, String address, int timeoutMs) throws Exception {
batchLoadGovernor.beforePlcCall();
long t0 = System.nanoTime();
boolean success = false;
boolean timedOut = false;
try {
byte v = doReadByteLane(pool, address, timeoutMs);
success = true;
return v;
} catch (TimeoutException e) {
timedOut = true;
throw e;
} finally {
batchLoadGovernor.afterPlcCall(success, (System.nanoTime() - t0) / 1_000_000L, timedOut);
}
}
/**
* 批量泳道读 STRING:节流包裹的 {@link #doReadStringLane}。
*
* @param pool 批量泳道连接池
* @param address 起始地址
* @param maxStringLength STRING 长度参数
* @param timeoutMs IO 超时毫秒数
* @return 字符串内容
*/
private String runBatchGovernedReadString(PlcConnectionPoolManager pool, String address, int maxStringLength, int timeoutMs) throws Exception {
batchLoadGovernor.beforePlcCall();
long t0 = System.nanoTime();
boolean success = false;
boolean timedOut = false;
try {
String v = doReadStringLane(pool, address, maxStringLength, timeoutMs);
success = true;
return v;
} catch (TimeoutException e) {
timedOut = true;
throw e;
} finally {
batchLoadGovernor.afterPlcCall(success, (System.nanoTime() - t0) / 1_000_000L, timedOut);
}
}
/**
* 批量泳道读连续字节数组:节流包裹的 {@link #doReadByteArrayLane}。
*
* @param pool 批量泳道连接池
* @param address 起始地址
* @param length 字节长度
* @param timeoutMs IO 超时毫秒数
* @return 字节数组
*/
private byte[] runBatchGovernedReadByteArray(PlcConnectionPoolManager pool, String address, int length, int timeoutMs) throws Exception {
batchLoadGovernor.beforePlcCall();
long t0 = System.nanoTime();
boolean success = false;
boolean timedOut = false;
try {
byte[] v = doReadByteArrayLane(pool, address, length, timeoutMs);
success = true;
return v;
} catch (TimeoutException e) {
timedOut = true;
throw e;
} finally {
batchLoadGovernor.afterPlcCall(success, (System.nanoTime() - t0) / 1_000_000L, timedOut);
}
}
// ==================== 同步读写 ====================
/**
* 同步写入单个 BOOL(任务泳道:独立连接池,不经负载节流)。
* 成功时累计写次数与耗时;失败时错误计数加一并抛出 {@link RuntimeException}。
*
* @param address 简化地址,参见 {@link #convertAddress(String, String)}
* @param value 写入值
*/
public void writeBoolean(String address, boolean value) {
long start = System.nanoTime();
try {
doWriteBoolean(address, value);
totalWriteCount.incrementAndGet();
totalWriteTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("写入失败: " + address, e);
}
}
/**
* 同步读取单个 BOOL(任务泳道)。
* 统计口径同 {@link #writeBoolean(String, boolean)}。
*
* @param address 简化地址
* @return 当前布尔值
*/
public boolean readBoolean(String address) {
long start = System.nanoTime();
try {
boolean result = doReadBoolean(address);
totalReadCount.incrementAndGet();
totalReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("读取失败: " + address, e);
}
}
/**
* 任务泳道读单个 BYTE:地址为两段式 {@code DBn.offset},同 {@link #convertAddress(String, String)} 字节级约定。
* 统计口径与 {@link #readBoolean(String)} 相同。
*/
public byte readByte(String address) {
long start = System.nanoTime();
try {
byte result = doReadByteLane(getTaskPoolManager(), address, properties.effectiveTaskTimeoutMs());
totalReadCount.incrementAndGet();
totalReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("读取字节失败: " + address, e);
}
}
/**
* 任务泳道读 S7 STRING:默认按最大长度 254 协商类型 {@code STRING(254)}。
*/
public String readString(String address) {
return readString(address, 254);
}
/**
* 任务泳道读 STRING:{@code maxStringLength} 对应 PLC 声明长度(内部限制 1~254)。
*/
public String readString(String address, int maxStringLength) {
long start = System.nanoTime();
try {
String result = doReadStringLane(getTaskPoolManager(), address, maxStringLength, properties.effectiveTaskTimeoutMs());
totalReadCount.incrementAndGet();
totalReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("读取字符串失败: " + address, e);
}
}
/**
* 任务泳道从 {@code address} 起始连续读取 {@code length} 个字节(每个字节一条 BYTE 标签,单次响应)。
*/
public byte[] readByteArray(String address, int length) {
long start = System.nanoTime();
try {
byte[] result = doReadByteArrayLane(getTaskPoolManager(), address, length, properties.effectiveTaskTimeoutMs());
totalReadCount.incrementAndGet();
totalReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("读取字节数组失败: " + address + ", length=" + length, e);
}
}
/**
* 任务泳道写 BOOL:不经负载节流;标签名 {@code tag}。
*
* @param address 简化地址
* @param value 写入值
* @throws Exception 含 {@link TimeoutException}、PLC 通信异常等
*/
private void doWriteBoolean(String address, boolean value) throws Exception {
PlcConnection conn = null;
try {
conn = getTaskPoolManager().getConnection();
String plcAddress = convertAddress(address, "BOOL");
var request = conn.writeRequestBuilder()
.addTagAddress("tag", plcAddress, value)
.build();
var response = request.execute().get(properties.effectiveTaskTimeoutMs(), TimeUnit.MILLISECONDS);
checkWriteResponse(response, "tag");
} catch (TimeoutException e) {
throw e;
} finally {
getTaskPoolManager().returnConnection(conn);
}
}
/**
* 任务泳道读 BOOL:不经负载节流;标签名 {@code tag}。
*
* @param address 简化地址
* @return 读取到的布尔值
* @throws Exception 含 {@link TimeoutException}、PLC 通信异常等
*/
private boolean doReadBoolean(String address) throws Exception {
PlcConnection conn = null;
try {
conn = getTaskPoolManager().getConnection();
String plcAddress = convertAddress(address, "BOOL");
var request = conn.readRequestBuilder()
.addTagAddress("tag", plcAddress)
.build();
var response = request.execute().get(properties.effectiveTaskTimeoutMs(), TimeUnit.MILLISECONDS);
checkResponse(response, "tag");
return response.getBoolean("tag");
} catch (TimeoutException e) {
throw e;
} finally {
getTaskPoolManager().returnConnection(conn);
}
}
/**
* AGV 泳道读 BOOL:不经负载节流;使用 {@link #getAgvPoolManager()} 与 {@link PlcProperties#effectiveAgvTimeoutMs()}。
*
* @param address 简化 BOOL 地址(三段式含位下标)
* @return 读取到的布尔值
* @throws Exception 超时或通信异常
*/
private boolean doReadBooleanAgv(String address) throws Exception {
PlcConnection conn = null;
try {
conn = getAgvPoolManager().getConnection();
String plcAddress = convertAddress(address, "BOOL");
var request = conn.readRequestBuilder()
.addTagAddress("tag", plcAddress)
.build();
var response = request.execute().get(properties.effectiveAgvTimeoutMs(), TimeUnit.MILLISECONDS);
checkResponse(response, "tag");
return response.getBoolean("tag");
} catch (TimeoutException e) {
throw e;
} finally {
getAgvPoolManager().returnConnection(conn);
}
}
/**
* 同步读取单个 BOOL(AGV 关键路径泳道):独立连接池与统计,不经负载节流。
* 用于车载导航、避让等与通用任务路径隔离的读点。
*
* @param address 简化地址,参见 {@link #convertAddress(String, String)}
* @return 当前布尔值
*/
public boolean readBooleanAgv(String address) {
long start = System.nanoTime();
try {
boolean result = doReadBooleanAgv(address);
totalAgvReadCount.incrementAndGet();
totalAgvReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("AGV 读取失败: " + address, e);
}
}
/**
* AGV 泳道读 BYTE(关键路径,不经节流);统计口径同 {@link #readBooleanAgv(String)}。
*/
public byte readByteAgv(String address) {
long start = System.nanoTime();
try {
byte result = doReadByteLane(getAgvPoolManager(), address, properties.effectiveAgvTimeoutMs());
totalAgvReadCount.incrementAndGet();
totalAgvReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("AGV 读取字节失败: " + address, e);
}
}
/**
* AGV 泳道读 STRING,默认按 {@code STRING(254)}。
*
* @param address 两段式起始字节地址
* @return 读取到的字符串
*/
public String readStringAgv(String address) {
return readStringAgv(address, 254);
}
/**
* AGV 泳道读 STRING,指定最大长度。
*
* @param address 两段式起始字节地址
* @param maxStringLength PLC 声明的 STRING 最大长度(内部限制 1~254)
* @return 读取到的字符串
*/
public String readStringAgv(String address, int maxStringLength) {
long start = System.nanoTime();
try {
String result = doReadStringLane(getAgvPoolManager(), address, maxStringLength, properties.effectiveAgvTimeoutMs());
totalAgvReadCount.incrementAndGet();
totalAgvReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("AGV 读取字符串失败: " + address, e);
}
}
/**
* AGV 泳道从起始地址连续读取指定长度字节(不经负载节流)。
*
* @param address 两段式起始地址
* @param length 字节个数,≥ 1
* @return 字节数组
*/
public byte[] readByteArrayAgv(String address, int length) {
long start = System.nanoTime();
try {
byte[] result = doReadByteArrayLane(getAgvPoolManager(), address, length, properties.effectiveAgvTimeoutMs());
totalAgvReadCount.incrementAndGet();
totalAgvReadTimeMs.addAndGet((System.nanoTime() - start) / 1_000_000);
return result;
} catch (Exception e) {
errorCount.incrementAndGet();
throw new RuntimeException("AGV 读取字节数组失败: " + address + ", length=" + length, e);
}
}
/**
* 批量泳道读单个 BOOL(用于周期轮询):走批量连接池与负载节流,不计入任务泳道读统计。
*
* @param address 三段式 BOOL 地址
* @return 读取到的布尔值
* @throws Exception 超时或通信失败
*/
private boolean doBatchLaneReadBoolean(String address) throws Exception {
batchLoadGovernor.beforePlcCall();
long t0 = System.nanoTime();
boolean success = false;
boolean timedOut = false;
PlcConnection conn = null;
try {
conn = getBatchPoolManager().getConnection();
try {
String plcAddress = convertAddress(address, "BOOL");
var request = conn.readRequestBuilder()
.addTagAddress("tag", plcAddress)
.build();
var response = request.execute().get(properties.effectiveBatchTimeoutMs(), TimeUnit.MILLISECONDS);
checkResponse(response, "tag");
success = true;
return response.getBoolean("tag");
} finally {
getBatchPoolManager().returnConnection(conn);
}
} catch (TimeoutException e) {
timedOut = true;
throw e;
} finally {
batchLoadGovernor.afterPlcCall(success, (System.nanoTime() - t0) / 1_000_000L, timedOut);
}
}
// ==================== 异步读写(线程池 + CompletableFuture) ====================
/**
* 在线程池中异步写 BOOL;底层仍走 {@link #doWriteBoolean(String, boolean)}。
* 异常会以 {@link CompletionException} 出现在 Future 中。
*
* @param address 简化地址
* @param value 写入值
* @param success 成功回调,可为 {@code null};成功时参数为 {@code null}
* @param failure 失败回调,可为 {@code null}
* @return 异步任务,完成表示写请求结束(未必协议成功,需看异常)
*/
public CompletableFuture<Void> writeBooleanAsync(String address, boolean value,
Consumer<Void> success,
Consumer<Throwable> failure) {
requireTaskLane();
return CompletableFuture.runAsync(() -> {
try {
doWriteBoolean(address, value);
if (success != null) {
success.accept(null);
}
} catch (Exception e) {
log.error("异步写入失败: {}", e.getMessage());
errorCount.incrementAndGet();
if (failure != null) {
failure.accept(e);
}
throw new CompletionException(e);
}
}, taskExecutorService);
}
/**
* 在线程池中异步读 BOOL;底层走 {@link #doReadBoolean(String)}。
*
* @param address 简化地址
* @param success 成功回调,可为 {@code null}
* @param failure 失败回调,可为 {@code null}
* @return Future 在完成时携带读取值或失败
*/
public CompletableFuture<Boolean> readBooleanAsync(String address,
Consumer<Boolean> success,
Consumer<Throwable> failure) {
requireTaskLane();
return CompletableFuture.supplyAsync(() -> {
try {
boolean result = doReadBoolean(address);
if (success != null) {
success.accept(result);
}
return result;
} catch (Exception e) {
log.error("异步读取失败: {}", e.getMessage());
errorCount.incrementAndGet();
if (failure != null) {
failure.accept(e);
}
throw new CompletionException(e);
}
}, taskExecutorService);
}
/**
* 在线程池中异步读 BOOL(AGV 泳道);底层 {@link #doReadBooleanAgv(String)}。
* 与任务泳道 {@link #readBooleanAsync} 一致:成功次数/延迟不计入 {@link #getStats()} 中的 AGV 同步读指标。
*
* @param address 简化地址
* @param success 成功回调,可为 {@code null}
* @param failure 失败回调,可为 {@code null}
* @return Future 在完成时携带读取值或失败
*/
public CompletableFuture<Boolean> readBooleanAgvAsync(String address,
Consumer<Boolean> success,
Consumer<Throwable> failure) {
requireAgvLane();
return CompletableFuture.supplyAsync(() -> {
try {
boolean result = doReadBooleanAgv(address);
if (success != null) {
success.accept(result);
}
return result;
} catch (Exception e) {
log.error("AGV 异步读取失败: {}", e.getMessage());
errorCount.incrementAndGet();
if (failure != null) {
failure.accept(e);
}
throw new CompletionException(e);
}
}, agvExecutorService);
}
// ==================== 批量读 ====================
/**
* 单次请求批量读(批量泳道:独立连接池,受负载节流影响)。
* 地址统一按 BYTE 解析;大包由 PLC4X 按 {@code pdu-size} 自动拆分。
*
* @param addresses key 为业务别名,value 为简化地址(如 DB1.0)
* @return 别名到读取值的映射(类型由驱动决定)
* @throws Exception 超时或通信失败
*/
public Map<String, Object> batchRead(Map<String, String> addresses) throws Exception {
batchLoadGovernor.beforePlcCall();
long t0 = System.nanoTime();
boolean success = false;
boolean timedOut = false;
PlcConnection conn = null;
try {
conn = getBatchPoolManager().getConnection();
try {
var builder = conn.readRequestBuilder();
for (Map.Entry<String, String> entry : addresses.entrySet()) {
String plcAddr = convertAddress(entry.getValue(), "BYTE");
builder.addTagAddress(entry.getKey(), plcAddr);
}
var request = builder.build();
var response = request.execute().get(properties.effectiveBatchTimeoutMs(), TimeUnit.MILLISECONDS);
Map<String, Object> result = new ConcurrentHashMap<>();
for (String alias : addresses.keySet()) {
checkResponse(response, alias);
result.put(alias, response.getObject(alias));
}
success = true;
return result;
} finally {
getBatchPoolManager().returnConnection(conn);
}
} catch (TimeoutException e) {
timedOut = true;
throw e;
} finally {
batchLoadGovernor.afterPlcCall(success, (System.nanoTime() - t0) / 1_000_000L, timedOut);
}
}
/**
* 单次请求批量写多个 BOOL:一条连接、一次请求。
* {@code aliasToAddress} 与 {@code aliasToValue} 的 key 集合必须完全一致。
*
* @param aliasToAddress 别名 → 简化地址
* @param aliasToValue 同名别名 → 写入布尔值
* @throws IllegalArgumentException key 不一致或非法参数
* @throws Exception 超时或通信失败
*/
public void batchWriteBoolean(Map<String, String> aliasToAddress, Map<String, Boolean> aliasToValue) throws Exception {
if (!aliasToAddress.keySet().equals(aliasToValue.keySet())) {
throw new IllegalArgumentException("aliasToAddress 与 aliasToValue 的 key 必须一致");
}
if (aliasToAddress.isEmpty()) {
return;
}
batchLoadGovernor.beforePlcCall();
long t0 = System.nanoTime();
boolean success = false;
boolean timedOut = false;
PlcConnection conn = null;
try {
conn = getBatchPoolManager().getConnection();
try {
var wb = conn.writeRequestBuilder();
for (String alias : aliasToAddress.keySet()) {
String plcAddr = convertAddress(aliasToAddress.get(alias), "BOOL");
wb.addTagAddress(alias, plcAddr, aliasToValue.get(alias));
}
var response = wb.build().execute().get(properties.effectiveBatchTimeoutMs(), TimeUnit.MILLISECONDS);
for (String alias : aliasToAddress.keySet()) {
checkWriteResponse(response, alias);
}
success = true;
} finally {
getBatchPoolManager().returnConnection(conn);
}
} catch (TimeoutException e) {
timedOut = true;
throw e;
} finally {
batchLoadGovernor.afterPlcCall(success, (System.nanoTime() - t0) / 1_000_000L, timedOut);
}
}
/**
* 批量泳道读单个 BYTE(非关键路径):走批量连接池并受 {@link PlcLoadGovernor} 节流。
*/
public byte readByteBatch(String address) throws Exception {
return runBatchGovernedReadByte(getBatchPoolManager(), address, properties.effectiveBatchTimeoutMs());
}
/**
* 批量泳道读 STRING,默认 {@code STRING(254)}。
*/
public String readStringBatch(String address) throws Exception {
return readStringBatch(address, 254);
}
/**
* 批量泳道读 STRING。
*/
public String readStringBatch(String address, int maxStringLength) throws Exception {
return runBatchGovernedReadString(getBatchPoolManager(), address, maxStringLength, properties.effectiveBatchTimeoutMs());
}
/**
* 批量泳道连续读字节数组。
*/
public byte[] readByteArrayBatch(String address, int length) throws Exception {
return runBatchGovernedReadByteArray(getBatchPoolManager(), address, length, properties.effectiveBatchTimeoutMs());
}
// ==================== 周期轮询(fixed delay:上一轮结束后间隔固定毫秒再拉) ====================
/**
* 按配置默认周期 {@link PlcProperties#getPollPeriodMs()} 批量轮询,每次成功后回调整表数据。
*
* @param addresses 同 {@link #batchRead(Map)}
* @param onData 每次成功拉取后的回调,不可为 {@code null}
* @param onError 单次轮询失败回调,可为 {@code null}
* @return 订阅 id,用于 {@link #unsubscribe(String)}
*/
public String subscribeBatchPoll(Map<String, String> addresses,
Consumer<Map<String, Object>> onData,
Consumer<Throwable> onError) {
return subscribeBatchPoll(properties.getPollPeriodMs(), addresses, onData, onError);
}
/**
* 固定间隔批量轮询:内部调用 {@link #batchRead(Map)}。
* 使用 {@link ScheduledExecutorService#scheduleWithFixedDelay},上一轮未完成时不会启动下一轮。
*
* @param periodMs 间隔毫秒,≥ 1
* @param addresses 同 batchRead:key 为别名,value 为简化地址
* @param onData 必填;每次成功拉取后调用
* @param onError 可选;单次轮询异常时调用
* @return 订阅 id,可用于 {@link #unsubscribe(String)}
*/
public String subscribeBatchPoll(long periodMs, Map<String, String> addresses,
Consumer<Map<String, Object>> onData,
Consumer<Throwable> onError) {
ensurePollingReady(addresses, onData);
if (periodMs < 1) {
throw new IllegalArgumentException("periodMs 必须 >= 1");
}
String id = nextPollId();
Runnable task = () -> {
if (!running) {
return;
}
try {
Map<String, Object> data = batchRead(addresses);
onData.accept(data);
} catch (Exception e) {
errorCount.incrementAndGet();
notifyPollError(onError, e);
}
};
registerScheduledPoll(id, periodMs, task);
return id;
}
/**
* 按默认周期轮询单个 BOOL,周期取自 {@link PlcProperties#getPollPeriodMs()}。
*
* @param address 简化地址
* @param onData 每次读成功后回调当前值,不可为 {@code null}
* @param onError 单次失败回调,可为 {@code null}
* @return 订阅 id
*/
public String subscribeBooleanPoll(String address,
Consumer<Boolean> onData,
Consumer<Throwable> onError) {
return subscribeBooleanPoll(properties.getPollPeriodMs(), address, onData, onError);
}
/**
* 固定间隔(上一轮结束后延迟)轮询单个 BOOL,走批量泳道 {@link #doBatchLaneReadBoolean(String)},不占用任务连接池。
*
* @param periodMs 间隔毫秒,≥ 1
* @param address 简化地址
* @param onData 成功回调,不可为 {@code null}
* @param onError 失败回调,可为 {@code null}
* @return 订阅 id
* @throws IllegalStateException 助手未启动
* @throws IllegalArgumentException 参数非法
*/
public String subscribeBooleanPoll(long periodMs, String address,
Consumer<Boolean> onData,
Consumer<Throwable> onError) {
requireBatchLane();
if (!running || batchPollScheduler == null) {
throw new IllegalStateException("PLC 助手未启动或批量泳道未就绪,无法注册周期轮询");
}
if (address == null || address.isBlank()) {
throw new IllegalArgumentException("address 不能为空");
}
if (onData == null) {
throw new IllegalArgumentException("onData 回调不能为空");
}
if (periodMs < 1) {
throw new IllegalArgumentException("periodMs 必须 >= 1");
}
String id = nextPollId();
Runnable task = () -> {
if (!running) {
return;
}
try {
boolean v = doBatchLaneReadBoolean(address);
onData.accept(v);
} catch (Exception e) {
errorCount.incrementAndGet();
notifyPollError(onError, e);
}
};
registerScheduledPoll(id, periodMs, task);
return id;
}
/**
* 生成唯一轮询订阅 id。
*
* @return 形如 {@code plc-poll-1} 的字符串
*/
private String nextPollId() {
return "plc-poll-" + pollIdSeq.incrementAndGet();
}
/**
* 校验批量轮询前置条件:已启动、地址非空、回调非空。
*/
private void ensurePollingReady(Map<String, String> addresses, Consumer<Map<String, Object>> onData) {
requireBatchLane();
if (!running || batchPollScheduler == null) {
throw new IllegalStateException("PLC 助手未启动或批量泳道未就绪,无法注册周期轮询");
}
if (addresses == null || addresses.isEmpty()) {
throw new IllegalArgumentException("轮询地址不能为空");
}
if (onData == null) {
throw new IllegalArgumentException("onData 回调不能为空");
}
}
/**
* 将轮询异常交给业务回调或记录日志,吞掉回调内的二次异常。
*
* @param onError 可为 {@code null}
* @param e 本轮异常
*/
private void notifyPollError(Consumer<Throwable> onError, Exception e) {
if (onError != null) {
try {
onError.accept(e);
} catch (Exception ex) {
log.warn("轮询 onError 回调异常: {}", ex.getMessage());
}
} else {
log.warn("周期轮询失败: {}", e.getMessage());
}
}
/**
* 注册 fixed-delay 周期任务:首次立即执行,之后每轮结束间隔 {@code periodMs} 再执行。
*
* @param id 订阅 id
* @param periodMs 周期间隔毫秒
* @param task 拉取逻辑
*/
private void registerScheduledPoll(String id, long periodMs, Runnable task) {
ScheduledFuture<?> future = batchPollScheduler.scheduleWithFixedDelay(task, 0, periodMs, TimeUnit.MILLISECONDS);
pollSubscriptions.put(id, future);
}
/**
* 根据订阅 id 取消对应的 fixed-delay 轮询任务。
*
* @param subscriptionId {@link #subscribeBatchPoll} / {@link #subscribeBooleanPoll} 返回值
* @return 若存在该订阅并成功取消则为 {@code true}
*/
public boolean unsubscribe(String subscriptionId) {
ScheduledFuture<?> f = pollSubscriptions.remove(subscriptionId);
if (f != null) {
f.cancel(false);
return true;
}
return false;
}
/**
* 取消当前所有周期轮询订阅(逐个 {@link #unsubscribe(String)})。
*/
public void unsubscribeAll() {
for (String id : new ArrayList<>(pollSubscriptions.keySet())) {
unsubscribe(id);
}
}
// ==================== 响应校验与统计 ====================
/**
* 校验读响应中指定标签是否为 {@link PlcResponseCode#OK}。
*
* @param response PLC4X 读响应
* @param tagName 标签名
* @throws RuntimeException 响应码非 OK
*/
private void checkResponse(PlcReadResponse response, String tagName) {
var code = response.getResponseCode(tagName);
if (code != PlcResponseCode.OK) {
throw new RuntimeException("读取失败: " + tagName + ", code=" + code);
}
}
/**
* 校验写响应中指定标签是否为 {@link PlcResponseCode#OK}。
*
* @param response PLC4X 写响应
* @param tagName 标签名
* @throws RuntimeException 响应码非 OK
*/
private void checkWriteResponse(PlcWriteResponse response, String tagName) {
var code = response.getResponseCode(tagName);
if (code != PlcResponseCode.OK) {
throw new RuntimeException("写入失败: " + tagName + ", code=" + code);
}
}
/**
* 同步读成功样本的平均耗时(毫秒);尚未成功读时返回 0。
*
* @return 平均读延迟
*/
public double getAverageReadLatency() {
long count = totalReadCount.get();
return count == 0 ? 0 : (double) totalReadTimeMs.get() / count;
}
/**
* 同步写成功样本的平均耗时(毫秒);尚未成功写时返回 0。
*
* @return 平均写延迟
*/
public double getAverageWriteLatency() {
long count = totalWriteCount.get();
return count == 0 ? 0 : (double) totalWriteTimeMs.get() / count;
}
/**
* AGV 泳道同步读({@link #readBooleanAgv})成功样本的平均耗时(毫秒);尚无成功样本时返回 0。
*/
public double getAverageAgvReadLatency() {
long count = totalAgvReadCount.get();
return count == 0 ? 0 : (double) totalAgvReadTimeMs.get() / count;
}
/**
* 导出运行时指标快照:读写计数与延迟、错误数、轮询订阅数、负载节流档位、超时与 PDU 配置等。
*
* @return 只读用途的统计 Map
*/
public Map<String, Object> getStats() {
Map<String, Object> stats = new HashMap<>();
stats.put("taskLaneEnabled", properties.getTask().isEnabled());
stats.put("batchLaneEnabled", properties.getBatch().isEnabled());
stats.put("agvLaneEnabled", properties.getAgv().isEnabled());
stats.put("totalReadCount", totalReadCount.get());
stats.put("totalWriteCount", totalWriteCount.get());
stats.put("avgReadLatencyMs", getAverageReadLatency());
stats.put("avgWriteLatencyMs", getAverageWriteLatency());
stats.put("errorCount", errorCount.get());
stats.put("isRunning", running);
stats.put("pollSubscriptionCount", pollSubscriptions.size());
stats.put("loadGovernorThrottleMs", batchLoadGovernor.getThrottleSleepMs());
stats.put("taskLaneHost", properties.effectiveTaskHost());
stats.put("batchLaneHost", properties.effectiveBatchHost());
stats.put("agvLaneHost", properties.effectiveAgvHost());
stats.put("taskLaneEndpointKey", properties.resolvedTaskEndpoint().logicalKey());
stats.put("batchLaneEndpointKey", properties.resolvedBatchEndpoint().logicalKey());
stats.put("agvLaneEndpointKey", properties.resolvedAgvEndpoint().logicalKey());
stats.put("taskLaneTimeoutMs", properties.effectiveTaskTimeoutMs());
stats.put("batchLaneTimeoutMs", properties.effectiveBatchTimeoutMs());
stats.put("agvLaneTimeoutMs", properties.effectiveAgvTimeoutMs());
stats.put("totalAgvReadCount", totalAgvReadCount.get());
stats.put("avgAgvReadLatencyMs", getAverageAgvReadLatency());
stats.put("s7PduOptimize", properties.isS7PduOptimize());
stats.put("timeoutMs", properties.getTimeoutMs());
return stats;
}
}
java
package org.ircm.boot.plc4x.plc.config;
import java.util.concurrent.atomic.AtomicInteger;
/**
* PLC 负载自适应节流(仅用于批量泳道):根据单次调用耗时与超时情况动态调整调用前休眠。
* 任务泳道与 AGV 关键路径为确定性不做节流,故本类仅由批量读写/批量泳道轮询路径调用。
*/
public final class PlcLoadGovernor {
private final PlcProperties properties;
private final AtomicInteger throttleSleepMs = new AtomicInteger(0);
/**
* 构造负载节流器:读取 {@link PlcProperties} 中的启用开关与各阈值。
*
* @param properties 负载治理相关开关与阈值({@code plc.load-governor-*} 等)
*/
public PlcLoadGovernor(PlcProperties properties) {
this.properties = properties;
}
/**
* 在实际发起 PLC 请求前调用:若当前节流值大于 0 则睡眠,避免瞬间高压。
*/
public void beforePlcCall() {
if (!properties.isLoadGovernorEnabled()) {
return;
}
int ms = throttleSleepMs.get();
if (ms <= 0) {
return;
}
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* 单次 PLC 交互结束后调用,用于更新节流档位。
*
* @param success 本次是否在协议层面完成(读到/写到且无超时)
* @param latencyMs 往返耗时(毫秒)
* @param timedOut 是否发生 Future 超时
*/
public void afterPlcCall(boolean success, long latencyMs, boolean timedOut) {
if (!properties.isLoadGovernorEnabled()) {
return;
}
if (timedOut || !success) {
bump(properties.getGovernorThrottleStepMs());
} else if (latencyMs >= properties.getGovernorLatencyHighWatermarkMs()) {
bump(Math.max(1, properties.getGovernorThrottleStepMs() / 2));
} else {
decay(properties.getGovernorDecayStepMs());
}
}
/**
* 增大节流休眠时间,不超过配置上限。
*/
private void bump(int step) {
int max = properties.getGovernorMaxThrottleMs();
throttleSleepMs.updateAndGet(cur -> Math.min(max, cur + step));
}
/**
* 减小节流休眠时间,不低于 0。
*/
private void decay(int step) {
throttleSleepMs.updateAndGet(cur -> Math.max(0, cur - step));
}
/**
* 当前建议在每次 PLC 调用前额外休眠的毫秒数(可由监控或 {@link PlcHelperEnhanced#getStats()} 透出)。
*
* @return 节流毫秒数
*/
public int getThrottleSleepMs() {
return throttleSleepMs.get();
}
}
java
package org.ircm.boot.plc4x.plc.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* PLC 连接相关配置,绑定前缀 {@code plc}。
* 采用「任务 task」「AGV 关键路径 agv」「批量/轮询 batch」多泳道:各自连接池隔离;task/agv 不经负载节流。
* 顶层 {@link #host}/{@link #rack}/{@link #slot} 在泳道未单独指定 host、或未启用 {@link #dataSourcesEnabled} 时作为默认 PLC 定位;
* 命名数据源见 {@link #dataSources}、{@link #dataSourcesEnabled} 及 {@link #ResolvedPlcEndpoint}。
*/
@Data
@Component
@ConfigurationProperties(prefix = "plc")
public class PlcProperties {
/**
* 默认 PLC IP(任务/批量泳道均可回退到此)
*/
private String host = "192.168.1.100";
/** S7 rack,与 {@link #slot} 组成 CPU 机架定位 */
private int rack = 0;
/** S7 slot(CPU 槽位) */
private int slot = 1;
/**
* 可选:CPU 系列标识(如 {@code s7-1200}、{@code s7-1500}),由 {@code application-s7-*.yml} 等 Profile 写入,
* 便于日志与运维区分;不参与 PLC4X 协议计算,实际仍以 rack/slot、pdu-size 等数值为准。
*/
private String cpuSeries;
/**
* 单次读/写 Future 超时(毫秒)
*/
private int timeoutMs = 1000;
/**
* 任务泳道:关键路径串行 IO,默认独立连接池与小线程池。
*/
private TaskLaneProperties task = new TaskLaneProperties();
/**
* 批量泳道:批量读写、周期轮询,默认独立连接池与较大线程池及轮询调度线程。
*/
private BatchLaneProperties batch = new BatchLaneProperties();
/**
* AGV 关键路径泳道:BOOL 与扩展类型读,独立 TCP 连接池与线程池,不经负载节流。
*/
private AgvLaneProperties agv = new AgvLaneProperties();
/** @deprecated 请使用 {@link TaskLaneProperties#setAsyncCoreSize(int)} / {@link BatchLaneProperties} */
private int asyncCoreSize = 2;
/** @deprecated 请使用泳道内 async-max-size */
private int asyncMaxSize = 4;
/** @deprecated 请使用 {@link TaskLaneProperties#setMaxConnections(int)} / {@link BatchLaneProperties#setMaxConnections(int)} */
private int maxConnections = 4;
/** 是否在连接串中附带 pdu-size、max-amq-*,便于驱动协商 PDU 与并行窗口 */
private boolean s7PduOptimize = true;
/** 协商的最大 PDU 字节数 */
private int s7PduSize = 1024;
/** PLC 侧允许的并行未确认请求数量上限 */
private int s7MaxAmqCaller = 4;
/** 本侧并行窗口(PLC4X S7 驱动参数) */
private int s7MaxAmqCallee = 8;
/** TCP 通道默认超时毫秒数,写入连接 URL */
private int tcpDefaultTimeoutMs = 1000;
/** 是否启用 TCP_NODELAY(小包低延迟) */
private boolean tcpNoDelay = true;
/**
* 仅作用于批量泳道:任务泳道为确定性不做节流睡眠。
*/
private boolean loadGovernorEnabled = true;
/** 单次往返耗时超过该值(毫秒)视为偏高负载,小幅增加节流 */
private int governorLatencyHighWatermarkMs = 400;
/** 失败或超时后单次增加的节流步长(毫秒) */
private int governorThrottleStepMs = 50;
/** 节流休眠上限(毫秒) */
private int governorMaxThrottleMs = 2000;
/** 成功且延迟不高时每次递减节流的步长(毫秒) */
private int governorDecayStepMs = 25;
/** 周期轮询默认间隔(毫秒),{@code subscribe*} 未显式传周期时使用 */
private int pollPeriodMs = 1000;
/** @deprecated 使用 {@link BatchLaneProperties#getPollThreads()} */
private int pollThreads = 1;
/** 重试策略占位配置,供后续接入 RetryTemplate */
private RetryProperties retry = new RetryProperties();
/**
* 是否启用 {@link #dataSources} 与泳道 {@code data-source} 解析。
* 为 {@code false}(默认)时忽略各泳道的 {@code data-source},仅按泳道 host 或顶层 {@link #host} 连接;
* {@code plc.data-sources} 仍可写在 yml 中作文档或占位,不参与运行时解析。
*/
private boolean dataSourcesEnabled = false;
/** 命名 PLC 数据源映射(多 IP / 主备 PLC:task/agv/batch 可通过 {@code data-source} 引用,需 {@link #dataSourcesEnabled} 为 true) */
private Map<String, PlcDataSourceProperties> dataSources = new HashMap<>();
/**
* 某一泳道解析后的连接目标:逻辑名(连接池缓存键片段)、host/rack/slot、以及 Future.get / 池许可等待超时。
*
* @param logicalKey {@code lane-direct}(泳道显式 host)、{@code default}(回落顶层)、或 {@link PlcDataSourceProperties} 在 map 中的键名
*/
public record ResolvedPlcEndpoint(String logicalKey, String host, int rack, int slot, int timeoutMs) {}
// ---------- 泳道解析(优先泳道 host;启用数据源时其次命名数据源;否则顶层 host/rack/slot) ----------
/**
* 任务泳道解析结果:{@link TaskLaneProperties#getHost()} 非空白优先;
* 否则若 {@link #dataSourcesEnabled} 且配置了 {@link TaskLaneProperties#getDataSource()} 则从 {@link #dataSources} 取定位;
* 否则回落 {@link #host}。
*/
public ResolvedPlcEndpoint resolvedTaskEndpoint() {
TaskLaneProperties t = task;
return resolveLane(trimToNull(t.getHost()), effectiveDataSourceName(trimToNull(t.getDataSource())), t.getRack(), t.getSlot());
}
/**
* 批量泳道解析结果,语义同 {@link #resolvedTaskEndpoint()}。
*/
public ResolvedPlcEndpoint resolvedBatchEndpoint() {
BatchLaneProperties b = batch;
return resolveLane(trimToNull(b.getHost()), effectiveDataSourceName(trimToNull(b.getDataSource())), b.getRack(), b.getSlot());
}
/**
* AGV 泳道解析结果,语义同 {@link #resolvedTaskEndpoint()}。
*/
public ResolvedPlcEndpoint resolvedAgvEndpoint() {
AgvLaneProperties a = agv;
return resolveLane(trimToNull(a.getHost()), effectiveDataSourceName(trimToNull(a.getDataSource())), a.getRack(), a.getSlot());
}
/**
* 任务泳道实际连接的 PLC 主机:见 {@link #resolvedTaskEndpoint()}。
*/
public String effectiveTaskHost() {
return resolvedTaskEndpoint().host();
}
/**
* 任务泳道 rack:见 {@link #resolvedTaskEndpoint()}。
*/
public int effectiveTaskRack() {
return resolvedTaskEndpoint().rack();
}
/**
* 任务泳道 slot:见 {@link #resolvedTaskEndpoint()}。
*/
public int effectiveTaskSlot() {
return resolvedTaskEndpoint().slot();
}
/**
* 任务泳道 IO 超时(毫秒):显式 host 时用顶层 {@link #timeoutMs};命名数据源时若数据源 timeout-ms 大于 0 则用之,否则回落顶层。
*/
public int effectiveTaskTimeoutMs() {
return resolvedTaskEndpoint().timeoutMs();
}
/**
* 批量泳道实际连接的 PLC 主机:见 {@link #resolvedBatchEndpoint()}。
*/
public String effectiveBatchHost() {
return resolvedBatchEndpoint().host();
}
/**
* 批量泳道 rack:见 {@link #resolvedBatchEndpoint()}。
*/
public int effectiveBatchRack() {
return resolvedBatchEndpoint().rack();
}
/**
* 批量泳道 slot:见 {@link #resolvedBatchEndpoint()}。
*/
public int effectiveBatchSlot() {
return resolvedBatchEndpoint().slot();
}
/**
* 批量泳道 IO 超时(毫秒):规则同 {@link #effectiveTaskTimeoutMs()}。
*/
public int effectiveBatchTimeoutMs() {
return resolvedBatchEndpoint().timeoutMs();
}
/**
* 批量泳道上 fixed-delay 轮询使用的线程池大小:优先 {@link BatchLaneProperties#getPollThreads()},
* 若为 0 或未配置则回退废弃字段 {@link #pollThreads},至少为 1。
*/
public int effectiveBatchPollThreads() {
int b = batch.getPollThreads();
return b > 0 ? b : Math.max(1, pollThreads);
}
/**
* AGV 泳道实际连接的 PLC 主机:见 {@link #resolvedAgvEndpoint()}。
*/
public String effectiveAgvHost() {
return resolvedAgvEndpoint().host();
}
/**
* AGV 泳道 rack:见 {@link #resolvedAgvEndpoint()}。
*/
public int effectiveAgvRack() {
return resolvedAgvEndpoint().rack();
}
/**
* AGV 泳道 slot:见 {@link #resolvedAgvEndpoint()}。
*/
public int effectiveAgvSlot() {
return resolvedAgvEndpoint().slot();
}
/**
* AGV 泳道 IO 超时(毫秒):规则同 {@link #effectiveTaskTimeoutMs()}。
*/
public int effectiveAgvTimeoutMs() {
return resolvedAgvEndpoint().timeoutMs();
}
/**
* 解析单泳道的 PLC 端点:优先泳道直连 IP,其次命名数据源,最后回落顶层 {@link #host}/{@link #rack}/{@link #slot}。
*
* @param laneHost 泳道 {@code host} 去空白后非空则视为直连
* @param dataSourceName 泳道 {@code data-source}(若全局未启用数据源特性则为 {@code null}),非空且在 {@link #dataSources} 中存在则取其定位
* @param laneRack 泳道 rack,{@code null} 时按直连/数据源/顶层规则回落
* @param laneSlot 泳道 slot,{@code null} 时按直连/数据源/顶层规则回落
* @return 解析后的逻辑键、连接参数与该泳道 IO 超时
*/
private ResolvedPlcEndpoint resolveLane(String laneHost, String dataSourceName, Integer laneRack, Integer laneSlot) {
if (laneHost != null) {
int r = laneRack != null ? laneRack : rack;
int s = laneSlot != null ? laneSlot : slot;
return new ResolvedPlcEndpoint("lane-direct", laneHost, r, s, normTimeout(timeoutMs));
}
if (dataSourceName != null) {
PlcDataSourceProperties ds = requireNamedDataSource(dataSourceName);
int r = laneRack != null ? laneRack : ds.getRack();
int s = laneSlot != null ? laneSlot : ds.getSlot();
int to = ds.getTimeoutMs() > 0 ? normTimeout(ds.getTimeoutMs()) : normTimeout(timeoutMs);
return new ResolvedPlcEndpoint(dataSourceName, ds.getHost().trim(), r, s, to);
}
int r = laneRack != null ? laneRack : rack;
int s = laneSlot != null ? laneSlot : slot;
return new ResolvedPlcEndpoint("default", host, r, s, normTimeout(timeoutMs));
}
/**
* 全局关闭命名数据源时,泳道配置的 data-source 不参与解析。
*
* @param configured 泳道 YAML 中的逻辑名,可为 {@code null}
* @return 启用时为原值,未启用时为 {@code null}
*/
private String effectiveDataSourceName(String configured) {
return dataSourcesEnabled ? configured : null;
}
/**
* 按名称取命名数据源并校验 {@code host} 非空。
*
* @param name {@link #dataSources} 中的键
* @return 对应配置
* @throws IllegalStateException 未定义或 host 为空
*/
private PlcDataSourceProperties requireNamedDataSource(String name) {
PlcDataSourceProperties ds = dataSources.get(name);
if (ds == null) {
throw new IllegalStateException("plc.data-sources 中未定义命名数据源: \"" + name + "\"");
}
String h = ds.getHost();
if (h == null || h.isBlank()) {
throw new IllegalStateException("plc.data-sources[\"" + name + "\"].host 不能为空");
}
return ds;
}
/**
* 将空白串规范为 {@code null},便于「未配置」判断。
*
* @param s 原始字符串,可为 {@code null}
* @return 去首尾空白后非空则返回该串,否则 {@code null}
*/
private static String trimToNull(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
/**
* 超时毫秒数下限为 1,避免 0 传入 Future 或连接池。
*
* @param ms 原始毫秒
* @return 至少为 1 的毫秒值
*/
private static int normTimeout(int ms) {
return Math.max(1, ms);
}
/**
* 任务泳道:关键路径,低延迟、确定性;单独连接池上限与异步线程池。
*/
@Data
public static class TaskLaneProperties {
/**
* 是否启用任务泳道(连接池、线程池及 {@link PlcHelperEnhanced} 任务 API)。
* 默认 {@code false};未启用时调用任务泳道接口将抛出 {@link IllegalStateException}。
*/
private boolean enabled = false;
/** 为空则使用顶层 plc.host;非空时优先于 {@link #dataSource} */
private String host;
/**
* 引用 {@link PlcProperties#dataSources} 中的键(YAML:{@code data-source});
* 仅在 {@link PlcProperties#dataSourcesEnabled} 为 true 且 {@link #host} 为空或空白时生效。
*/
private String dataSource;
/** 为空则使用顶层 {@link PlcProperties#rack}(或命名数据源中的 rack) */
private Integer rack;
/** 为空则使用顶层 {@link PlcProperties#slot} */
private Integer slot;
/** 任务连接池并发(许可数),建议 1~2 */
private int maxConnections = 2;
/** 任务异步线程池核心线程数(异步 BOOL 等) */
private int asyncCoreSize = 1;
/** 任务异步线程池最大线程数 */
private int asyncMaxSize = 2;
}
/**
* 批量泳道:并行批量读、批量写、周期轮询;可与任务指向同一 IP 但使用不同 TCP 连接以实现隔离。
*/
@Data
public static class BatchLaneProperties {
/**
* 是否启用批量泳道(批量读写、周期轮询、调度线程池)。
* 默认 {@code false};未启用时批量相关 API 不可用。
*/
private boolean enabled = false;
/** 为空则使用顶层 plc.host 或 {@link #dataSource}(与任务泳道仍为两套 TCP 连接) */
private String host;
/** 命名数据源键;需 {@link PlcProperties#dataSourcesEnabled},且仅在 host 空白时生效 */
private String dataSource;
/** 为空则使用顶层 rack(或数据源 rack) */
private Integer rack;
/** 为空则使用顶层 slot */
private Integer slot;
/** 批量泳道连接池最大并行连接数 */
private int maxConnections = 4;
/** 批量泳道异步线程池核心线程数 */
private int asyncCoreSize = 2;
/** 批量泳道异步线程池最大线程数 */
private int asyncMaxSize = 8;
/** 仅用于批量泳道上的 fixed-delay 轮询调度线程数 */
private int pollThreads = 1;
}
/**
* AGV 关键路径泳道:与任务、批量物理隔离连接;不经 {@code load-governor}。
*/
@Data
public static class AgvLaneProperties {
/**
* 是否启用 AGV 关键路径泳道。
* 默认 {@code false};未启用时 AGV 读接口不可用。
*/
private boolean enabled = false;
/** 为空则使用顶层 plc.host 或 {@link #dataSource} */
private String host;
/** 命名数据源键;需 {@link PlcProperties#dataSourcesEnabled},且仅在 host 空白时生效 */
private String dataSource;
/** 为空则使用顶层 rack(或数据源 rack) */
private Integer rack;
/** 为空则使用顶层 slot */
private Integer slot;
/** AGV 侧连接池并发许可数,建议 1~2 */
private int maxConnections = 2;
/** AGV 异步读线程池核心线程数 */
private int asyncCoreSize = 1;
/** AGV 异步读线程池最大线程数 */
private int asyncMaxSize = 2;
}
/**
* 指数退避重试参数(预留,当前核心读写路径未强制使用)。
*/
@Data
public static class RetryProperties {
/** 最大尝试次数(含首次) */
private int maxAttempts = 3;
/** 首次重试前等待毫秒数 */
private long initialDelayMs = 100;
/** 每次等待相对上一次的倍数 */
private double multiplier = 2.0;
/** 单次等待上限毫秒数 */
private long maxDelayMs = 5000;
}
/**
* 命名数据源:逻辑名到一台 PLC 的定位与超时;由 task/agv/batch 的 {@code data-source} 引用。
*/
@Data
public static class PlcDataSourceProperties {
/** PLC IPv4 或主机名(必填) */
private String host;
/** S7 rack,默认 0 */
private int rack = 0;
/** S7 CPU 槽位,默认 1 */
private int slot = 1;
/** 大于 0 时作为引用该数据源的泳道的 IO / 池许可等待超时(毫秒);0 表示回落顶层 {@link PlcProperties#timeoutMs} */
private int timeoutMs = 1000;
}
}
java
package org.ircm.boot.plc4x;
import org.ircm.boot.plc4x.plc.config.PlcHelperEnhanced;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Spring Boot 入口:扫描本包及子包,加载基于 PLC4X 的 PLC 封装 Bean(如 {@link PlcHelperEnhanced})。
*/
@SpringBootApplication
public class Plc4xApplication {
/**
* 启动 Spring Boot 应用,加载 PLC 封装组件与配置。
*
* @param args 命令行参数,透传给 Spring
*/
public static void main(String[] args) {
SpringApplication.run(Plc4xApplication.class, args);
}
}
java
# =============================================================================
# application.yml --- Spring Boot 主配置(默认 Profile:default)
# =============================================================================
#
# 【与本项目代码的关系】
# - 前缀 plc.* 绑定类:org.ircm.boot.plc4x.plc.config.PlcProperties
# - 读写 PLC 的 Bean:org.ircm.boot.plc4x.plc.config.PlcHelperEnhanced
# - YAML 使用 kebab-case(如 max-connections),Java 中为 camelCase(maxConnections)
#
# 【CPU 型号】
# - 未激活额外 Profile 时:本文件值为「通用默认」,PDU 偏大(偏 S7-1500 友好)。
# - 接 S7-1200 / ET200SP:建议启用 profile,见下文 spring.profiles 注释。
#
# 【覆盖配置的常用方式】(优先级由高到低简记:命令行 > 环境变量 > profile > 本文件)
# - 命令行:--plc.host=192.168.0.10 --spring.profiles.active=s7-1200
# - 环境变量:PLC_HOST( relaxed binding 等价 plc.host,具体以大写+下划线替换层级)
# - JAR 外置:同目录 application.yml 或 config/application.yml
#
# 【健康检查】
# - GET /actuator/health :未启用任何泳道(task/agv/batch.enabled 均为 false)时 DOWN;
# 已启用的泳道按连通性探活,details 含 taskPlc/batchPlc/agvPlc(up/down/disabled)。
#
# =============================================================================
# -----------------------------------------------------------------------------
# Spring Boot --- 应用基础
# -----------------------------------------------------------------------------
spring:
application:
# 应用名称(日志、Spring Cloud 等场景展示)
name: plc-service
# ---------------------------------------------------------------------------
# Spring Profile --- CPU / 机型预设(可选)
# ---------------------------------------------------------------------------
# 激活后,会额外加载同目录下 application-{profile}.yml,并与本文件合并;
# 对于 plc 下同名属性,一般以 profile 文件中为准(后加载覆盖先加载,以 Spring Boot 规则为准)。
#
# 本仓库提供的 Profile 文件名对照:
# profile 名 文件名 适用 PLC(简述)
# --------- --------- --------------
# s7-1200 application-s7-1200.yml S7-1200,较小 PDU/AMQ,超时略长
# s7-1500 application-s7-1500.yml S7-1500,较大 PDU/AMQ,利于批量
# s7-et200sp application-s7-et200sp.yml ET200SP CPU,参数偏保守(类似 1200)
#
# 示例:
# java -jar app.jar --spring.profiles.active=s7-1200
# 或 IDEA Active profiles:s7-1500
#
# profiles:
# active: s7-1500
# -----------------------------------------------------------------------------
# Spring Boot Actuator --- 运维端点
# -----------------------------------------------------------------------------
management:
endpoints:
web:
exposure:
# 暴露给 HTTP 的端点 id 列表(逗号分隔)
# health:含 PLC 泳道探活;metrics:指标;info:应用信息(需在 application 配置 info.*)
include: health,info,metrics
endpoint:
health:
# show-details:
# always --- 总是返回 details(含自定义 HealthContributor 的明细,本项目含 PLC 任务/批量/AGV 泳道)
# when-authorized --- 仅授权用户可看明细(生产可降低泄露面)
show-details: always
# =============================================================================
# plc --- Siemens S7(PLC4X)连接与泳道参数
# =============================================================================
#
# 【三泳道架构】(每种泳道 = 独立 TCP 连接池 + 对应线程池或调度器)
#
# 泳道 典型 API(节选) 负载节流 load-governor
# ---- -------------------------------- ---------------------
# task read/write Boolean、readByte、 不启用(保证确定性低抖动)
# readString、readByteArray、异步 BOOL
# agv readBooleanAgv、readByteAgv、 不启用(车载关键路径)
# readStringAgv、readByteArrayAgv、异步 BOOL 读
# batch batchRead、batchWriteBoolean、 启用(防批量压垮 PLC)
# subscribeBatchPoll、subscribeBooleanPoll
#
# 【地址字符串约定】(与 PlcHelperEnhanced 内 convertAddress 一致)
#
# 用途 格式 PLC4X 语义示例
# ---- ----- --------------
# 单字节 BYTE DB{块}.{偏移} DB1.10 → %DB1.DBB10:BYTE
# STRING 起始 DB{块}.{偏移} 两段式;STRING(maxLen) 在代码/配置中约定长度
# 字节数组起始 DB{块}.{偏移} 连续多个 DBB
# BOOL 位 DB{块}.{字节}.{位} DB1.10.0 → %DB1.DBX10.0:BOOL
#
# 【连接 URL】(无需手写;由 PlcConnectionPoolManager 根据下列参数生成)
# 形如:s7://{host}?rack=&slot=&pdu-size=&max-amq-caller=&max-amq-callee=&tcp.default-timeout=&tcp.no-delay=
#
# 【已废弃的全局字段 --- 请勿在新项目中使用,请改泳道内配置】
# plc.async-core-size / plc.async-max-size / plc.max-connections
# plc.poll-threads(请改用 plc.batch.poll-threads)
# 若仍在旧 yml 中存在,仅为兼容;逻辑上以 task.batch.agv 内为准。
#
plc:
# ----- 顶层:默认 PLC 定位(task / agv / batch 未单独指定时的回退) -----
# IPv4 / 主机名;端口固定 ISO-on-TCP 102,不在此配置
host: 192.168.1.100
# S7 Rack(硬件配置需与 TIA Portal 一致)
rack: 0
# S7 Slot(CPU 或以太网接口槽位,常见为 1)
slot: 1
# 应用层:单次 read/write Future.get(timeout)(毫秒)
timeout-ms: 1000
# 可选:机型标签,写入 PlcProperties.cpuSeries;激活 application-s7-*.yml 时会自动带上
# 不参与驱动协商,仅日志与运维识别(如 Grafana 标签)
# cpu-series: custom
# ----- task:任务关键路径泳道(TaskLaneProperties) -----
task:
# enabled:是否启用任务泳道(默认 false)。仅开启的泳道会创建连接池/线程池并提供对应 API。
enabled: false
# host:可选;非空白优先于 data-source;二者皆空则回落 plc.host
# host: 192.168.1.50
# data-source:可选;引用 plc.data-sources 下的键(如 production);仅在 host 空白时解析 host/rack/slot/timeout-ms;
# 且须 plc.data-sources-enabled: true(默认 false,关闭时不解析命名数据源)
# data-source: production
# rack / slot:可选 Integer;不配置则回落 plc.rack / plc.slot(使用 data-source 时亦可覆盖数据源中的 rack/slot)
# rack: 0
# slot: 1
# max-connections:连接池许可数;过大将增加 PLC 并行连接压力,关键路径建议 1~2
max-connections: 2
# async-core-size:线程池核心线程(plc-task-io)
async-core-size: 1
# async-max-size:线程池上限;队列满时 CallerRunsPolicy,调用线程可能自执行导致短暂阻塞
async-max-size: 2
# ----- agv:AGV 关键路径泳道(AgvLaneProperties) -----
agv:
enabled: false
# host / data-source / rack / slot:语义同 task(data-source 需 plc.data-sources-enabled: true);不配 host 且无可用 data-source 则回落顶层
# host: 192.168.1.102
# rack: 0
# slot: 1
# max-connections:AGV 专用池并行连接数
max-connections: 2
# async-*:readBooleanAgvAsync 等使用的线程池(plc-agv-io)
async-core-size: 1
async-max-size: 2
# ----- batch:批量 / 轮询泳道(BatchLaneProperties) -----
batch:
enabled: false
# host / data-source / rack / slot:语义同 task(data-source 需 plc.data-sources-enabled: true);常用于多 PLC 分离
# host: 192.168.1.101
# rack: 0
# slot: 1
# max-connections:批量读、批量写、轮询 BOOL 共用此池
max-connections: 4
# async-*:预留异步批量 API(plc-batch-io)
async-core-size: 2
async-max-size: 8
# poll-threads:ScheduledExecutorService 线程数;多线程时多个 fixed-delay 任务可并行触发
# effectiveBatchPollThreads():若此处为 0,则回退已废弃的 plc.poll-threads,且最小为 1
poll-threads: 1
# ----- S7 / TCP:PLC4X 驱动连接串参数(所有泳道连接池共用这一组数值) -----
# s7-pdu-optimize:false 时不追加 pdu-size、max-amq-*(仅用 rack/slot/tcp 超时)
s7-pdu-optimize: true
# s7-pdu-size:期望 PDU 字节长度;实际=min(本值, PLC 协商结果);1200 场景 profile 中通常更小
s7-pdu-size: 1024
# s7-max-amq-caller:PLC 侧允许的并行「未确认请求」数量上限(过小吞吐降,过大占 PLC)
s7-max-amq-caller: 4
# s7-max-amq-callee:本侧(PLC4X)并行窗口
s7-max-amq-callee: 8
# tcp-default-timeout-ms:写入 URL 的 tcp.default-timeout(毫秒)
tcp-default-timeout-ms: 1000
# tcp-no-delay:true 追加 tcp.no-delay=true(禁用 Nagle,小包延迟更低)
tcp-no-delay: true
# ----- load-governor:批量泳道自适应节流(PlcLoadGovernor) -----
# load-governor-enabled:false 则 before/afterPlcCall 不睡眠、不调档位
load-governor-enabled: true
# governor-latency-high-watermark-ms:单次 RTT ≥ 此值视为「偏忙」,小幅 bump 节流
governor-latency-high-watermark-ms: 400
# governor-throttle-step-ms:超时或失败时,节流睡眠单次增加量(ms)
governor-throttle-step-ms: 50
# governor-max-throttle-ms:节流睡眠上限(ms)
governor-max-throttle-ms: 2000
# governor-decay-step-ms:成功且 RTT 不高时,节流单次递减量(ms)
governor-decay-step-ms: 25
# ----- poll-period-ms:subscribeBatchPoll / subscribeBooleanPoll 未传周期时的默认间隔 -----
poll-period-ms: 1000
# ----- retry:指数退避占位(RetryProperties);当前核心读写路径未强制使用 -----
retry:
max-attempts: 3
initial-delay-ms: 100
multiplier: 2.0
max-delay-ms: 5000
# ----- data-sources:命名数据源(PlcDataSourceProperties),多 IP / 主备 -------
# data-sources-enabled:默认 false。false 时不解析 task/agv/batch 的 data-source,plc.data-sources 仅作文档/占位;
# true 时泳道可写 data-source: <键名>,在未写泳道 host 时使用此处 host、rack、slot。
# timeout-ms:大于 0 时覆盖该泳道的 Future.get / 连接池借出等待超时;为 0 则仍用顶层 plc.timeout-ms
data-sources-enabled: false
data-sources:
production:
host: 192.168.1.100
rack: 0
slot: 1
timeout-ms: 1000
backup:
host: 192.168.1.101
rack: 0
slot: 1
timeout-ms: 1000
java
# =============================================================================
# application-s7-1200.yml
# Spring Profile 名称:s7-1200
# =============================================================================
#
# 【何时使用】现场 PLC 为 Siemens S7-1200(紧凑型),或通信在默认 PDU 下不稳定时。
#
# 【如何启用】
# spring.profiles.active=s7-1200
# 或与默认一起:spring.profiles.active=default,s7-1200
#
# 【设计意图】(相对主 application.yml)
# - 降低 s7-pdu-size、s7-max-amq-*,减轻 1200 CPU 通信栈压力。
# - 适度增大 timeout-ms / tcp-default-timeout-ms,减少偶发首连慢导致的误判。
# - batch.max-connections 略收紧;仍依赖 load-governor 在高负载时.sleep。
#
# 【必须与 TIA 一致】plc.rack、plc.slot、各泳道可选 rack/slot;单体以太网 CPU 多为 rack=0、slot=1。
#
# 【排障】若仍握手失败或频繁超时,可再将 s7-pdu-size 改为 240 试验(注释写明,改数值即可)。
#
# 【合并】与 application.yml 合并;下文 plc.* 覆盖主文件中同名键。
# 【结构同步】泳道 enabled、data-sources-enabled、retry、data-sources 与主 application.yml 一致;
# 本文件主要覆盖 cpu-series、PDU/AMQ、超时及 batch 连接数等 1200 专属项。
#
# 【健康检查】GET /actuator/health:仅对已 enabled 的泳道探活;详见主 application.yml 说明。
#
# =============================================================================
plc:
# cpu-series → PlcProperties.cpuSeries;启动时打印日志「PLC CPU 系列配置标识」
cpu-series: s7-1200
# ----- 顶层:默认 PLC 定位(与主配置一致,可按现场覆盖) -----
host: 192.168.1.100
rack: 0
slot: 1
# timeout-ms:Java Future.get 等待 PLC 响应的上限(毫秒)
# tcp-default-timeout-ms:PLC4X Netty TCP 连接层超时(毫秒),写入连接 URL
timeout-ms: 1500
tcp-default-timeout-ms: 1500
# s7-pdu-optimize:建议在 true,以便协商 PDU 与 AMQ
s7-pdu-optimize: true
# s7-pdu-size:1200 保守值;若确认固件支持更高可酌情上调(需实测)
s7-pdu-size: 480
# s7-max-amq-caller / callee:并行窗口偏小,利于 1200 稳定
s7-max-amq-caller: 2
s7-max-amq-callee: 4
# tcp-no-delay:小包低延迟,一般保持 true
tcp-no-delay: true
# ----- task:任务关键路径泳道(与主 application.yml 语义一致) -----
task:
enabled: false
# host / data-source / rack / slot:见主文件;不配 host 且无 data-source 则回落 plc.host
max-connections: 2
async-core-size: 1
async-max-size: 2
# ----- agv:AGV 关键路径泳道 -----
agv:
enabled: false
max-connections: 2
async-core-size: 1
async-max-size: 2
# ----- batch:批量 / 轮询泳道(1200 略收紧 max-connections) -----
batch:
enabled: false
max-connections: 3
async-core-size: 2
async-max-size: 6
poll-threads: 1
# ----- load-governor:批量泳道自适应节流 -----
load-governor-enabled: true
governor-latency-high-watermark-ms: 400
governor-throttle-step-ms: 50
governor-max-throttle-ms: 2000
governor-decay-step-ms: 25
poll-period-ms: 1000
# ----- retry:与主 application.yml 一致(占位) -----
retry:
max-attempts: 3
initial-delay-ms: 100
multiplier: 2.0
max-delay-ms: 5000
# ----- data-sources:命名数据源;timeout-ms 与本 profile 顶层超时对齐 -----
data-sources-enabled: false
data-sources:
production:
host: 192.168.1.100
rack: 0
slot: 1
timeout-ms: 1500
backup:
host: 192.168.1.101
rack: 0
slot: 1
timeout-ms: 1500
java
# =============================================================================
# application-s7-1500.yml
# Spring Profile 名称:s7-1500
# =============================================================================
#
# 【何时使用】现场 PLC 为 Siemens S7-1500 系列(151x / 152x / 154x / 156x 等),
# 或需要较大批量读、希望减少 PDU 拆分次数的场景。
#
# 【如何启用】spring.profiles.active=s7-1500
#
# 【设计意图】
# - s7-pdu-size、max-amq 与主 application.yml 默认接近或略高,发挥 1500 吞吐能力。
# - batch 泳道连接数、异步上限略大,便于并行批量与轮询(仍受连接池与 PLC 能力约束)。
#
# 【分布式 PLC】若 CPU 位于 ET 200MP 等,rack/slot 必须以设备视图为准,勿照搬 0/1。
#
# 【固件较旧】若协商异常,可将 s7-pdu-size 临时改为 960 再测。
#
# 【合并】与 application.yml 合并;下文 plc.* 覆盖主文件中同名键。
# 【结构同步】泳道 enabled、data-sources-enabled、retry、data-sources 与主 application.yml 一致。
#
# 【健康检查】GET /actuator/health:仅对已 enabled 的泳道探活;详见主 application.yml 说明。
#
# =============================================================================
plc:
cpu-series: s7-1500
# ----- 顶层:默认 PLC 定位(与主配置一致,可按现场覆盖) -----
host: 192.168.1.100
rack: 0
slot: 1
# 1500 默认超时与主配置一致即可;现场网络差时可同步上调 timeout-ms 与 tcp-default-timeout-ms
timeout-ms: 1000
tcp-default-timeout-ms: 1000
s7-pdu-optimize: true
# s7-pdu-size:高性能 CPU 常用上限附近;最终仍受 PLC 协商限制
s7-pdu-size: 1024
s7-max-amq-caller: 4
s7-max-amq-callee: 8
tcp-no-delay: true
# ----- task / agv / batch:与主文件结构一致;数值按 1500 能力 -----
task:
enabled: false
max-connections: 2
async-core-size: 1
async-max-size: 2
agv:
enabled: false
max-connections: 2
async-core-size: 1
async-max-size: 2
batch:
enabled: false
max-connections: 4
async-core-size: 2
async-max-size: 8
poll-threads: 1
load-governor-enabled: true
governor-latency-high-watermark-ms: 400
governor-throttle-step-ms: 50
governor-max-throttle-ms: 2000
governor-decay-step-ms: 25
poll-period-ms: 1000
retry:
max-attempts: 3
initial-delay-ms: 100
multiplier: 2.0
max-delay-ms: 5000
data-sources-enabled: false
data-sources:
production:
host: 192.168.1.100
rack: 0
slot: 1
timeout-ms: 1000
backup:
host: 192.168.1.101
rack: 0
slot: 1
timeout-ms: 1000
java
# =============================================================================
# application-s7-et200sp.yml
# Spring Profile 名称:s7-et200sp
# =============================================================================
#
# 【何时使用】CPU 为 Siemens ET 200SP 系列(如 CPU 151xSP、开放式控制器等),
# 通信行为接近紧凑型 PLC,推荐保守 PDU / AMQ。
#
# 【如何启用】spring.profiles.active=s7-et200sp
#
# 【与 s7-1200 profile 关系】
# 数值上与 application-s7-1200.yml 接近(480 PDU、较小 AMQ、超时 1500ms)。
# 若现场 SP-CPU 性能强、批量极大,可改用 profile s7-1500 提升 pdu-size。
#
# 【rack / slot】必须以 TIA Portal 设备视图中「接口子模块 / CPU」槽位为准;
# 文档常见为 0 / 1,但以具体项目硬件为准。
#
# 【合并】与 application.yml 合并;下文 plc.* 覆盖主文件中同名键。
# 【结构同步】泳道 enabled、data-sources-enabled、retry、data-sources 与主 application.yml 一致。
#
# 【健康检查】GET /actuator/health:仅对已 enabled 的泳道探活;详见主 application.yml 说明。
#
# =============================================================================
plc:
cpu-series: s7-et200sp
# ----- 顶层:默认 PLC 定位(与主配置一致,可按现场覆盖) -----
host: 192.168.1.100
rack: 0
slot: 1
timeout-ms: 1500
tcp-default-timeout-ms: 1500
s7-pdu-optimize: true
s7-pdu-size: 480
s7-max-amq-caller: 2
s7-max-amq-callee: 4
tcp-no-delay: true
task:
enabled: false
max-connections: 2
async-core-size: 1
async-max-size: 2
agv:
enabled: false
max-connections: 2
async-core-size: 1
async-max-size: 2
batch:
enabled: false
max-connections: 3
async-core-size: 2
async-max-size: 6
poll-threads: 1
load-governor-enabled: true
governor-latency-high-watermark-ms: 400
governor-throttle-step-ms: 50
governor-max-throttle-ms: 2000
governor-decay-step-ms: 25
poll-period-ms: 1000
retry:
max-attempts: 3
initial-delay-ms: 100
multiplier: 2.0
max-delay-ms: 5000
data-sources-enabled: false
data-sources:
production:
host: 192.168.1.100
rack: 0
slot: 1
timeout-ms: 1500
backup:
host: 192.168.1.101
rack: 0
slot: 1
timeout-ms: 1500
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link PlcConnectionPoolManager} 连接串拼装逻辑单元测试(包内可见的 {@link PlcConnectionPoolManager#buildS7Url})。
*/
@DisplayName("PlcConnectionPoolManager S7 URL")
class PlcConnectionPoolManagerTest {
/**
* {@link PlcConnectionPoolManager#buildS7Url}:关闭 PDU 优化时仅含主机、rack、slot、TCP 超时。
*/
@Test
@DisplayName("基础 rack/slot 与 tcp.default-timeout")
void buildS7Url_minimal_containsHostRackSlotTcpTimeout() {
String url = PlcConnectionPoolManager.buildS7Url(
"192.168.0.10", 0, 1, false, 1024, 4, 8, 1500, false);
assertThat(url).isEqualTo("s7://192.168.0.10?rack=0&slot=1&tcp.default-timeout=1500");
}
/**
* 开启 PDU 优化时 URL 应包含 pdu-size、max-amq-caller、callee。
*/
@Test
@DisplayName("pduOptimize=true 时附带 pdu-size 与 max-amq")
void buildS7Url_pduOptimize_appendsPduAndAmq() {
String url = PlcConnectionPoolManager.buildS7Url(
"10.0.0.1", 2, 3, true, 960, 5, 6, 500, false);
assertThat(url)
.startsWith("s7://10.0.0.1?rack=2&slot=3")
.contains("&pdu-size=960&max-amq-caller=5&max-amq-callee=6")
.contains("&tcp.default-timeout=500")
.doesNotContain("tcp.no-delay");
}
/**
* tcpNoDelay 为 true 时追加 {@code tcp.no-delay=true}。
*/
@Test
@DisplayName("tcpNoDelay=true 追加 tcp.no-delay=true")
void buildS7Url_tcpNoDelay_appendsFlag() {
String url = PlcConnectionPoolManager.buildS7Url(
"plc.local", 0, 2, false, 1024, 4, 8, 1000, true);
assertThat(url).endsWith("&tcp.default-timeout=1000&tcp.no-delay=true");
}
/**
* tcp.default-timeout 传入 0 时应规范为 1。
*/
@Test
@DisplayName("tcp.default-timeout 至少为 1")
void buildS7Url_tcpTimeout_zero_normalizedToOne() {
String url = PlcConnectionPoolManager.buildS7Url(
"127.0.0.1", 0, 1, false, 1024, 4, 8, 0, false);
assertThat(url).contains("&tcp.default-timeout=1");
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link PlcHelperEnhanced} 周期轮询 API 使用示例与轻量断言。
*
* 典型用法(在 Spring 已启动、{@link PlcHelperEnhanced#start()} 已执行后):
* <pre>{@code
* // 1) 按 application.yml 中 plc.poll-period-ms 默认周期,轮询单个 BOOL
* String id = plcHelper.subscribeBooleanPoll("DB1.0.0",
* v -> {
* // 处理当前值
* },
* e -> {
* // 单次读失败(可选)
* });
*
* // 2) 自定义周期(毫秒)
* String id2 = plcHelper.subscribeBooleanPoll(500L, "DB1.0.1", v -> { }, e -> { });
*
* // 3) 批量读:别名 -> 简化地址,每次回调拿到整表快照
* String id3 = plcHelper.subscribeBatchPoll(
* Map.of("tem", "DB1.0", "run", "DB1.1"),
* map -> {
* Object tem = map.get("tem");
* },
* e -> { });
*
* // 4) 取消订阅
* plcHelper.unsubscribe(id);
* plcHelper.unsubscribeAll();
* }</pre>
*/
@SpringBootTest
@DisplayName("PlcHelperEnhanced 周期轮询示例")
class PlcHelperEnhancedPollingExampleTest {
@Autowired
private PlcHelperEnhanced plcHelper;
/**
* 校验:传入从未注册的订阅 id 时,{@link PlcHelperEnhanced#unsubscribe(String)} 返回 false。
*/
@Test
@DisplayName("取消不存在的订阅返回 false")
void unsubscribe_unknownId_returnsFalse() {
assertThat(plcHelper.unsubscribe("plc-poll-no-such-id")).isFalse();
}
/**
* 校验:当前无任何周期轮询订阅时,{@link PlcHelperEnhanced#unsubscribeAll()} 可安全调用且不抛异常。
*/
@Test
@DisplayName("无订阅时 unsubscribeAll 不抛异常")
void unsubscribeAll_whenEmpty_noException() {
plcHelper.unsubscribeAll();
}
/**
* 校验:{@link PlcHelperEnhanced#getStats()} 包含轮询订阅数、负载节流档位、超时与各泳道主机等运维字段。
*/
@Test
@DisplayName("统计信息包含当前轮询订阅数量")
void getStats_containsPollSubscriptionCount() {
Map<String, Object> stats = plcHelper.getStats();
assertThat(stats).containsKey("pollSubscriptionCount");
assertThat(((Number) stats.get("pollSubscriptionCount")).intValue()).isZero();
assertThat(stats).containsKeys("loadGovernorThrottleMs", "timeoutMs", "s7PduOptimize",
"taskLaneHost", "batchLaneHost", "agvLaneHost",
"taskLaneEnabled", "batchLaneEnabled", "agvLaneEnabled",
"taskLaneEndpointKey", "batchLaneEndpointKey", "agvLaneEndpointKey",
"taskLaneTimeoutMs", "batchLaneTimeoutMs", "agvLaneTimeoutMs",
"totalAgvReadCount", "avgAgvReadLatencyMs");
assertThat(((Number) stats.get("loadGovernorThrottleMs")).intValue()).isZero();
assertThat(((Number) stats.get("totalAgvReadCount")).longValue()).isZero();
}
/**
* 需要真实 PLC 与可达地址时启用:去掉 {@link Disabled},并按现场修改 DB 地址。
*/
@Test
@Disabled("需要真实 PLC;本地连通后去掉本注解再运行")
@DisplayName("示例:BOOL 周期轮询(需真实设备)")
void example_subscribeBooleanPoll_withRealPlc() {
String id = plcHelper.subscribeBooleanPoll(
"DB1.0.0",
v -> {
// 业务处理
},
e -> {
// 可选:记录单次失败
});
assertThat(plcHelper.unsubscribe(id)).isTrue();
}
/**
* 需要真实 PLC 时启用。
*/
@Test
@Disabled("需要真实 PLC;本地连通后去掉本注解再运行")
@DisplayName("示例:批量周期轮询(需真实设备)")
void example_subscribeBatchPoll_withRealPlc() {
String id = plcHelper.subscribeBatchPoll(
Map.of("a", "DB1.0", "b", "DB1.1"),
map -> {
// map.get("a"), map.get("b")
},
e -> {
});
assertThat(plcHelper.unsubscribe(id)).isTrue();
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowable;
/**
* {@link PlcHelperEnhanced} 参数校验与「无需连接 PLC」的分支测试。
*/
@SpringBootTest
@DisplayName("PlcHelperEnhanced 参数校验")
class PlcHelperEnhancedValidationTest {
@Autowired
private PlcHelperEnhanced plcHelper;
/**
* periodMs 小于 1 时应拒绝注册 BOOL 轮询。
*/
@Test
@DisplayName("subscribeBooleanPoll:periodMs < 1 抛 IllegalArgumentException")
void subscribeBooleanPoll_invalidPeriod_throws() {
assertThatThrownBy(() ->
plcHelper.subscribeBooleanPoll(0L, "DB1.0.0", v -> {}, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("periodMs");
}
/**
* address 为空白时应拒绝注册 BOOL 轮询。
*/
@Test
@DisplayName("subscribeBooleanPoll:address 空白抛 IllegalArgumentException")
void subscribeBooleanPoll_blankAddress_throws() {
assertThatThrownBy(() ->
plcHelper.subscribeBooleanPoll(100L, " ", v -> {}, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("address");
}
/**
* onData 为空时应拒绝注册 BOOL 轮询。
*/
@Test
@DisplayName("subscribeBooleanPoll:onData 为空抛 IllegalArgumentException")
void subscribeBooleanPoll_nullOnData_throws() {
assertThatThrownBy(() ->
plcHelper.subscribeBooleanPoll(100L, "DB1.0.0", null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("onData");
}
/**
* 批量轮询 periodMs 非法时应抛异常。
*/
@Test
@DisplayName("subscribeBatchPoll:periodMs < 1 抛 IllegalArgumentException")
void subscribeBatchPoll_invalidPeriod_throws() {
assertThatThrownBy(() ->
plcHelper.subscribeBatchPoll(0L, Map.of("x", "DB1.0"), m -> {}, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("periodMs");
}
/**
* 轮询地址 map 为空时应抛异常。
*/
@Test
@DisplayName("subscribeBatchPoll:地址 map 为空抛 IllegalArgumentException")
void subscribeBatchPoll_emptyAddresses_throws() {
assertThatThrownBy(() ->
plcHelper.subscribeBatchPoll(Map.of(), m -> {}, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("轮询地址");
}
/**
* 批量写 BOOL 时两 map 的 key 必须一致。
*/
@Test
@DisplayName("batchWriteBoolean:别名 key 不一致抛 IllegalArgumentException(不发起连接)")
void batchWriteBoolean_keyMismatch_throwsBeforeConnect() {
assertThatThrownBy(() ->
plcHelper.batchWriteBoolean(Map.of("a", "DB1.0.0"), Map.of("b", true)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("key");
}
/**
* 批量写传入空 map 时应直接返回。
*/
@Test
@DisplayName("batchWriteBoolean:空 map 直接返回不抛异常")
void batchWriteBoolean_empty_noOp() throws Exception {
plcHelper.batchWriteBoolean(Map.of(), Map.of());
}
/**
* readByteArray 在 length 非法时应在借连接前失败。
*/
@Test
@DisplayName("readByteArray:length < 1 时根因为 IllegalArgumentException(不发起连接)")
void readByteArray_invalidLength_throws() {
Throwable thrown = catchThrowable(() -> plcHelper.readByteArray("DB1.0", 0));
assertThat(thrown).isInstanceOf(RuntimeException.class);
assertThat(thrown.getCause()).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("length");
}
/**
* readByteArray 地址须为两段式。
*/
@Test
@DisplayName("readByteArray:地址须为两段式 DBn.byte(根因 IllegalArgumentException)")
void readByteArray_invalidAddressFormat_throws() {
assertThatThrownBy(() -> plcHelper.readByteArray("DB1", 3))
.isInstanceOf(RuntimeException.class)
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
/**
* readString 在地址非法时应先于借连接失败。
*/
@Test
@DisplayName("readString:两段式地址非法时根因为 IllegalArgumentException(校验先于借连接)")
void readString_invalidAddress_throws() {
assertThatThrownBy(() -> plcHelper.readString("DB1"))
.isInstanceOf(RuntimeException.class)
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
/**
* 服务停止后健康检查应为 DOWN,随后恢复启动以免污染其它用例。
*/
@Test
@DisplayName("health:服务未启动时应为 DOWN(反射调用 stop 后探测)")
void health_whenStopped_isDown() throws Exception {
plcHelper.stop();
try {
assertThat(plcHelper.health().getStatus().getCode()).isEqualTo("DOWN");
} finally {
plcHelper.start();
}
}
/**
* 尚无成功样本时平均延迟 API 应返回 0。
*/
@Test
@DisplayName("平均延迟:尚无成功读写时为 0")
void averageLatencies_zeroWhenNoSamples() {
assertThat(plcHelper.getAverageReadLatency()).isZero();
assertThat(plcHelper.getAverageWriteLatency()).isZero();
assertThat(plcHelper.getAverageAgvReadLatency()).isZero();
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link PlcLoadGovernor} 节流档位单元测试。
*/
@DisplayName("PlcLoadGovernor")
class PlcLoadGovernorTest {
private PlcProperties props;
private PlcLoadGovernor governor;
/**
* 每个用例前构造默认 {@link PlcProperties} 与 {@link PlcLoadGovernor}。
*/
@BeforeEach
void setUp() {
props = new PlcProperties();
props.setLoadGovernorEnabled(true);
props.setGovernorThrottleStepMs(50);
props.setGovernorMaxThrottleMs(2000);
props.setGovernorDecayStepMs(25);
props.setGovernorLatencyHighWatermarkMs(400);
governor = new PlcLoadGovernor(props);
}
/**
* 初始节流档位应为 0。
*/
@Test
@DisplayName("初始节流为 0")
void initialThrottle_zero() {
assertThat(governor.getThrottleSleepMs()).isZero();
}
/**
* 关闭负载治理后不应修改节流值。
*/
@Test
@DisplayName("关闭负载治理时不调整节流")
void disabled_noAdjustment() {
props.setLoadGovernorEnabled(false);
governor.afterPlcCall(false, 0, true);
assertThat(governor.getThrottleSleepMs()).isZero();
}
/**
* 失败或 Future 超时后应按步长累加节流。
*/
@Test
@DisplayName("超时或失败按 governorThrottleStepMs 增大")
void afterFailure_orTimeout_bumpsStep() {
governor.afterPlcCall(false, 100, false);
assertThat(governor.getThrottleSleepMs()).isEqualTo(50);
governor.afterPlcCall(true, 100, true);
assertThat(governor.getThrottleSleepMs()).isEqualTo(100);
}
/**
* 成功且 RTT 低于水位线时应递减节流。
*/
@Test
@DisplayName("成功且延迟低于水位线时递减")
void afterSuccess_lowLatency_decays() {
governor.afterPlcCall(false, 0, false);
assertThat(governor.getThrottleSleepMs()).isEqualTo(50);
governor.afterPlcCall(true, 100, false);
assertThat(governor.getThrottleSleepMs()).isEqualTo(25);
governor.afterPlcCall(true, 50, false);
assertThat(governor.getThrottleSleepMs()).isZero();
}
/**
* 成功但 RTT 过高时应小幅增加节流。
*/
@Test
@DisplayName("成功但延迟不低于水位线时小幅 bump(步长至少 1)")
void afterSuccess_highLatency_bumpsHalfStep() {
governor.afterPlcCall(true, 500, false);
assertThat(governor.getThrottleSleepMs()).isEqualTo(25);
}
/**
* 节流不得超过配置的最大值。
*/
@Test
@DisplayName("节流不超过 governorMaxThrottleMs")
void throttle_cappedAtMax() {
props.setGovernorMaxThrottleMs(120);
for (int i = 0; i < 10; i++) {
governor.afterPlcCall(false, 0, false);
}
assertThat(governor.getThrottleSleepMs()).isEqualTo(120);
}
/**
* 递减时节流不应低于 0。
*/
@Test
@DisplayName("节流不低于 0")
void decay_notBelowZero() {
governor.afterPlcCall(true, 10, false);
assertThat(governor.getThrottleSleepMs()).isZero();
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@link PlcProperties} 泳道 effective* 解析逻辑单元测试(无 Spring 容器)。
*/
@DisplayName("PlcProperties 泳道解析")
class PlcPropertiesLaneResolutionTest {
private PlcProperties props;
/**
* 初始化默认顶层 host/rack/slot。
*/
@BeforeEach
void setUp() {
props = new PlcProperties();
props.setHost("192.168.1.100");
props.setRack(0);
props.setSlot(1);
}
/**
* 未配置泳道 host 时三泳道均应回落到 plc.host。
*/
@Test
@DisplayName("未配置泳道 host 时全部回落顶层 host")
void effectiveHosts_fallbackToTopLevel() {
assertThat(props.effectiveTaskHost()).isEqualTo("192.168.1.100");
assertThat(props.effectiveBatchHost()).isEqualTo("192.168.1.100");
assertThat(props.effectiveAgvHost()).isEqualTo("192.168.1.100");
}
/**
* 各泳道单独 host 应覆盖顶层。
*/
@Test
@DisplayName("泳道单独配置 host 时生效")
void effectiveHosts_laneOverrides() {
props.getTask().setHost("10.0.0.1");
props.getBatch().setHost("10.0.0.2");
props.getAgv().setHost("10.0.0.3");
assertThat(props.effectiveTaskHost()).isEqualTo("10.0.0.1");
assertThat(props.effectiveBatchHost()).isEqualTo("10.0.0.2");
assertThat(props.effectiveAgvHost()).isEqualTo("10.0.0.3");
}
/**
* 仅含空白的泳道 host 视为未配置。
*/
@Test
@DisplayName("泳道 host 为空串视为未配置")
void effectiveHosts_blankHostIgnored() {
props.getTask().setHost(" ");
assertThat(props.effectiveTaskHost()).isEqualTo("192.168.1.100");
}
/**
* rack/slot 未在泳道配置时应回落顶层;配置后应覆盖。
*/
@Test
@DisplayName("rack/slot Integer 为空时回落顶层")
void effectiveRackSlot_fallback() {
props.setRack(7);
props.setSlot(3);
assertThat(props.effectiveTaskRack()).isEqualTo(7);
assertThat(props.effectiveTaskSlot()).isEqualTo(3);
props.getTask().setRack(2);
props.getTask().setSlot(5);
assertThat(props.effectiveTaskRack()).isEqualTo(2);
assertThat(props.effectiveTaskSlot()).isEqualTo(5);
}
/**
* batch.pollThreads 大于 0 时应优先于废弃的 plc.poll-threads。
*/
@Test
@DisplayName("effectiveBatchPollThreads:batch.pollThreads>0 优先")
void effectiveBatchPollThreads_prefersBatchLane() {
props.getBatch().setPollThreads(4);
props.setPollThreads(9);
assertThat(props.effectiveBatchPollThreads()).isEqualTo(4);
}
/**
* batch 泳道 pollThreads 为 0 时应回落顶层 pollThreads。
*/
@Test
@DisplayName("effectiveBatchPollThreads:batch 为 0 时回落顶层 pollThreads")
void effectiveBatchPollThreads_fallbackDeprecatedPollThreads() {
props.getBatch().setPollThreads(0);
props.setPollThreads(3);
assertThat(props.effectiveBatchPollThreads()).isEqualTo(3);
}
/**
* 两层 pollThreads 均为 0 时 effectiveBatchPollThreads 至少为 1。
*/
@Test
@DisplayName("effectiveBatchPollThreads:两层均为 0 时至少为 1")
void effectiveBatchPollThreads_minimumOne() {
props.getBatch().setPollThreads(0);
props.setPollThreads(0);
assertThat(props.effectiveBatchPollThreads()).isEqualTo(1);
}
/**
* data-source 指向的命名数据源应解析出 host/rack/slot 与超时。
*/
@Test
@DisplayName("data-source:泳道 host 空白时解析命名数据源")
void effectiveHosts_dataSourceResolves() {
props.setDataSourcesEnabled(true);
props.getDataSources().put("production", namedDs("10.0.0.10", 1, 2, 500));
props.getTask().setDataSource("production");
assertThat(props.effectiveTaskHost()).isEqualTo("10.0.0.10");
assertThat(props.effectiveTaskRack()).isEqualTo(1);
assertThat(props.effectiveTaskSlot()).isEqualTo(2);
assertThat(props.effectiveTaskTimeoutMs()).isEqualTo(500);
}
/**
* 全局关闭命名数据源时:即使有 plc.data-sources 与泳道 data-source,也回落顶层 host,且不校验命名键是否存在。
*/
@Test
@DisplayName("data-sources-enabled=false 时忽略 data-source,回落顶层 host")
void dataSourcesDisabled_ignoresDataSource_fallsBackTopLevel() {
props.getDataSources().put("production", namedDs("10.0.0.10", 0, 1, 500));
props.getTask().setDataSource("production");
assertThat(props.isDataSourcesEnabled()).isFalse();
assertThat(props.effectiveTaskHost()).isEqualTo("192.168.1.100");
assertThat(props.resolvedTaskEndpoint().logicalKey()).isEqualTo("default");
}
/**
* 泳道显式 host 优先于 data-source。
*/
@Test
@DisplayName("泳道显式 host 优先,忽略 data-source 指向的另一台 IP")
void effectiveHosts_explicitLaneHostIgnoresDataSource() {
props.getDataSources().put("production", namedDs("10.0.0.10", 0, 1, 500));
props.getTask().setHost("10.0.0.99");
props.getTask().setDataSource("production");
assertThat(props.effectiveTaskHost()).isEqualTo("10.0.0.99");
assertThat(props.effectiveTaskTimeoutMs()).isEqualTo(1000);
}
/**
* data-sources 中不存在给定键时应抛出 {@link IllegalStateException}。
*/
@Test
@DisplayName("data-source 未定义时 resolvedTaskEndpoint 抛 IllegalStateException")
void effectiveHosts_missingDataSourceThrows() {
props.setDataSourcesEnabled(true);
props.getTask().setDataSource("ghost");
assertThatThrownBy(() -> props.resolvedTaskEndpoint())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ghost");
}
/**
* 数据源 timeout-ms 为 0 时 IO 超时回落 plc.timeout-ms。
*/
@Test
@DisplayName("数据源 timeout-ms 为 0 时回落顶层 plc.timeout-ms")
void effectiveTimeout_dataSourceZeroFallsBackGlobal() {
props.setDataSourcesEnabled(true);
props.setTimeoutMs(2000);
props.getDataSources().put("p", namedDs("10.0.0.10", 0, 1, 0));
props.getTask().setDataSource("p");
assertThat(props.effectiveTaskTimeoutMs()).isEqualTo(2000);
}
/**
* 使用 data-source 时泳道 rack 仍可覆盖数据源中的 rack。
*/
@Test
@DisplayName("使用 data-source 时泳道 rack 可覆盖数据源 rack")
void effectiveRack_laneOverridesDataSource() {
props.setDataSourcesEnabled(true);
props.getDataSources().put("production", namedDs("10.0.0.10", 1, 2, 1000));
props.getTask().setDataSource("production");
props.getTask().setRack(9);
assertThat(props.effectiveTaskRack()).isEqualTo(9);
assertThat(props.effectiveTaskSlot()).isEqualTo(2);
}
/**
* 构造测试用 {@link PlcProperties.PlcDataSourceProperties}。
*/
private static PlcProperties.PlcDataSourceProperties namedDs(String host, int rack, int slot, int timeoutMs) {
PlcProperties.PlcDataSourceProperties ds = new PlcProperties.PlcDataSourceProperties();
ds.setHost(host);
ds.setRack(rack);
ds.setSlot(slot);
ds.setTimeoutMs(timeoutMs);
return ds;
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 校验 {@code application-s7-et200sp.yml} 与 {@link PlcProperties} 绑定。
*/
@SpringBootTest
@TestPropertySource(properties = {
"plc.task.enabled=true",
"plc.batch.enabled=true",
"plc.agv.enabled=true"
})
@ActiveProfiles("s7-et200sp")
@DisplayName("Profile s7-et200sp")
class PlcS7Et200spProfileBindingTest {
@Autowired
private PlcProperties plcProperties;
/**
* 激活 s7-et200sp Profile 后应与 ET200SP 保守预设一致。
*/
@Test
@DisplayName("ET200SP 保守 PDU 与标识")
void et200sp_preset_bound() {
assertThat(plcProperties.getCpuSeries()).isEqualTo("s7-et200sp");
assertThat(plcProperties.getS7PduSize()).isEqualTo(480);
assertThat(plcProperties.getS7MaxAmqCaller()).isEqualTo(2);
assertThat(plcProperties.getTimeoutMs()).isEqualTo(1500);
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 校验 {@code application-s7-1200.yml} 与 {@link PlcProperties} 绑定。
*/
@SpringBootTest
@TestPropertySource(properties = {
"plc.task.enabled=true",
"plc.batch.enabled=true",
"plc.agv.enabled=true"
})
@ActiveProfiles("s7-1200")
@DisplayName("Profile s7-1200")
class PlcS71200ProfileBindingTest {
@Autowired
private PlcProperties plcProperties;
/**
* 激活 s7-1200 Profile 后 PDU、并行窗口、超时等应与 yml 预设一致。
*/
@Test
@DisplayName("PDU/AMQ 与 cpu-series 为 1200 保守预设")
void s71200_preset_bound() {
assertThat(plcProperties.getCpuSeries()).isEqualTo("s7-1200");
assertThat(plcProperties.getRack()).isZero();
assertThat(plcProperties.getSlot()).isEqualTo(1);
assertThat(plcProperties.getS7PduSize()).isEqualTo(480);
assertThat(plcProperties.getS7MaxAmqCaller()).isEqualTo(2);
assertThat(plcProperties.getS7MaxAmqCallee()).isEqualTo(4);
assertThat(plcProperties.getTimeoutMs()).isEqualTo(1500);
assertThat(plcProperties.getTcpDefaultTimeoutMs()).isEqualTo(1500);
assertThat(plcProperties.getBatch().getMaxConnections()).isEqualTo(3);
}
}
java
package org.ircm.boot.plc4x.plc.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 校验 {@code application-s7-1500.yml} 与 {@link PlcProperties} 绑定。
*/
@SpringBootTest
@TestPropertySource(properties = {
"plc.task.enabled=true",
"plc.batch.enabled=true",
"plc.agv.enabled=true"
})
@ActiveProfiles("s7-1500")
@DisplayName("Profile s7-1500")
class PlcS71500ProfileBindingTest {
@Autowired
private PlcProperties plcProperties;
/**
* 激活 s7-1500 Profile 后应与高性能预设一致。
*/
@Test
@DisplayName("PDU/AMQ 与 cpu-series 为 1500 高性能预设")
void s71500_preset_bound() {
assertThat(plcProperties.getCpuSeries()).isEqualTo("s7-1500");
assertThat(plcProperties.getRack()).isZero();
assertThat(plcProperties.getSlot()).isEqualTo(1);
assertThat(plcProperties.getS7PduSize()).isEqualTo(1024);
assertThat(plcProperties.getS7MaxAmqCaller()).isEqualTo(4);
assertThat(plcProperties.getS7MaxAmqCallee()).isEqualTo(8);
assertThat(plcProperties.getTimeoutMs()).isEqualTo(1000);
assertThat(plcProperties.getTcpDefaultTimeoutMs()).isEqualTo(1000);
assertThat(plcProperties.getBatch().getMaxConnections()).isEqualTo(4);
}
}
java
# 单元/集成测试:默认开启三泳道,避免 PlcHelperEnhanced 各 API 在未启用时抛 IllegalStateException。
# Profile 绑定测试(s7-1200 / s7-1500 / s7-et200sp)另用 @TestPropertySource 覆盖 enabled,以免主 profile 里 enabled:false 与这里冲突。
plc:
task:
enabled: true
batch:
enabled: true
agv:
enabled: true
java
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.ircm.boot</groupId>
<artifactId>plc4x</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>plc4x</name>
<description>plc4x</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- PLC4X Java API -->
<dependency>
<groupId>org.apache.plc4x</groupId>
<artifactId>plc4j-api</artifactId>
<version>0.13.1</version>
</dependency>
<!-- 西门子S7协议驱动 -->
<dependency>
<groupId>org.apache.plc4x</groupId>
<artifactId>plc4j-driver-s7</artifactId>
<version>0.13.1</version>
</dependency>
<!-- 异步支持 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<!-- Actuator 健康检查 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 配置处理器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>