feat: Recipe/Page 핸드셰이크 에코 수렴 루프 적용

PLC가 PC 에코 넘버(PC_PAGE_NO/PC_RECIPE_NO) 일치를 상시 확인하는 것으로 확정(260806)됨에 따라, PC 책임을 검증에서 '정확히 읽고 → 내부 상태 갱신 → 에코'로 전환.

- CheckRecipeNoComp/CheckPageNoComp를 수렴 루프로 재작성 (에코 후 되읽어 일치 확인, 어긋나면 재읽기, 맞는 즉시 다음 노드로)
- 에코 없이 return 하던 경로 제거 (RecipeNo=0/미등록 → 에코 안 하고 재시도)
- 범위 밖 PageNo: 3초 무한대기 → 200ms 재읽기, 에코 안 함
- Page 3회 수집(600ms)·Recipe 800ms 재폴링 제거 → 정상 흐름 지연 0
- while 탈출 후 context.Stopped 가드 추가
- ReadOutWord(eWordOutput) 헬퍼 추가 (ReadOutBit 패턴 준용)
- 시뮬레이션 가드: IsSimul이면 수렴 생략 (PLC 워드가 항상 0이라 못 빠져나감)

구버전은 #region 260806 으로 전량 주석 보존 — 원복 시 블록 교체.
분석: docs/분석/2026/08/06/
This commit is contained in:
노도연 2026-08-06 11:41:58 +09:00
parent a9d9e4f4d6
commit c7f12b0bda
4 changed files with 664 additions and 14 deletions

View File

@ -16,6 +16,13 @@ namespace ILSOS_PrismaticRD_PTOP.Device
public (int err, short data) ReadWord(eWordInput address) => base.ReadWord((ushort)address, ePLCDevice.D);
public int WriteWord(eWordOutput addrOffset, short data, ePLCDevice device = ePLCDevice.D, bool isOutput = true) => base.WriteWord((int)addrOffset, data, ePLCDevice.D, isOutput);
#region 260806 신규 — 출력 워드 되읽기 (원복: 이 region 통째로 삭제)
// 비트에는 ReadOutBit(위)이 있는데 워드에는 대응 헬퍼가 없어 base.ReadWord(..., isInput:false)를
// 직접 불러야 했다(WriteWordToBit 참고). 에코 워드(PC_PAGE_NO/PC_RECIPE_NO)가 실제로
// 반영됐는지 확인하는 용도.
public (int err, short data) ReadOutWord(eWordOutput address) => base.ReadWord((ushort)address, ePLCDevice.D, isInput: false);
#endregion
public (int err, int data) ReadDWord(eWordInput address) => base.ReadDWord((ushort)address, ePLCDevice.D);
public int WriteDWord(eWordOutput addrOffset, int data, ePLCDevice device = ePLCDevice.D, bool isOutput = true) => base.WriteDWord((int)addrOffset, data, ePLCDevice.D, isOutput);
public (int err, short[] datas) ReadWords(eWordInput startAddress, eWordInput endAddress) => base.ReadWordBlock((int)startAddress, endAddress - startAddress + 1, ePLCDevice.D);

View File

@ -7,6 +7,8 @@ using AuroraLaserScanner;
using AuroraPLC;
using CoreUnit.Control.UserControl.Canvas;
using CoreUnit.Scanner.UserControl.ScannerCanvas;
// 260806: PLC 반환 err 판정은 Hermes.E1.OK 를 완전 한정명으로 쓴다.
// using Hermes; 를 새로 넣으면 이 파일이 이미 여는 다수 네임스페이스와 이름이 겹칠 수 있어 피했다.
using ILSOS_PrismaticRD_PTOP.Device;
using System;
using System.Collections.Generic;
@ -122,6 +124,11 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
//
const int PLC_TIMEOUT = 60; // sec, Timeout은 시퀀스 정지가 아니라 타임아웃 시간마다 로그만 남기도록 한다
#region 260806 신규 — 에코 수렴 루프 상수 (원복: 이 region 통째로 삭제)
const int ECHO_RETRY_MS = 200; // 수렴 재시도 간격. 정상 흐름은 1회 순회로 끝나므로 보정 경로에서만 쓰인다
const int ECHO_TOAST_EVERY = 10; // 토스트 폭주 방지 — 첫 회차 + 이후 N회마다만 표시
#endregion
private eListNo _WeldingScannerListNo;
private List<IDrawObject> _SelectedObjectList;
@ -133,7 +140,10 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
private short plcPageNo = 0;// [0] 페이지 1 =음극 A [1]페이지 2= 양극 P(C)
private List<short> plcPageNoList = new List<short>();
#region 260806 구버전 보존 — Page 3회 수집용 필드 (원복: 아래 주석을 푼다)
// 수렴 루프로 대체돼 참조가 없어졌다. 삭제하지 않고 남긴다.
//private List<short> plcPageNoList = new List<short>();
#endregion
private short plcRecipeNo = 0; // 260618 PLC Recipe No (11/21/22/31/32)
@ -340,6 +350,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
private int _RingBeamPower;
private int _CenterRingRatio;
#region 260806 구버전 보존 — CheckRecipeNoComp (원복: 아래 블록 주석을 풀고 뒤의 260806 신규 region을 제거)
/*
// CheckRecipeNoComp: 단일 트리거(PAGE_CHANGE_REQ 0x08) 통합 — Page보다 먼저 Recipe를 확정/전환.
// PLC_RECIPE_NO 1회 읽기 → 0/미등록/현재활성과 동일이면 전환·워드 재기록 생략(시퀀스 정지 없음).
// 실제로 다를 때만 ActivateRecipe + UI 스레드 동기 reload(Model.Layers 갱신 보장) + PC_RECIPE_NO 에코.
@ -431,6 +443,131 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
return SequenceAction.Ok();
}
*/
#endregion
#region 260806 신규 — CheckRecipeNoComp 에코 수렴형 (원복: 이 region 제거 후 위 구버전 주석 해제)
// 방침(2026-08-06 PLC 확정): PLC는 PC가 에코한 PC_RECIPE_NO 가 자기 값과 같아지는지를 상시 확인한다.
// 따라서 PC의 책임은 "정확히 읽고 → 레시피를 실제로 활성화하고 → 에코" 뿐이다.
// 어긋났을 때만 다시 읽어 고쳐 쓰고, 맞는 즉시 다음 노드로 넘어간다(대기는 다음 노드가 한다).
//
// 노드를 나갈 때 보장: PC_RECIPE_NO == PLC_RECIPE_NO 그리고 활성 레시피 Id == 그 번호
// ★ 워드만 맞추면 안 된다. 활성 레시피를 같이 갱신하지 않으면 PLC는 일치로 보고 통과시키는데
// PC는 옛 레시피로 용접한다. 이 경우는 PLC 게이트로도 막히지 않는다(에코가 맞으니 트리거가 나온다).
private SequenceActionResult CheckRecipeNoComp(SequenceContext context, string id, SequenceActionResult param)
{
Log.I("[CheckRecipeNoComp] PLC_RECIPE_NO 수렴 시작");
// 시뮬레이션: PLC 워드가 항상 0이라 아래 수렴 루프를 빠져나갈 수 없다.
// → 종전 동작(현 활성 레시피 유지 + 에코)으로 통과시킨다.
if (_PLC.IsSimul)
{
var simRecipe = _RecipeManager.ActivatedRecipe();
plcRecipeNo = (short)(simRecipe == null ? 0 : simRecipe.Id);
_PLC.WriteWord(eWordOutput.PC_RECIPE_NO, plcRecipeNo);
Log.I($"[CheckRecipeNoComp] 시뮬레이션 → 수렴 생략, 현 활성 레시피({plcRecipeNo}) 에코 → CheckPageNoComp 이동");
return SequenceAction.Ok();
}
int retry = 0;
while (!context.Stopped)
{
if (retry > 0) Thread.Sleep(ECHO_RETRY_MS);
retry++;
// ── 1. PLC가 준 Recipe No 읽기 ──
var read = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO);
if (read.err != Hermes.E1.OK)
{
Log.E($"[CheckRecipeNoComp] PLC_RECIPE_NO 읽기 실패 (err:{read.err}) — 재시도 {retry}회차");
continue;
}
short readRecipeNo = read.data;
// 0은 정상 상태가 아니다. PLC는 워드를 항상 써놓는다(260804 확인).
// 미연결·미활성이면 ReadWord가 (NOT_READY/NOT_CONNECTED, 0)을 돌려주므로 여기서도 걸러진다.
if (readRecipeNo == 0)
{
if (retry == 1 || retry % ECHO_TOAST_EVERY == 0)
MessageUtil.ShowErrorToast("PLC Recipe No = 0\n모니터링 확인해주세요.");
Log.E($"[CheckRecipeNoComp] PLC_RECIPE_NO=0 (이상값) — 에코하지 않고 재시도 {retry}회차");
continue;
}
// 등록 레시피 조회(.Id 1:1, 하드코딩 금지). 미등록이면 에코하지 않는다 —
// PLC가 넘어가지 않으므로 잘못된 레시피로 용접이 나갈 수 없다.
var recipe = _RecipeManager.Recipes().FirstOrDefault(x => x.Id == readRecipeNo);
if (recipe == null)
{
if (retry == 1 || retry % ECHO_TOAST_EVERY == 0)
MessageUtil.ShowErrorToast($"미등록 레시피: {readRecipeNo}\n레시피 확인해주세요.");
Log.E($"[CheckRecipeNoComp] {readRecipeNo}번 레시피 미등록 — 에코하지 않고 재시도 {retry}회차");
continue;
}
// ── 2. 내부 상태 갱신: 실제 레시피 활성화 (에코와 같은 세트) ──
var preActivated = _RecipeManager.ActivatedRecipe();
if (preActivated == null || preActivated.Id != recipe.Id)
{
_RecipeManager.ActivateRecipe(recipe.Id, "PLC Recipe Change");
Log.I($"[CheckRecipeNoComp] ActivateRecipe ({(preActivated == null ? -1 : preActivated.Id)} -> {recipe.Id})");
if (!WaitRecipeActivated(context, recipe.Id))
{
if (context.Stopped) return null;
Log.W($"[CheckRecipeNoComp] ActivateRecipe({recipe.Id}) 반영 대기 TIMEOUT — 에코하지 않고 재시도 {retry}회차");
continue;
}
// ★ Model.Layers를 새 레시피로 동기 갱신 (UI 스레드).
// ActivateRecipe의 이벤트 체인(ObserveOnDispatcher 2단)은 비동기라
// Page 검증 전 Layers 갱신을 보장 못 한다.
Application.Current.Dispatcher.Invoke(() => RecipeHelper.OnActiveReloaded());
Log.I("[CheckRecipeNoComp] Model.Layers 동기 reload 완료 (UI thread)");
}
// ── 3. 에코 ──
int werr = _PLC.WriteWord(eWordOutput.PC_RECIPE_NO, readRecipeNo);
if (werr != Hermes.E1.OK)
{
Log.E($"[CheckRecipeNoComp] PC_RECIPE_NO 쓰기 실패 (err:{werr}) — 재시도 {retry}회차");
continue;
}
// ── 4. 되읽어 수렴 확인 (내가 쓴 값이 반영됐는가 + PLC가 여전히 같은 값인가) ──
short echoed = _PLC.ReadOutWord(eWordOutput.PC_RECIPE_NO).data;
short current = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data;
if (echoed == readRecipeNo && current == readRecipeNo)
{
plcRecipeNo = readRecipeNo;
Log.I($"---> [CheckRecipeNoComp] 수렴 완료 ({retry}회차) - PC_RECIPE_NO: [{plcRecipeNo}] → CheckPageNoComp 이동");
break;
}
Log.W($"[CheckRecipeNoComp] 미수렴 - 읽음:[{readRecipeNo}] 에코:[{echoed}] 현재PLC:[{current}] — 재시도 {retry}회차");
}
if (context.Stopped) return null;
return SequenceAction.Ok();
}
// ActivatedRecipe().Id 가 targetId 로 바뀔 때까지 대기. 타임아웃이면 false를 돌려주고 호출부가 재시도한다.
// (구버전 :408-419는 타임아웃 시 시작시각만 리셋해 빠져나가지 못했다)
private bool WaitRecipeActivated(SequenceContext context, int targetId)
{
var start = DateTime.Now;
while (!context.Stopped)
{
var cur = _RecipeManager.ActivatedRecipe();
if (cur != null && cur.Id == targetId) return true;
if (DateTime.Now.Subtract(start).TotalSeconds > PLC_TIMEOUT) return false;
Thread.Sleep(10);
}
return false;
}
#endregion
// 페이지 체크 - 페이지만 감시 AutoRunStatus는 실제 상위에서 동작 체크하고 시퀸스 동작한다.
private SequenceActionResult CheckPageNo(SequenceContext context, string id, SequenceActionResult param)
@ -487,12 +624,16 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
}
#region 260806 구버전 보존 — CheckPageNoComp (원복: 아래 블록 주석을 풀고 뒤의 260806 신규 region을 제거)
/*
private SequenceActionResult CheckPageNoComp(SequenceContext context, string id, SequenceActionResult param)
{
Log.I($"[CheckPageNoComp] PLC 페이지 번호 읽기 시작 (3회 연속 검증)");
Status.SetWorkingPosition(eWorkingPosition.LOADING_CELL);
int checknum = 0;
int invalidPageWait = 0; // 260805: 범위 밖 PageNo로 대기한 회차 (중단하지 않고 대기)
const int TOAST_EVERY = 10; // 토스트 폭주 방지 — 첫 회차 + 이후 N회마다만 표시
plcPageNoList = new List<short>();
@ -531,18 +672,26 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
int recipePageCount = Application.Current.Dispatcher.Invoke(() => _ScannerCanvasUserCtrl.Model.Layers.Count());
if (plcPageNo > recipePageCount || plcPageNo < 1)
{
Log.E($"[CheckPageNoComp] Invalid PageNo received: {plcPageNo} (활성 Recipe 페이지 수: {recipePageCount})");
// 260805 가드: 종전에는 여기서 return null 이었다. Aurora에서 Action이 null을 돌려주면
// 시퀀스가 중단되므로(sequence-builder 규칙: "null 중단"), 일시적인 오독 한 번에
// AutoRun이 죽고 작업자가 재시작해야 했다.
// → 중단하지 않고 이 노드에서 대기하며 재수집한다. 작업자가 토스트를 보고
// 모니터링에서 값을 확인·조치하면 다음 회차에 정상값을 읽어 그대로 이어간다.
// ※ recipePageCount는 매 회차 활성 Recipe의 Layers 수로 다시 구한다.
// Recipe마다 페이지 수가 다르고, 레시피 반영이 늦으면 이 값도 늦게 맞으므로
// 재시도 자체가 그 지연을 흡수한다.
// ※ 빠져나가는 경로는 while 조건의 context.Stopped — Stop 버튼은 정상 동작한다.
invalidPageWait++;
Log.E($"[CheckPageNoComp] Invalid PageNo received: {plcPageNo} " +
$"(활성 Recipe 페이지 수: {recipePageCount}) — 대기 {invalidPageWait}회차, 시퀀스 중단하지 않음");
// 디버그 끝내고 삭제
//MessageUtil.ShowErrorToast($"ReadWord -> {plcPageNo}");
MessageUtil.ShowErrorToast($"ReadWord -> {plcPageNo}");
if (invalidPageWait == 1 || invalidPageWait % TOAST_EVERY == 0)
MessageUtil.ShowErrorToast($"PLC Page No 이상: {plcPageNo}\n모니터링 확인해주세요.");
plcPageNoList.Clear();
checknum = 0;
Thread.Sleep(3000);
// 나중에 알람 처리해.
// 에러 발생 시 시퀀스 중단 또는 기본값 처리 (현장 상황에 따라 선택)
//AlarmManager.Alarm(AlarmCode.ALARM_SEQUENCE_TeachingRun_InitVariableError);
return null;
continue;
}
else
{
@ -580,12 +729,106 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
Application.Current.AuroraEnv().EventPublisher.Publish(new EventMessage("", CommonEventIds.EVENT_AUTO_RUN_UPDATE_PAGENUM, (int)_CurrentPageNo));
}
Thread.Sleep(5); // 추후 제거
Thread.Sleep(5); // 추후 제거
Log.I($"[CheckPageNoComp] 페이지 확정 - PC_PAGE_NO: [{plcPageNo}] 에코 완료 → WeldingList 업로드 이동");
return SequenceAction.Ok();
}
*/
#endregion
#region 260806 신규 — CheckPageNoComp 에코 수렴형 (원복: 이 region 제거 후 위 구버전 주석 해제)
// 노드를 나갈 때 보장: PC_PAGE_NO == PLC_PAGE_NO 그리고 _CurrentPageNo == PC_PAGE_NO - 1
// ★ 워드만 맞추고 _CurrentPageNo를 갱신하지 않으면 PLC는 일치로 보고 통과시키는데
// PC는 옛 페이지 레이어로 용접한다. 반드시 "재읽기 → _CurrentPageNo 갱신 → 에코"를 한 세트로 처리한다.
//
// 구버전과의 차이:
// - 3회 수집(Sleep 200×3) 제거 → 정상 흐름 지연 0. 값 안정성은 되읽기 일치로 확인한다.
// (구버전 로그는 "3회 연속 검증"이라 했으나 3개 값을 비교하는 코드가 없었다)
// - 범위 밖 PageNo: 종전 Sleep(3000) 무한 대기 → 200ms 재읽기. 에코하지 않으므로
// PLC가 넘어가지 않아 잘못된 페이지로 용접이 나갈 수 없다.
// - while 탈출 후 context.Stopped 가드 추가 (종전에는 정지 중에도 미검증 값을 에코했다)
private SequenceActionResult CheckPageNoComp(SequenceContext context, string id, SequenceActionResult param)
{
Log.I("[CheckPageNoComp] PLC_PAGE_NO 수렴 시작");
Status.SetWorkingPosition(eWorkingPosition.LOADING_CELL);
int retry = 0;
while (!context.Stopped)
{
if (retry > 0) Thread.Sleep(ECHO_RETRY_MS);
retry++;
// ── 1. PLC가 준 Page No 읽기 ──
var read = _PLC.ReadWord(eWordInput.PLC_PAGE_NO);
if (read.err != Hermes.E1.OK)
{
Log.E($"[CheckPageNoComp] PLC_PAGE_NO 읽기 실패 (err:{read.err}) — 재시도 {retry}회차");
continue;
}
short p = read.data;
// 상한은 활성 Recipe의 페이지 수(레이어 수)로 가변 검증한다 (Recipe별 2~5페이지, 하드코딩 금지).
// Model.Layers 접근은 UI 스레드로 직렬화 (레시피 reload와의 race 방지).
int recipePageCount = Application.Current.Dispatcher.Invoke(() => _ScannerCanvasUserCtrl.Model.Layers.Count());
if (p < 1 || p > recipePageCount)
{
if (retry == 1 || retry % ECHO_TOAST_EVERY == 0)
MessageUtil.ShowErrorToast($"PLC Page No 이상: {p}\n모니터링 확인해주세요.");
Log.E($"[CheckPageNoComp] Invalid PageNo: {p} (활성 Recipe 페이지 수: {recipePageCount}) " +
$"— 에코하지 않고 재시도 {retry}회차");
continue;
}
// ── 2. 내부 상태 갱신 (에코와 같은 세트) ──
_CurrentPageNo = p - 1; // 1-based to 0-based // page index
// ── 3. 에코 ──
int werr = _PLC.WriteWord(eWordOutput.PC_PAGE_NO, p);
if (werr != Hermes.E1.OK)
{
Log.E($"[CheckPageNoComp] PC_PAGE_NO 쓰기 실패 (err:{werr}) — 재시도 {retry}회차");
continue;
}
// ── 4. 되읽어 수렴 확인 ──
short echoed = _PLC.ReadOutWord(eWordOutput.PC_PAGE_NO).data;
short current = _PLC.ReadWord(eWordInput.PLC_PAGE_NO).data;
if (echoed == p && current == p)
{
plcPageNo = p;
Log.I($"---> [CheckPageNoComp] 수렴 완료 ({retry}회차) - PC_PAGE_NO: [{plcPageNo}], PC_Index: [{_CurrentPageNo}]");
break;
}
Log.W($"[CheckPageNoComp] 미수렴 - 읽음:[{p}] 에코:[{echoed}] 현재PLC:[{current}] — 재시도 {retry}회차");
}
if (context.Stopped) return null;
// ── 수렴 후 1회만 수행 ──
string cellId = _PLC.CellId();
string lotId = _PLC.LotId();
Status.GetProcessingWeldingData().CellId = cellId;
Status.GetProcessingWeldingData().LotId = lotId;
Log.I($"[CheckPageNoComp] CellId: [{cellId}], LotId: [{lotId}]");
// PLC는 이 비트를 보지 않는다(260806 확인 — 게이트는 워드다). 기존 배선 호환을 위해 ON은 유지한다.
_PLC.WriteBit(eBitOutput.PC_PAGE_CHANGE_COMPLETE, true);
// UI 페이지 업데이트 알림
Application.Current.AuroraEnv().EventPublisher.Publish(
new EventMessage("", CommonEventIds.EVENT_AUTO_RUN_UPDATE_PAGENUM, (int)_CurrentPageNo));
Log.I($"[CheckPageNoComp] 페이지 확정 - PC_PAGE_NO: [{plcPageNo}] 에코 완료 → WeldingList 업로드 이동");
return SequenceAction.Ok();
}
#endregion
private bool LaserReadyResponse()
{
Log.I($"[LaserReadyResponse] 레이저 Ready 응답 (현재 no-op)");
@ -1208,8 +1451,38 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
// 260512 워드
// 260621: 에코 워드(PC_PAGE_NO/PC_RECIPE_NO)는 정지 시 0으로 지우지 않음 — PLC가 정합 확인에 쓰는
// 마지막 핸드셰이크 값을 보존(다음 사이클에 어차피 재에코). 기존 0 초기화 2줄 제거.
Log.I("[OnSeqActionStop] PLC IO Output Word Init");
// 마지막 핸드셰이크 값을 보존(다음 사이클에 어차피 재에코).
#region 260805 정지 모드만 로그로 남긴다 (워드 0 초기화는 하지 않음)
// 0 초기화를 넣었다가 롤백했다. 지울 필요가 없는 이유 —
// ① 재진입 시 CheckRecipeNoComp / CheckPageNoComp 가 어차피 워드를 재에코한다.
// ② PLC가 보는 핸드셰이크 게이트는 워드가 아니라 PC_PAGE_CHANGE_COMPLETE 비트이고,
// 그 비트는 바로 위에서 이미 false로 내렸다 → 잔재 워드를 유효값으로 읽을 경로가 없다.
// ③ bit15 Resume(Manual 수동 출사 후 이어하기)은 직전 Recipe/Page 에코가 남아 있어야 한다.
// 아래 분기는 진단용이다. 워드를 쓰지 않으므로 PLC 동작에는 영향이 없다.
// 0 초기화가 필요하다고 판단되면 PLC 담당 확인 후 !ResumeMode 쪽에만 WriteWord를 넣는다.
// 로그 값은 캐시된 마지막 핸드셰이크 값이다 — 정지(특히 ForceError) 시점에 PLC를 다시 읽지 않는다.
#region 260806 정정 — 위 ②는 사실과 반대다 (원복: 이 region 통째로 삭제. 위 원문은 그대로 보존돼 있다)
// 2026-08-06 PLC측 시퀀스 확정 결과:
// · PLC가 보는 핸드셰이크 게이트는 **워드**(PC_PAGE_NO / PC_RECIPE_NO)다.
// PC 에코 넘버가 자기 값과 같아지는지를 상시 확인하고, 같으면 다음 스텝으로 진행한다.
// · PC_PAGE_CHANGE_COMPLETE(M70008)는 PLC가 **보지 않는다**. 따라서 위 ②의
// "비트를 내렸으니 잔재 워드를 읽을 경로가 없다"는 논리는 성립하지 않는다.
// 그럼에도 동작은 바꾸지 않는다 — 워드 0 초기화는 SeqStatusMonitor.UpdatePLC()가
// Auto OFF 시 이미 담당하고 있고(M70515 ON 구간은 예외로 보존), 여기서 중복으로 쓰면
// bit15 Resume이 의존하는 보존값을 깨뜨린다(위 ③).
// 분석: docs/분석/2026/08/06/RecipePage_에코보장_방침_분석.md
#endregion
if (!ResumeMode)
{
Log.I($"[OnSeqActionStop] 일반 정지 (ResumeMode=false) → 에코 워드 보존, 0 초기화 안 함 " +
$"(마지막 핸드셰이크 PageNo:{plcPageNo}, RecipeNo:{plcRecipeNo})");
}
else
{
Log.I($"[OnSeqActionStop] Resume 정지 (ResumeMode=true, bit15 Manual 전환) → 에코 워드 반드시 보존 " +
$"(마지막 핸드셰이크 PageNo:{plcPageNo}, RecipeNo:{plcRecipeNo})");
}
#endregion

View File

@ -0,0 +1,183 @@
# Recipe/Page 핸드셰이크 — "에코 보장" 방침 전환 분석
- 작성일: 2026-08-06
- 대상 브랜치: `feature/ubisam-pptx-integration`
- 분류: **분석 전용 — 소스 미수정**
- 발단: PLC측 시퀀스 확정 완료. **PLC는 Page/Recipe 넘버 일치만 보고 넘어간다**(2026-08-06 확인)
- 관련: [AutoRun 재기동 워드 미반영 원인분석](../04/AutoRun재기동_Recipe_Page워드_미반영_원인분석.md)
- **후속: [에코 수렴 루프 설계안](./RecipePage_에코수렴루프_설계.md)** — 2026-08-06 추가 확인
(**PLC는 일치를 상시 확인한다**. 30초는 확인 간격이 아니라 한 번의 확인 창이며 반복된다)으로
§5 조치표 일부가 정정됐습니다. **설계안을 우선 참조하세요.**
---
## 0. 결론
**PC측 책임이 "검증"에서 "에코 보장"으로 바뀝니다.**
PLC가 넘버 일치를 판정하므로, PC가 자체 검증으로 **대기·중단하는 코드는 전부 교착 요인**입니다.
PC가 멈추면 에코가 안 나가고, 에코가 안 나가면 PLC도 멈춰 **양쪽이 서로 기다립니다.**
현재 코드에는 **에코를 하지 않고 빠져나가거나 무한 대기하는 경로가 4건** 있습니다(§2).
전부 "PC가 신중하게 확인하려고" 넣은 것인데, 새 방침에서는 정확히 반대로 작동합니다.
> **단, "잘 써주기"의 전제는 "제대로 읽기"입니다.** 틀린 값을 에코하면 PLC가 불일치로 안 넘어가
> 결국 멈춥니다. 따라서 [08/04 §4](../04/AutoRun재기동_Recipe_Page워드_미반영_원인분석.md)의
> **상승엣지 전환(조치 A)은 이 방침에서도 그대로 유효**하며, 오히려 전제 조건이 됩니다. → §4
---
## 1. 확정된 프로토콜 (2026-08-06)
| 주체 | 동작 |
|---|---|
| PLC | Recipe·Page 워드를 **동시에** 쓴다 → `M70508`(`PLC_PAGE_CHANGE_REQ`, bit 0x08) ON |
| PLC | `PC_PAGE_NO`·`PC_RECIPE_NO`가 자기가 쓴 값과 **같아지면 다음 스텝으로 진행** |
| PC | bit8 감지 → Recipe 읽기·활성화 → Page 읽기 → 에코 |
**게이트는 워드다.** `PC_PAGE_CHANGE_COMPLETE`(M70008, `eBitOutput` 0x08)는 PLC가 보지 않는다.
### 코드 주석이 이와 반대로 적혀 있다
`SeqAutoRunPTOP.cs:1225` (`OnSeqActionStop`), 에코 워드 0 초기화를 **롤백한 근거**:
```
② PLC가 보는 핸드셰이크 게이트는 워드가 아니라 PC_PAGE_CHANGE_COMPLETE 비트이고,
그 비트는 바로 위에서 이미 false로 내렸다 → 잔재 워드를 유효값으로 읽을 경로가 없다.
```
→ **이 전제는 틀렸습니다.** 롤백 판단의 근거가 무효이므로 재검토 대상입니다.
다만 워드 0 초기화는 `SeqStatusMonitor.cs:104-109`가 Auto OFF 시 이미 수행하므로
**중복 구현 상태**입니다(`OnSeqActionStop`은 로그만, `:1231-1240`).
### 안전 측면은 PLC가 커버한다 (검증됨)
용접 실행은 `PLC_WELD_TRIGGER_REQ`(M70516) ON 대기가 **별도 게이트**입니다
(`SeqAutoRunPTOP.cs:196`, `:705`). PC가 페이지를 오독하면 → 에코 불일치 → PLC가 트리거를
주지 않음 → **용접이 나가지 않습니다.** 오독이 잘못된 용접으로 이어지는 경로는 없습니다.
> 남는 비용은 **안전이 아니라 정지 시간**입니다. 그래서 이 문서의 초점은 "멈춤 제거"입니다.
---
## 2. ★ 핑퐁을 끊는 지점 전수 (에코 누락·무한 대기)
### ① `CheckRecipeNoComp` — 에코 없이 return 하는 경로 2건
> **정정(2026-08-06):** PLC는 일치를 **상시 확인**하므로 아래 "대기"는 영구 교착이 아닙니다.
> PC가 맞추는 **즉시** 진행됩니다. → [수렴 루프 설계안 §0](./RecipePage_에코수렴루프_설계.md)
| 위치 | 조건 | `PC_RECIPE_NO` | PLC 결과 |
|---|---|---|---|
| `:353-357` | `plcRecipeNo == 0` | **미기록** (직전 값 잔존) | 불일치 → 재시도 대기 (PC는 복구 시도 안 함) |
| `:361-365` | 미등록 번호 (`Log.W` 한 줄) | **미기록** | 불일치 → 재시도 대기 (PC는 복구 시도 안 함) |
| `:395-398` | 이미 활성 + 4회 폴링 동안 불변 | `preActivated.Id` | PLC 요구값과 다르면 대기 |
| `:429` | 실제 전환 완료 | `plcRecipeNo` | 통과 |
`ReadWord`는 `(int err, short data)`인데(`PLCController.Logic.cs:16`) `:349`가 `.data`만 씁니다.
PLC 미연결·미활성이면 **에러 없이 `0`을 반환**하므로(`PLCBase.APIs.cs:105`, `:109`)
통신 이상이 자동으로 `:353` 경로에 빠집니다. 화면 표시 없음, 로그 1줄뿐.
### ② `CheckPageNoComp:534-556` — 범위 밖이면 3초 주기 무한 대기
260805에 `return null`(시퀀스 중단)에서 대기 루프로 바꾼 지점입니다. 중단은 아니지만
**에코를 하지 않은 채 머무릅니다.** PLC도 대기 중이므로 **완전 교착**입니다.
탈출구는 `while (!context.Stopped)`(`:501`) 하나 — 작업자가 정지를 눌러야 합니다.
### ③ `CheckRecipeNoComp:408-419` — ActivateRecipe 반영 대기가 사실상 무한
타임아웃 분기(`:413-417`)가 `Log.W` 후 **`actStart`를 리셋**합니다. 빠져나가지 않습니다.
### ④ `CheckPageNoComp:501` while 탈출 후 `Stopped` 가드 없음
형제 노드는 둘 다 있습니다(`CheckPageNo:483`, `CheckRecipeNoComp:420`).
정지로 루프를 빠져나와도 `:573`이 실행돼 **미검증 값을 에코**합니다.
워드가 게이트이므로 이건 로그 오염이 아니라 **정지한 PC가 PLC에 GO를 쏘는 것**입니다.
---
## 3. 방침에 비추어 과잉인 코드
| 위치 | 현재 | 새 방침에서의 평가 |
|---|---|---|
| `CheckPageNoComp:499-566` | Page 3회 수집 (`Sleep(200)` × 3) | **과잉.** 최소 600ms 지연만 발생. 값 검증은 PLC 몫 |
| `:492`, `:527` 로그 | "3회 연속 검증" | **거짓 로그.** 3개 값을 비교하는 코드가 없다 (`:561`은 출력뿐, 판정은 마지막 값 `plcPageNo` 하나) |
| `CheckRecipeNoComp:374-399` | 값 변화를 최대 4회(약 800ms) 기다림 | **과잉 + 유해.** 잘못된 트리거 시점의 우회책. [08/04 조치 B](../04/AutoRun재기동_Recipe_Page워드_미반영_원인분석.md)와 동일 판정 |
| `CheckPageNoComp:581-584` | 에코 후 재읽기·비교 → ACK 비트 | **무효.** PLC가 M70008을 안 본다. 불일치여도 `Ok()` 반환(`:597`)이라 PC 흐름에도 영향 없음. **PC 로그용으로만 의미** |
> `:374-399`의 `count++ < 3`은 실제로 **4회 순회**합니다(count 0·1·2·3). 주석("3번 체크하자")과 불일치.
---
## 4. "잘 써주기"의 전제 — 읽기 시점
새 방침은 PC측 검증을 덜어내지만, **읽는 값 자체가 틀리면 에코도 틀리고 결국 멈춥니다.**
`CheckPageNo:457`은 `PLC_PAGE_CHANGE_REQ`를 **레벨(ON)로 판정**합니다. 상승엣지가 아닙니다.
PLC 프로토콜이 `워드 write → REQ raise` 순서이므로([08/04 §4](../04/AutoRun재기동_Recipe_Page워드_미반영_원인분석.md)),
**상승엣지 직후에 읽으면 워드는 항상 새 값**입니다. 반면 레벨 판정은 이미 ON인 잔재 신호에
붙어 **갱신 전 값**을 읽습니다.
| 트리거 판정 | 워드 정합성 | 에코 결과 |
|---|---|---|
| 레벨 (현재) | 보장 없음 | 옛 값 에코 → PLC 불일치 → **멈춤** |
| 상승엣지 | 보장됨 | 새 값 에코 → **핑퐁 성립** |
→ **엣지 전환은 검증 강화가 아니라 "정확히 1회 읽고 즉시 에코"를 가능하게 하는 선행 조치**입니다.
엣지로 바꾸면 §3의 3회 수집·800ms 재폴링을 **둘 다 안전하게 제거**할 수 있습니다.
---
## 5. 조치 후보 (미승인 — 승인 시 별도 작업지시서)
> ⚠ **이 표는 [수렴 루프 설계안 §6](./RecipePage_에코수렴루프_설계.md)에서 갱신됐습니다.**
> P2·P3 정정, P4·P5 제거, P1 우선순위 하향. **설계안 §6을 정본으로 봅니다.**
원칙: **PC는 자기가 읽은 값·실제 상태를 지체 없이 에코한다. 일치 판정은 PLC에 맡긴다.**
| # | 대상 | 내용 | 근거 |
|---|---|---|---|
| **P1** | `CheckPageNo:457` | 레벨 → **상승엣지** 판정 | §4 (= 08/04 조치 A) |
| **P2** | `CheckRecipeNoComp:353-365` | 에코 없이 return 하는 2경로에 **에코 추가**(현재 활성 레시피 번호) + 알람/토스트 | §2-① |
| **P3** | `CheckPageNoComp:534-556` | 무한 대기 제거 → **읽은 값 그대로 에코**하고 진행. Layer 선택은 기존 폴백(`:626-628`) 사용 | §2-② |
| P4 | `CheckPageNoComp:499-566` | 3회 수집 → **1회 읽기**로 축소 | §3 (P1 선행) |
| P5 | `CheckRecipeNoComp:374-399` | 800ms 재폴링 **제거** | §3 (P1 선행) |
| P6 | `:349`, `:510` | `err` 확인 — **중단이 아니라 재읽기** 목적. `err != OK`면 즉시 재시도 | §2-① |
| P7 | `CheckPageNoComp:501` 직후 | `if (context.Stopped) return null;` 추가 | §2-④ |
| P8 | `CheckRecipeNoComp:413-417` | 타임아웃 시 리셋 대신 **탈출 + 알람** | §2-③ |
| P9 | `:492`, `:527` | 로그 문구 정정 ("3회 연속 검증" → 실제 동작) | §3 · `rules.md` #1 |
| P10 | `:1219-1241` | 주석 ②의 잘못된 전제 정정 + `SeqStatusMonitor`와 중복 정리 | §1 |
**P1~P3이 "멈춤 제거"의 직접 조치입니다.** P4·P5는 P1 적용 후에만 안전합니다.
### 주의 — `ResumeMode`(bit15) 예외
`OnSeqActionStop:1227`·`:1236-1240`, `SeqStatusMonitor:104`는 **M70515 ON 구간에서 에코 워드를
보존**합니다. 가접 Resume이 직전 Recipe/Page 에코에 의존하기 때문입니다.
P2·P10 적용 시 **이 예외를 반드시 유지**해야 합니다.
---
## 6. PLC측 질문 정리 (08/04 ★ 항목 갱신)
| # | 질문 | 상태 |
|---|---|---|
| ~~★3~~ | 워드를 쓴 뒤 REQ를 올리는가 | **해소(08/04)** — `write → raise` 확정 |
| ~~★4~~ | `PC_PAGE_CHANGE_COMPLETE` 하강을 REQ 재발행 조건으로 쓰는가 | **해소(08/06)** — PLC는 넘버만 본다 |
| ★1 | **Manual 전환 / PC 정지 시 M70508을 PLC가 OFF 시키는가** | **미해소** — P1(엣지 전환) 필요성의 근거 |
| ★2 | M70515(Weld Ready REQ)를 PLC가 언제 OFF 시키는가 | 미해소 — [bit15 Resume 교착](../04/AutoRun재기동_Recipe_Page워드_미반영_원인분석.md) |
| ★5 | `PC_WELD_READY_ACK`(M70015)를 레벨로 보는가 펄스로 보는가 | 미해소 |
> ★1이 "OFF 시킨다"로 확인되면 P1 없이도 대부분 해소되지만, **엣지 판정이 더 강한 보장**이라
> 어느 쪽이든 P1을 권장합니다.
---
## 7. 부수 확인 사항
- `CheckPageNoComp:521-525` — CellId/LotId를 매 회차 읽어 `Status`에 즉시 대입. 읽기 실패 시
`string.Empty`가 그대로 들어갑니다(`PLCController.Address.cs:227`, `:239`). 빈 값 체크 없음.
- `CheckRecipeNoComp:376`의 재폴링이 `plcRecipeNo` 필드를 덮어써, `ScoutSaveTag:886` 파일명에
0이 박힐 수 있습니다. (경미 — P5로 함께 해소)
- 에코 워드 0 초기화 주체가 `SeqStatusMonitor:106-107` 한 곳입니다. `OnSeqActionStop`은 로그만
남깁니다(`:1231-1240`). 두 파일의 주석이 서로 다른 전제를 적고 있어 정리가 필요합니다.

View File

@ -0,0 +1,187 @@
# Recipe/Page 에코 수렴 루프 — 설계안
- 작성일: 2026-08-06
- 분류: **설계 제안 — 소스 미수정 · 미승인**
- 상위 문서: [에코 보장 방침 분석](./RecipePage_에코보장_방침_분석.md)
- 전제(2026-08-06 확인): **PLC는 PC 에코 넘버가 자기 값과 같은지를 상시 확인한다.**
30초는 확인 *간격*이 아니라 **한 번의 확인 창(window)** 이며, 그 안에서 계속 체크하고
안 맞으면 대기했다가 다시 30초 창을 반복한다. → 지연 페널티 없음
---
## 0. PLC가 상시 확인한다는 것이 바꾸는 것
앞 문서는 "에코가 안 나가면 PLC가 **무한 대기**"를 전제로 썼습니다.
| 항목 | 종전 판단 | 정정 |
|---|---|---|
| 에코 누락 | 영구 교착 | **자동 복구** — PC가 맞추는 즉시 진행 (30초를 기다리지 않음) |
| 범위 밖 PageNo | 읽은 값 그대로 에코하고 진행 | **에코하지 않고 재시도가 맞다** — 아래 참조 |
> **정정 근거:** 범위 밖 값을 그대로 에코하면 PLC는 일치로 보고 넘어가고,
> PC는 폴백 레이어(`SeqAutoRunPTOP.cs:626-628`)로 용접합니다. 에코를 안 하면 PLC가
> 30초마다 재확인하며 기다릴 뿐이므로, **안 넘기는 쪽이 안전하고 복구도 자동**입니다.
즉 PC가 할 일은 **"제대로 읽고 → 내부 상태 갱신 → 에코"** 뿐입니다.
값이 안 맞을 때만 다시 읽어 고쳐 쓰고, **맞는 즉시 다음 노드로 넘어갑니다.**
> **루프는 짧아야 합니다.** 정상 흐름에서는 **1회 순회로 끝납니다.**
> 이 노드에서 오래 머무는 것이 목적이 아닙니다 — **대기는 다음 노드가 합니다**
> (`WeldingStartReadBit:705`, `PLC_WELD_TRIGGER_REQ` ON 대기).
> 재읽기 루프는 어긋났을 때만 도는 **보정 경로**입니다.
---
## 1. 수렴 조건 (노드를 나갈 때 반드시 참)
| 노드 | 나갈 때 보장해야 할 것 |
|---|---|
| `CheckRecipeNoComp` | `PC_RECIPE_NO == PLC_RECIPE_NO` **그리고** 활성 레시피 Id == 그 번호 |
| `CheckPageNoComp` | `PC_PAGE_NO == PLC_PAGE_NO` **그리고** `_CurrentPageNo == PC_PAGE_NO - 1` |
### ★ 가장 중요한 함정 — 워드만 맞추면 안 된다
**재읽기 → 내부 상태 갱신 → 에코**를 항상 **한 세트**로 처리해야 합니다.
워드만 수렴시키고 `_CurrentPageNo`(`:515`)나 활성 레시피를 안 고치면,
**PLC는 일치로 보고 통과시키는데 PC는 옛 페이지 레이어로 용접**합니다.
이 경우는 **PLC 게이트로도 못 막습니다** — 에코가 일치하므로 `PLC_WELD_TRIGGER_REQ`가
정상적으로 나옵니다. 지금까지 "PLC가 검증해주니 안전하다"고 본 보호막이 여기서만 뚫립니다.
---
## 2. `CheckRecipeNoComp` 수렴형 (의사코드)
```text
while (!context.Stopped)
{
(err, plcRecipeNo) = ReadWord(PLC_RECIPE_NO)
if (err != OK) { 로그 + Sleep(200); continue; } // 재시도, 중단 아님
if (plcRecipeNo == 0) { 정책 필요 → §4-①; Sleep(200); continue; }
recipe = Recipes().FirstOrDefault(Id == plcRecipeNo)
if (recipe == null) { 토스트(N회마다) + Sleep; continue; } // 에코 안 함
if (ActivatedRecipe()?.Id != recipe.Id)
{
ActivateRecipe(recipe.Id, "PLC Recipe Change")
활성 반영 대기 (타임아웃 시 알람 후 루프 상단으로) // 현재 :413-417은 리셋만 → P8
Dispatcher.Invoke(RecipeHelper.OnActiveReloaded) // Layers 동기 갱신 (현행 :425 유지)
}
werr = WriteWord(PC_RECIPE_NO, plcRecipeNo)
if (werr != OK) { 로그 + Sleep(200); continue; }
echoed = base.ReadWord((int)PC_RECIPE_NO, D, isInput: false) // 내가 쓴 값이 실제 반영됐나
cur = ReadWord(PLC_RECIPE_NO) // 그 사이 PLC가 바꿨나
if (echoed == plcRecipeNo && cur.data == plcRecipeNo) break; // 수렴 완료
Sleep(200)
}
if (context.Stopped) return null;
```
---
## 3. `CheckPageNoComp` 수렴형 (의사코드)
```text
while (!context.Stopped)
{
(err, p) = ReadWord(PLC_PAGE_NO)
if (err != OK) { 로그 + Sleep(200); continue; }
pageCount = Dispatcher.Invoke(() => Model.Layers.Count()) // 현행 :533 유지
if (p < 1 || p > pageCount) { 토스트(N회마다) + Sleep; continue; } // 에코 안 함 → §0
_CurrentPageNo = p - 1 // ★ 에코와 같은 세트
werr = WriteWord(PC_PAGE_NO, p)
if (werr != OK) { 로그 + Sleep(200); continue; }
echoed = base.ReadWord((int)PC_PAGE_NO, D, isInput: false)
cur = ReadWord(PLC_PAGE_NO)
if (echoed == p && cur.data == p) { plcPageNo = p; break; } // 수렴 완료
Sleep(200)
}
if (context.Stopped) return null; // ← 현재 없는 가드 (P7)
CellId/LotId 읽기 → Status 반영 // 수렴 후 1회로 이동
EventPublisher.Publish(EVENT_AUTO_RUN_UPDATE_PAGENUM, _CurrentPageNo)
```
**순서 제약:** Recipe 수렴 → Page 수렴. Page 유효범위가 활성 Recipe의 `Layers.Count()`에
의존하기 때문입니다(`:533`). 현재 Flows 순서(`:188-189`)가 이미 이 순서라 변경 없습니다.
---
## 4. 결정이 필요한 항목
### ① `PLC_RECIPE_NO == 0`을 무엇으로 볼 것인가
| 안 | 해석 | 동작 |
|---|---|---|
| A (현행 `:353`) | 전환 의사 없음 | 현 활성 레시피 번호를 에코하고 통과 |
| **B (권장)** | 이상값 | 에코하지 않고 재시도 |
08/04에 **"PLC는 워드를 항상 써놓는다"**가 확인됐으므로, 0이 읽히면 정상 상태가 아닙니다
(통신 실패 시 `ReadWord`가 `(err, 0)`을 반환 — `PLCBase.APIs.cs:105`, `:109`).
**B를 권장하되 PLC 담당 확인이 필요합니다.**
### ② 재시도 주기
PLC가 상시 확인하므로 **PC가 맞추는 즉시 진행**됩니다. 주기를 PLC에 맞출 필요가 없습니다.
정상 흐름은 1회 순회로 끝나므로 주기는 **보정 경로에서만** 의미가 있습니다 — **200ms** 면 충분합니다.
알림은 폭주 방지를 위해 현행 방식(첫 회 + N회마다 — `:497`, `:549`)을 그대로 씁니다.
> 현행 `Sleep(200)` × 3회(`:519`)와 `Sleep(3000)`(`:554`)은 **정상 흐름에 붙은 고정 지연**입니다.
> 수렴형으로 바꾸면 정상 흐름의 지연이 0이 되어 **사이클이 빨라집니다.**
### ③ 수렴 실패가 오래 지속될 때
**중단하지 않습니다.** `context.Stopped`가 유일한 탈출구입니다.
다만 N회 이상 지속되면 토스트를 **알람으로 승격**할지 결정이 필요합니다.
그래야 작업자가 "왜 안 넘어가는지"를 화면에서 알 수 있습니다.
(승격 기준 회수는 **미정 — 확인 필요**. PLC 30초 창과 맞출 이유는 없습니다.)
---
## 5. 되읽기에 쓸 API (실측 확인)
| 목적 | 호출 | 근거 |
|---|---|---|
| PLC가 쓴 값 | `_PLC.ReadWord(eWordInput.PLC_PAGE_NO)` | `PLCController.Logic.cs:16` |
| **내가 쓴 값(출력 워드)** | `base.ReadWord((int)addr, ePLCDevice.D, isInput: false)` | `PLCBase.APIs.cs:99`(public) · 동일 패턴 사용례 `PLCController.Logic.cs:28` |
| 쓰기 성공 여부 | `WriteWord(...)` 의 **`int` 반환값** | `PLCController.Logic.cs:17` — 현재 시퀀스는 버리고 있음 |
> 출력 워드 전용 헬퍼는 없습니다(`ReadOutBit`은 비트만 — `Logic.cs:14`).
> `base.ReadWord` 3인자 오버로드를 직접 부르거나, 대칭을 맞추려면
> `ReadOutWord(eWordOutput)` 헬퍼를 `PLCController.Logic.cs`에 추가하는 편이 낫습니다. **(제안)**
---
## 6. 이 설계가 대체하는 기존 조치
| 기존 조치 | 상태 | 사유 |
|---|---|---|
| P4 — Page 3회 수집 | **제거** | 수렴 루프가 대체. 값 안정성은 되읽기 일치로 확인 |
| P5 — Recipe 800ms 재폴링 | **제거** | 수렴 루프가 대체 |
| P2 — 에코 무조건화 | **일부 철회** | 미등록·범위 밖은 **에코하지 않는 게 맞다**(§0). "모든 경로 에코"가 아니라 **"수렴할 때까지 재시도"** 가 정답 |
| P3 — 범위 밖 대기 제거 | **정정** | 대기 자체는 유지. 다만 3초 → 200ms, 토스트 유지, 탈출은 수렴 |
| P1 — 상승엣지 전환 | **우선순위 하향** | 수렴 루프가 스테일 오독을 자동 복구한다. 여전히 유익하지만 **필수는 아님** |
| P6 — `err` 재읽기 | **유지·강화** | 수렴 루프의 재시도 조건으로 자연스럽게 편입 |
| P7 — `Stopped` 가드 | **유지** | §3 참조 |
| P8 — 활성 대기 타임아웃 탈출 | **유지** | 수렴 루프 상단으로 돌아가야 재시도가 성립 |
---
## 7. 남는 리스크
- **§1의 함정** — 워드와 내부 상태를 따로 고치면 PLC 게이트가 무력화됩니다. 구현 시 리뷰 필수.
- **`ResumeMode`(bit15) 예외** — `SeqStatusMonitor.cs:104`, `OnSeqActionStop:1227`이
M70515 ON 구간에서 에코 워드를 보존합니다. 수렴 루프가 이 구간에 진입하면
보존값과 충돌할 수 있습니다. **가접 시나리오와 함께 검토 필요.**
- **에코가 맞은 뒤 PLC가 워드를 바꾸는 경우** — 수렴 루프는 노드 안에서 끝나므로
노드를 나간 뒤의 변경은 감지하지 않습니다. 이건 **의도된 설계**입니다 — PC는 맞춰 쓰고
넘어가고, 이후 대기는 `WeldingStartReadBit`가 담당합니다.
[추론] PLC가 용접 완료 전에 워드를 바꾸지는 않을 것으로 보이나 **미확인**.