(* ============================================================ *)
(* FC_ModbusWrite_PutTask —— Modbus TCP 写缓存：任务入队        *)
(* S7-1200/1500 TIA Portal SCL（导入用，GB2312/ANSI 编码）      *)
(* 导入：项目树->外部源文件->添加新外部源文件->从源生成块       *)
(* 本文件只含 FC，须先导 ModbusWriteCache_Types（UDT 与 DB）。  *)
(* 不要先手动新建同名 FC。完整说明见 UTF-8 主版本。             *)
(* v2.1：块内不再做上升沿检测，AddTrigger 必须是单周期脉冲。    *)
(* 同一扫描周期可调多次（一键下发一组参数），各次互不干扰。     *)
(* 地址用 40001 起的区号写法，否则会被当成线圈发 FC05。         *)
(* 版本：v2.1 2026-10-23                                        *)
(* ============================================================ *)

FUNCTION "FC_ModbusWrite_PutTask" : Void
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.3
   VAR_INPUT 
      SlaveID : Byte;      // 从站/单元号 1..247
      RegAddr : UDInt;     // Modbus 地址，直接给 MB_DATA_ADDR（写寄存器用 40001 起）
      RegValue : Word;     // 要写入的值
      AddTrigger : Bool;   // 提交触发。必须是单周期脉冲（v2.1 起块内不再取沿）
   END_VAR

   VAR_OUTPUT 
      AddOk : Bool;        // TRUE = 本次已受理（新增入队 或 合并进同地址任务）
      Merged : Bool;       // TRUE = 没新增条目，只把同地址任务的值更新成了后写的值
      QueueFullFlag : Bool;// TRUE = 队列满，本次没入队（AddOk 为 FALSE）
      Rejected : Bool;     // TRUE = 参数非法，拒绝入队，一条都不会发出去
      RejectCode : Int;    // 拒收原因：1=从站号非法   2=地址超出 Modbus 地址空间
      AddrWarn : Bool;     // TRUE = 地址不在保持寄存器区，可能被当成线圈发 FC05
      MultiCall : Bool;    // 诊断用：本周期不是本 FC 的第一次调用。多次调用正常，别接报警
      TrigHoldWarn : Bool; // TRUE = 触发位疑似没取沿（连续两个周期为 TRUE），回去查脉冲源
   END_VAR

   VAR_TEMP 
      i : Int;             // 去重扫描的循环变量
      iSlot : Int;         // 去重扫描的槽位游标
      iCnt : Int;          // 本次要扫的条目数
      dwAddr : UDInt;      // 加了偏移之后的实际地址
      bFound : Bool;       // 去重是否命中
      bEdge : Bool;        // 本次调用是否要受理（= AddTrigger，v2.1 起不做取沿）
      bFirstInScan : Bool; // TRUE = 本周期第一次调用本 FC
   END_VAR


BEGIN
	(* ============== 0. 输出清零（每周期第一件事） =============== *)
	#AddOk := FALSE;
	#Merged := FALSE;
	#Rejected := FALSE;
	#RejectCode := 0;
	#AddrWarn := FALSE;
	#MultiCall := FALSE;
	#TrigHoldWarn := FALSE;

	(* ================== 1. 指针自愈 + 水位刷新 ================== *)
	(* 指针理论上不会越界，但在线改值、DB 结构变更、异常恢复都可能让它跑飞。
	   环形队列越界 = 数组越界 = PLC 直接 STOP，代价太大，先钳住再说。 *)
	IF ("DB_ModbusWriteCache".QueueWrPtr < 0)
	   OR ("DB_ModbusWriteCache".QueueWrPtr > "DB_ModbusWriteCache".QUEUE_MAX_LEN - 1) THEN
	    "DB_ModbusWriteCache".QueueWrPtr := 0;
	END_IF;
	IF ("DB_ModbusWriteCache".QueueRdPtr < 0)
	   OR ("DB_ModbusWriteCache".QueueRdPtr > "DB_ModbusWriteCache".QUEUE_MAX_LEN - 1) THEN
	    "DB_ModbusWriteCache".QueueRdPtr := 0;
	END_IF;

	(* 判满直接看条目数，不看上一周期算出来的 QueueFull：
	   后者会让"能不能入队"取决于 CacheRun 和本 FC 在同周期里的先后顺序 *)
	#QueueFullFlag := ("DB_ModbusWriteCache".QueueCount >= "DB_ModbusWriteCache".QUEUE_CAPACITY);

	(* 本周期第几次调用。自 v2.1 起"调多次"是正常用法，这个计数只用来做
	   两件事：① 认出厂"本周期第一次调用"，好做跨周期比较；② MultiCall 诊断。
	   CacheRun 每周期末尾会把它清零（见 CacheRun 第 8 节）。 *)
	"DB_ModbusWriteCache".PutCallCnt := "DB_ModbusWriteCache".PutCallCnt + 1;
	IF "DB_ModbusWriteCache".PutCallCnt > 30000 THEN
	    (* CacheRun 没在跑时它会一直涨，Int 溢出会变负，先自保          *)
	    "DB_ModbusWriteCache".PutCallCnt := 1;
	END_IF;
	#bFirstInScan := ("DB_ModbusWriteCache".PutCallCnt = 1);
	#MultiCall := NOT #bFirstInScan;   (* 纯诊断：本周期已不是第一次调用 *)

	(* ========================= 2. 触发 ========================== *)
	(* v2.1：块内不做边沿检测。AddTrigger 本身就是调用方给的单周期脉冲，
	   同一周期调 N 次，N 次都按各自的值独立处理，谁也不顶掉谁。
	   （v2.0 在这里用了一份共享的 DB.LastTrigger 做取沿，多次调用必丢，
	   详见文件头 v2.1 修订说明） *)
	#bEdge := #AddTrigger;

	(* 保险：只在每周期第一次调用时，跟"上周期最后一次调用留下的值"比一次。
	   连续两个扫描周期都是 TRUE，基本可以断定触发位没取沿。
	   只提示、不拦截：万一以后各调用点改用不同的触发位，这一位可能误报。 *)
	IF #bFirstInScan AND #AddTrigger AND "DB_ModbusWriteCache".LastTrigger THEN
	    #TrigHoldWarn := TRUE;
	END_IF;
	"DB_ModbusWriteCache".LastTrigger := #AddTrigger;

	(* ===== 3. 地址合成 + 参数体检（只在触发那一拍算，不占常态扫描时间） ===== *)
	#dwAddr := #RegAddr + "DB_ModbusWriteCache".ADDR_OFFSET;

	IF #bEdge THEN
	    IF #SlaveID = 0 THEN
	        (* 0 号是广播：从站不会应答，MB_CLIENT 只能一路等到超时才报错   *)
	        #Rejected := TRUE;
	        #RejectCode := 1;
	        "DB_ModbusWriteCache".RejectCount := "DB_ModbusWriteCache".RejectCount + 1;
	    END_IF;

	    IF (#dwAddr > 65535) AND (NOT #Rejected) THEN
	        (* 超出 Modbus 地址空间，发出去从站也只会回异常码               *)
	        #Rejected := TRUE;
	        #RejectCode := 2;
	        "DB_ModbusWriteCache".RejectCount := "DB_ModbusWriteCache".RejectCount + 1;
	    END_IF;

	    IF (#dwAddr < 40001) AND (NOT #Rejected) THEN
	        (* 只警示不拦截：MB_CLIENT 按地址区号自动选功能码，<40001 会被当成
	           线圈发 FC05，写的是完全不同的对象。不拦是因为旧工程可能就这么
	           用着，拦下来等于直接改现场行为；这一位亮起来就该去核对地址写法。 *)
	        #AddrWarn := TRUE;
	        "DB_ModbusWriteCache".AddrWarnCount := "DB_ModbusWriteCache".AddrWarnCount + 1;
	    END_IF;
	END_IF;

	(* ========================= 4. 入队 ========================== *)
	IF #bEdge AND (NOT #Rejected) THEN

	    IF #QueueFullFlag THEN
	        (* 背压：队列满就拒收并且记账。宁可丢新请求，也不能覆盖还没发出去的任务 *)
	        "DB_ModbusWriteCache".DropCount := "DB_ModbusWriteCache".DropCount + 1;

	    ELSE
	        (* ---- 4.1 查重：队列里已有"同从站 + 同地址"的待发任务，就只改它的值 ---- *)
	        (* 要跳过"正在发送的那一条"：它已经被复制进 CurTask，改队列槽位改不到它，
	           必须新加一条才能保证后写的值最终生效。
	           同一周期内前面几次调用刚入队的条目也在扫描范围内 —— 本 FC 入队时
	           就同步把 QueueCount 加了 1，所以"同周期写同一个地址三次"能正确合并。 *)
	        #iCnt := "DB_ModbusWriteCache".QueueCount;
	        #iSlot := "DB_ModbusWriteCache".QueueRdPtr;
	        IF "DB_ModbusWriteCache".Busy THEN
	            #iSlot := #iSlot + 1;
	            IF #iSlot >= "DB_ModbusWriteCache".QUEUE_MAX_LEN THEN
	                #iSlot := 0;
	            END_IF;
	            #iCnt := #iCnt - 1;
	        END_IF;

	        #bFound := FALSE;
	        FOR #i := 1 TO #iCnt DO
	            IF "DB_ModbusWriteCache".TaskQueue[#iSlot].TaskValid
	               AND ("DB_ModbusWriteCache".TaskQueue[#iSlot].SlaveID = #SlaveID)
	               AND ("DB_ModbusWriteCache".TaskQueue[#iSlot].RegAddr = #dwAddr) THEN
	                "DB_ModbusWriteCache".TaskQueue[#iSlot].RegValue := #RegValue;
	                "DB_ModbusWriteCache".TaskQueue[#iSlot].RetryCnt := "DB_ModbusWriteCache".RETRY_MAX;
	                #bFound := TRUE;
	                EXIT;
	            END_IF;
	            #iSlot := #iSlot + 1;
	            IF #iSlot >= "DB_ModbusWriteCache".QUEUE_MAX_LEN THEN
	                #iSlot := 0;
	            END_IF;
	        END_FOR;

	        IF #bFound THEN
	            #Merged := TRUE;
	            #AddOk := TRUE;
	        ELSE
	            (* ---------------- 4.2 写进写指针指向的空槽位 ---------------- *)
	            "DB_ModbusWriteCache".TaskQueue["DB_ModbusWriteCache".QueueWrPtr].SlaveID := #SlaveID;
	            "DB_ModbusWriteCache".TaskQueue["DB_ModbusWriteCache".QueueWrPtr].RegAddr := #dwAddr;
	            "DB_ModbusWriteCache".TaskQueue["DB_ModbusWriteCache".QueueWrPtr].RegValue := #RegValue;
	            "DB_ModbusWriteCache".TaskQueue["DB_ModbusWriteCache".QueueWrPtr].RetryCnt := "DB_ModbusWriteCache".RETRY_MAX;
	            "DB_ModbusWriteCache".TaskQueue["DB_ModbusWriteCache".QueueWrPtr].TaskValid := TRUE;

	            (* 序号单调递增：现场看 SeqNo 在变，就知道队列真的在动，不是卡住了 *)
	            "DB_ModbusWriteCache".Nonce := "DB_ModbusWriteCache".Nonce + 1;
	            "DB_ModbusWriteCache".TaskQueue["DB_ModbusWriteCache".QueueWrPtr].SeqNo := "DB_ModbusWriteCache".Nonce;

	            (* ------ 4.3 写指针自增 + 水位即时更新（不等下一周期） ------- *)
	            "DB_ModbusWriteCache".QueueWrPtr := "DB_ModbusWriteCache".QueueWrPtr + 1;
	            IF "DB_ModbusWriteCache".QueueWrPtr >= "DB_ModbusWriteCache".QUEUE_MAX_LEN THEN
	                "DB_ModbusWriteCache".QueueWrPtr := 0;
	            END_IF;

	            "DB_ModbusWriteCache".QueueCount := "DB_ModbusWriteCache".QueueCount + 1;
	            "DB_ModbusWriteCache".QueueEmpty := FALSE;
	            "DB_ModbusWriteCache".QueueFull := ("DB_ModbusWriteCache".QueueCount >= "DB_ModbusWriteCache".QUEUE_CAPACITY);
	            #AddOk := TRUE;
	        END_IF;
	    END_IF;
	END_IF;

END_FUNCTION

(* ============================================================ *)
(* 调用示例（放在 OB1 里，同一周期调几次都行）                  *)
(*                                                              *)
(* 典型用法：HMI 上一个"下发"按钮，一次提交一整组参数。         *)
(* 关键在于先把按钮这个电平取成单周期脉冲，再把同一个脉冲接到   *)
(* 每一个调用点的 AddTrigger 上 —— 三个调用点拿到的是同一拍的   *)
(* TRUE，互不干扰，三条都能进队列。                             *)
(*                                                              *)
(* (* 1. 电平取沿，一个周期只输出一拍 TRUE *)                    *)
(* "R_TRIG_下发"(CLK := "HMI_下发按钮");                        *)
(*                                                              *)
(* (* 2. 同一周期调 3 次，写 3 个不同寄存器 *)                   *)
(* "FC_ModbusWrite_PutTask"(SlaveID    := 1,                    *)
(* RegAddr    := 40001,                                         *)
(* RegValue   := "参数_设定值",                                 *)
(* AddTrigger := "R_TRIG_下发".Q,                               *)
(* AddOk      => "入队1成功");                                  *)
(*                                                              *)
(* "FC_ModbusWrite_PutTask"(SlaveID    := 1,                    *)
(* RegAddr    := 40002,                                         *)
(* RegValue   := "参数_Kp",                                     *)
(* AddTrigger := "R_TRIG_下发".Q,                               *)
(* AddOk      => "入队2成功");                                  *)
(*                                                              *)
(* "FC_ModbusWrite_PutTask"(SlaveID    := 1,                    *)
(* RegAddr    := 40003,                                         *)
(* RegValue   := "参数_Ki",                                     *)
(* AddTrigger := "R_TRIG_下发".Q,                               *)
(* AddOk      => "入队3成功");                                  *)
(*                                                              *)
(* 用法要点：                                                   *)
(* 1. AddTrigger 必须是单周期脉冲。HMI 按钮按住的几百毫秒里，   *)
(* R_TRIG 只在第一拍输出 TRUE，所以不会重复下发；不要把按钮位   *)
(* 直接接进 AddTrigger —— 那样每个扫描周期都会入队一条，        *)
(* TrigHoldWarn 会亮。                                          *)
(* 2. 同一周期写同一个地址多次（比如先写 100 再改 200）：       *)
(* 只有最后一次生效，前面几次会被合并（Merged=TRUE），          *)
(* 不会白跑多个来回。                                           *)
(* 3. AddOk=FALSE 且 QueueFullFlag=TRUE：队列满了，这条请求被   *)
(* 丢弃并计入 DropCount。要么让从站快一点，要么把队列长度       *)
(* 调大，要么减少提交频率。                                     *)
(* 4. Rejected=TRUE：参数根本不合法，别指望它以后会发出去。     *)
(* 5. MultiCall 现在只是诊断位（本周期第 2 次调用起就亮），     *)
(* 不要接报警 —— 以前 v2.0 接上去的，这次要拆掉。               *)
(* ============================================================ *)
