PLC4X完善的管理配置项目

项目架构

按照顺序类

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>
相关推荐
小时候的阳光2 年前
分别使用netty和apache.plc4x测试读取modbus协议的设备信号
apache·netty·tcp·modbus·plc4x