feat: Page Change bit로 Recipe+Page 전환 통합

PLC가 Page Change bit(0x08) 하나만 트리거로 보내면 Recipe No와
Page No를 함께 읽어 Recipe를 먼저 전환한 뒤 Page를 전환하도록 변경.
Recipe Change bit(0x09)는 트리거로 미사용(enum 정의는 보존).

- CheckRecipeNo Decision 제거, 흐름 재배선(CheckPageNo→Recipe→Page)
- Recipe No 1회 읽기, 0/음수/미등록/동일이면 전환 생략(정지 안 함)
- 실제 전환 시 ActivateRecipe 후 Dispatcher.Invoke(OnActiveReloaded)로
  Model.Layers 동기 갱신 + 페이지 검증/레이어 선택을 UI 스레드 직렬화
  (ActivateRecipe-Layers 비동기 race 차단)
- PC_RECIPE_CHANGE_COMPLETE 미사용 처리, PC_RECIPE_NO 에코는 유지

VS Rebuild(Debug|x64) EXITCODE=0 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
to3796zw 2026-06-21 00:04:35 +09:00
parent 9be3fdcb54
commit 25edd79325
2 changed files with 96 additions and 107 deletions

View File

@ -1,4 +1,4 @@
using Aurora; using Aurora;
using Aurora.Api; using Aurora.Api;
using Aurora.Api.Manager; using Aurora.Api.Manager;
using Aurora.View; using Aurora.View;
@ -58,7 +58,6 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
const string SEQ_ENTER = "[SeqAutoRunPTOP] SEQ_ENTER"; // inputParam 으로 받자. const string SEQ_ENTER = "[SeqAutoRunPTOP] SEQ_ENTER"; // inputParam 으로 받자.
const string SEQ_CHECK_INIT_CONDITION = "[SeqAutoRunPTOP] Check Init Condition"; // Check Init Condition const string SEQ_CHECK_INIT_CONDITION = "[SeqAutoRunPTOP] Check Init Condition"; // Check Init Condition
const string SEQ_CHECK_RECIPENO = "[SeqAutoRunPTOP] CHECK RecipeNo"; // 260618 Recipe Change
const string SEQ_CHECK_RECIPENO_COMPLETE = "[SeqAutoRunPTOP] CHECK RecipeNo Complete"; // 260618 Recipe Change const string SEQ_CHECK_RECIPENO_COMPLETE = "[SeqAutoRunPTOP] CHECK RecipeNo Complete"; // 260618 Recipe Change
const string SEQ_CHECK_PAGENO = "[SeqAutoRunPTOP] CHECK PageNo"; const string SEQ_CHECK_PAGENO = "[SeqAutoRunPTOP] CHECK PageNo";
const string SEQ_CHECK_PAGENO_COMPLETE = "[SeqAutoRunPTOP] CHECK PageNo Complete"; // 260513 const string SEQ_CHECK_PAGENO_COMPLETE = "[SeqAutoRunPTOP] CHECK PageNo Complete"; // 260513
@ -126,12 +125,9 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
sb.AddAction(SEQ_ENTER, Sequence_Enter); sb.AddAction(SEQ_ENTER, Sequence_Enter);
sb.AddDecision(SEQ_CHECK_INIT_CONDITION, CheckInitCondition); //분기 sb.AddDecision(SEQ_CHECK_INIT_CONDITION, CheckInitCondition); //분기
sb.AddDecision(SEQ_CHECK_RECIPENO, CheckRecipeNo); // 260618 Recipe(상위) — 논블로킹 peek 분기 sb.AddAction(SEQ_CHECK_PAGENO, CheckPageNo); // 단일 트리거: PLC_PAGE_CHANGE_REQ(0x08) 대기
sb.AddAction(SEQ_CHECK_RECIPENO_COMPLETE, CheckRecipeNoComp); // 260618 Recipe 확정/활성화 sb.AddAction(SEQ_CHECK_RECIPENO_COMPLETE, CheckRecipeNoComp); // Recipe 먼저 확정/전환 (Page보다 상위)
sb.AddAction(SEQ_CHECK_PAGENO, CheckPageNo); sb.AddAction(SEQ_CHECK_PAGENO_COMPLETE, CheckPageNoComp); // 그다음 Page 확정
sb.AddAction(SEQ_CHECK_PAGENO_COMPLETE, CheckPageNoComp); // 260513 ㅍㅔㅇㅣㅈㅣ ㅅㅓㄹㅈㅓㅇ
#region #region
sb.AddAction(SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ, SetUploadWeldingList_SeqParams); sb.AddAction(SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ, SetUploadWeldingList_SeqParams);
@ -153,10 +149,9 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
sb.Flows(sb.Init sb.Flows(sb.Init
> sb[SEQ_ENTER] > sb[SEQ_ENTER]
> sb[SEQ_CHECK_INIT_CONDITION] > sb[SEQ_CHECK_INIT_CONDITION]
> sb[SEQ_CHECK_RECIPENO] // 260618 Recipe 먼저(상위) > sb[SEQ_CHECK_PAGENO] // 단일 트리거 PLC_PAGE_CHANGE_REQ(0x08) ON 대기
> sb[SEQ_CHECK_RECIPENO_COMPLETE] // REQ ON 경로 > sb[SEQ_CHECK_RECIPENO_COMPLETE] // Recipe 먼저 확정/전환
> sb[SEQ_CHECK_PAGENO] > sb[SEQ_CHECK_PAGENO_COMPLETE] // 그다음 Page 확정
> sb[SEQ_CHECK_PAGENO_COMPLETE]
#region TeachingRun 시퀸스의 중간 용접리스트 생성 구간 #region TeachingRun 시퀸스의 중간 용접리스트 생성 구간
> sb[SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ] // 1 파라미터 세팅 - TeachingRun 노드 참고 > sb[SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ] // 1 파라미터 세팅 - TeachingRun 노드 참고
@ -173,8 +168,7 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
> sb[SEQ_COMPLETE] // 다시 enter로 올리기 > sb[SEQ_COMPLETE] // 다시 enter로 올리기
> sb.Final); > sb.Final);
sb.Flows(sb[SEQ_CHECK_CYCLE_STOP] > sb[SEQ_CHECK_RECIPENO]); // 260618 루프백: 매 사이클 Recipe부터 재확인 sb.Flows(sb[SEQ_CHECK_CYCLE_STOP] > sb[SEQ_CHECK_PAGENO]); // 루프백: 매 사이클 Page Change bit(0x08) 대기부터 재확인
sb.Flows(sb[SEQ_CHECK_RECIPENO] > sb[SEQ_CHECK_PAGENO]); // 260618 REQ OFF 경로(논블로킹): Recipe 유지하고 Page로
sb.Flows(sb[SEQ_CHECK_INIT_CONDITION] > sb[SEQ_COMPLETE] > sb.Final); sb.Flows(sb[SEQ_CHECK_INIT_CONDITION] > sb[SEQ_COMPLETE] > sb.Final);
sb.OnStopped(OnStopped); sb.OnStopped(OnStopped);
sb.OnForceError(OnForceError); sb.OnForceError(OnForceError);
@ -286,109 +280,76 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
// return SEQ_COMPLETE; // return SEQ_COMPLETE;
//} //}
Thread.Sleep(5); Thread.Sleep(5);
Log.I($"[CheckInitCondition] 조건 확인 완료 → CheckRecipeNo 이동"); Log.I($"[CheckInitCondition] 조건 확인 완료 → CheckPageNo 이동");
return SEQ_CHECK_RECIPENO; return SEQ_CHECK_PAGENO;
} }
private int _CenterBeamPower; private int _CenterBeamPower;
private int _RingBeamPower; private int _RingBeamPower;
private int _CenterRingRatio; private int _CenterRingRatio;
// ── 260618 Recipe Change 핸드셰이크 (Page의 상위 개념. 기존 CheckPageNo/CheckPageNoComp 미러) ──
// CheckRecipeNo: 논블로킹 peek(결정 2). REQ OFF면 현 Recipe 유지하고 즉시 Page로, ON이면 Comp로.
private string CheckRecipeNo(SequenceContext context, string id, ICollection<string> branches, SequenceActionResult param)
{
if (_PLC == null)
{
Log.E("[CheckRecipeNo] _PLC instance is null! Recipe 건너뛰고 CheckPageNo 이동");
return SEQ_CHECK_PAGENO;
}
// Page와 달리 ON까지 무한대기하지 않는다(논블로킹 1회 확인) // CheckRecipeNoComp: 단일 트리거(PAGE_CHANGE_REQ 0x08) 통합 — Page보다 먼저 Recipe를 확정/전환.
bool req = _PLC.ReadBit(eBitInput.PLC_RECIPE_CHANGE_REQ).data; // PLC_RECIPE_NO 1회 읽기 → 0/미등록/현재활성과 동일이면 전환·워드 재기록 생략(시퀀스 정지 없음).
if (!req) // 실제로 다를 때만 ActivateRecipe + UI 스레드 동기 reload(Model.Layers 갱신 보장) + PC_RECIPE_NO 에코.
{
Log.I("[CheckRecipeNo] PLC_RECIPE_CHANGE_REQ OFF → 현 Recipe 유지, CheckPageNo 이동");
return SEQ_CHECK_PAGENO;
}
Log.I("---> [CheckRecipeNo] PLC_RECIPE_CHANGE_REQ ON 감지 → CheckRecipeNoComp 이동");
return SEQ_CHECK_RECIPENO_COMPLETE;
}
// CheckRecipeNoComp: Recipe No 읽기(3회 검증) → .Id 직접 1:1 조회 → 활성화(반영 폴링) → 에코/완료비트(결정 3 미러형)
private SequenceActionResult CheckRecipeNoComp(SequenceContext context, string id, SequenceActionResult param) private SequenceActionResult CheckRecipeNoComp(SequenceContext context, string id, SequenceActionResult param)
{ {
Log.I("[CheckRecipeNoComp] PLC Recipe 번호 읽기 시작 (3회 연속 검증)"); // Recipe No 1회 읽기 (Page Change bit와 함께 래치되어 안정적이라고 가정 → 노이즈 검증 생략)
plcRecipeNo = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data;
Log.I($"[CheckRecipeNoComp] PLC_RECIPE_NO: [{plcRecipeNo}]");
int checknum = 0; // 0이면 레시피 전환 의사 없음 → 현 Recipe 유지하고 Page로
var recipeNoList = new List<short>(); if (plcRecipeNo == 0)
while (!context.Stopped)
{ {
recipeNoList.Add(_PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data); Log.I("[CheckRecipeNoComp] PLC_RECIPE_NO=0 → 레시피 전환 생략, 현 Recipe 유지 → CheckPageNoComp 이동");
plcRecipeNo = recipeNoList[checknum]; return SequenceAction.Ok();
checknum++;
Thread.Sleep(200);
Log.I($"[CheckRecipeNoComp] Recipe 확인 [{checknum}/3] - PLC_Recipe: [{plcRecipeNo}]");
if (checknum > 2)
{
Log.I($"[CheckRecipeNoComp] List {recipeNoList[0]}/{recipeNoList[1]}/{recipeNoList[2]}");
break;
}
} }
if (context.Stopped) return null;
// 레시피 조회 (결정 4 — .Id 직접 1:1, 유효값은 등록 레시피로 검증 / 하드코딩 금지). 선례: RMSHandlerForPTOP // 등록 레시피 조회(.Id 1:1, 하드코딩 금지). 미등록이면 전환 생략(정지 안 함)
// _RecipeManager는 Enter 노드에서 미리 확보됨.
var recipe = _RecipeManager.Recipes().FirstOrDefault(x => x.Id == plcRecipeNo); var recipe = _RecipeManager.Recipes().FirstOrDefault(x => x.Id == plcRecipeNo);
if (recipe == null) if (recipe == null)
{ {
Log.E($"[CheckRecipeNoComp] {plcRecipeNo}번 레시피 없음"); Log.W($"[CheckRecipeNoComp] {plcRecipeNo}번 레시피 미등록 → 전환 생략, 현 Recipe 유지");
MessageUtil.ShowErrorToast($"PLC Recipe No [{plcRecipeNo}] 에 해당하는 레시피가 없습니다."); return SequenceAction.Ok();
Thread.Sleep(3000);
return null;
} }
// 활성화 + 반영 확인 (비동기 race 주의: ActivatedRecipe().Id 일치까지 폴링) // 이미 활성 레시피면 재활성화·워드 재기록 생략 (매 페이지마다 PC_RECIPE_NO 워드 변경 금지)
var preActivated = _RecipeManager.ActivatedRecipe(); var preActivated = _RecipeManager.ActivatedRecipe();
if (preActivated == null || preActivated.Id != recipe.Id) if (preActivated != null && preActivated.Id == recipe.Id)
{ {
_RecipeManager.ActivateRecipe(recipe.Id, "PLC Recipe Change"); Log.I($"[CheckRecipeNoComp] 이미 활성 레시피({recipe.Id}) → 재활성화 생략 → CheckPageNoComp 이동");
Log.I($"[CheckRecipeNoComp] ActivateRecipe ({(preActivated == null ? -1 : preActivated.Id)} -> {recipe.Id})"); return SequenceAction.Ok();
}
var actStart = DateTime.Now; // ── 실제 전환 ──
while (!context.Stopped) _RecipeManager.ActivateRecipe(recipe.Id, "PLC Recipe Change");
Log.I($"[CheckRecipeNoComp] ActivateRecipe ({(preActivated == null ? -1 : preActivated.Id)} -> {recipe.Id})");
// ActivatedRecipe().Id 반영 대기 (비동기 race 주의)
var actStart = DateTime.Now;
while (!context.Stopped)
{
var cur = _RecipeManager.ActivatedRecipe();
if (cur != null && cur.Id == recipe.Id) break;
if (DateTime.Now.Subtract(actStart).TotalSeconds > PLC_TIMEOUT)
{ {
var cur = _RecipeManager.ActivatedRecipe(); Log.W("[CheckRecipeNoComp] ActivateRecipe 반영 대기 TIMEOUT...");
if (cur != null && cur.Id == recipe.Id) break; actStart = DateTime.Now;
if (DateTime.Now.Subtract(actStart).TotalSeconds > PLC_TIMEOUT)
{
Log.W("[CheckRecipeNoComp] ActivateRecipe 반영 대기 TIMEOUT...");
actStart = DateTime.Now;
}
Thread.Sleep(10);
} }
if (context.Stopped) return null; Thread.Sleep(10);
}
else
{
Log.I($"[CheckRecipeNoComp] 이미 활성 레시피({recipe.Id}) → 재활성화 생략");
} }
if (context.Stopped) return null;
Thread.Sleep(5); // ★ Model.Layers를 새 레시피로 동기 갱신 (UI 스레드).
// ActivateRecipe의 이벤트 체인(ObserveOnDispatcher 2단)은 비동기라 Page 검증 전 Layers 갱신을 보장 못 함.
// RecipeHelper.OnActiveReloaded()가 ReloadRecipe를 동기 수행 → 반환 시점에 Layers가 새 레시피로 보장됨.
Application.Current.Dispatcher.Invoke(() => RecipeHelper.OnActiveReloaded());
Log.I("[CheckRecipeNoComp] Model.Layers 동기 reload 완료 (UI thread)");
// 에코 + 완료 비트 (결정 3 — 기존 Page 미러형: COMPLETE 비트만 처리, REQ-OFF 대기 없음) // 에코 (실제 전환 시에만). PC_RECIPE_CHANGE_COMPLETE는 미사용(PLC가 감시 안 함).
_PLC.WriteWord(eWordOutput.PC_RECIPE_NO, plcRecipeNo); _PLC.WriteWord(eWordOutput.PC_RECIPE_NO, plcRecipeNo);
Log.I($"---> [CheckRecipeNoComp] Set PC_RECIPE_NO: {plcRecipeNo}"); Log.I($"---> [CheckRecipeNoComp] Recipe 전환 완료 - Set PC_RECIPE_NO: [{plcRecipeNo}] → CheckPageNoComp 이동");
_PLC.WriteBit(eBitOutput.PC_RECIPE_CHANGE_COMPLETE, false);
Log.I("[CheckRecipeNoComp] Set PC_RECIPE_CHANGE_COMPLETE false");
Thread.Sleep(5);
Log.I($"[CheckRecipeNoComp] Recipe 확정 - PC_RECIPE_NO: [{plcRecipeNo}] 에코 완료 → CheckPageNo 이동");
return SequenceAction.Ok(); return SequenceAction.Ok();
} }
@ -487,7 +448,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
if (checknum > 2) if (checknum > 2)
{ {
// 260618: 상한 하드코딩(>2) 제거 → 활성 Recipe의 페이지 수(레이어 수)로 가변 검증 (Recipe별 2~5페이지) // 260618: 상한 하드코딩(>2) 제거 → 활성 Recipe의 페이지 수(레이어 수)로 가변 검증 (Recipe별 2~5페이지)
int recipePageCount = _ScannerCanvasUserCtrl.Model.Layers.Count(); // B-1: Model.Layers 접근을 UI 스레드로 직렬화 (레시피 reload와의 race 방지)
int recipePageCount = Application.Current.Dispatcher.Invoke(() => _ScannerCanvasUserCtrl.Model.Layers.Count());
if (plcPageNo > recipePageCount || plcPageNo < 1) if (plcPageNo > recipePageCount || plcPageNo < 1)
{ {
Log.E($"[CheckPageNoComp] Invalid PageNo received: {plcPageNo} (활성 Recipe 페이지 수: {recipePageCount})"); Log.E($"[CheckPageNoComp] Invalid PageNo received: {plcPageNo} (활성 Recipe 페이지 수: {recipePageCount})");
@ -556,21 +518,25 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
#region 페이지 번호에 따라 용접 리스트 레이어 선택 됨. 아래 데이터가 정확한지는 확인 필요 #region 페이지 번호에 따라 용접 리스트 레이어 선택 됨. 아래 데이터가 정확한지는 확인 필요
// 페이지 번호에 따라 용접 리스트(레이어) 선택 // 페이지 번호에 따라 용접 리스트(레이어) 선택
int layerCount = _ScannerCanvasUserCtrl.Model.Layers.Count();
int totalLineCount = 0; int totalLineCount = 0;
if (layerCount > _CurrentPageNo && _CurrentPageNo >= 0) // B-1: Model.Layers 접근(레이어 선택)을 UI 스레드로 직렬화 (레시피 reload와의 race 방지)
Application.Current.Dispatcher.Invoke(() =>
{ {
var layer = _ScannerCanvasUserCtrl.Model.Layers[_CurrentPageNo]; int layerCount = _ScannerCanvasUserCtrl.Model.Layers.Count();
_SelectedObjectList = layer.Objects; if (layerCount > _CurrentPageNo && _CurrentPageNo >= 0)
Log.I($"[SetUploadWeldingList] Selected PageNo [{_CurrentPageNo}] → Layer Name: [{layer.No}], Object Count: [{_SelectedObjectList.Count()}]"); {
} var layer = _ScannerCanvasUserCtrl.Model.Layers[_CurrentPageNo];
else _SelectedObjectList = layer.Objects;
{ Log.I($"[SetUploadWeldingList] Selected PageNo [{_CurrentPageNo}] → Layer Name: [{layer.No}], Object Count: [{_SelectedObjectList.Count()}]");
Log.E($"[SetUploadWeldingList] Invalid PageNo [{_CurrentPageNo}] (Total Layers: {layerCount}) → Fallback to Active Layer"); }
_SelectedObjectList = _ScannerCanvasUserCtrl.Model.ActiveLayer.Objects; else
Log.W($"[SetUploadWeldingList] Fallback Layer Name: [{_ScannerCanvasUserCtrl.Model.ActiveLayer.No}], Object Count: [{_SelectedObjectList.Count()}]"); {
} Log.E($"[SetUploadWeldingList] Invalid PageNo [{_CurrentPageNo}] (Total Layers: {layerCount}) → Fallback to Active Layer");
_SelectedObjectList = _ScannerCanvasUserCtrl.Model.ActiveLayer.Objects;
Log.W($"[SetUploadWeldingList] Fallback Layer Name: [{_ScannerCanvasUserCtrl.Model.ActiveLayer.No}], Object Count: [{_SelectedObjectList.Count()}]");
}
});
if (_SelectedObjectList == null || _SelectedObjectList.Count() == 0) if (_SelectedObjectList == null || _SelectedObjectList.Count() == 0)
{ {
@ -859,8 +825,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
Log.I($"[CheckCycleStop] 사이클 중지 여부 확인 중"); Log.I($"[CheckCycleStop] 사이클 중지 여부 확인 중");
Status.SetWorkingPosition(eWorkingPosition.CHECKING_CYCLE); Status.SetWorkingPosition(eWorkingPosition.CHECKING_CYCLE);
Log.I($"[CheckCycleStop] 사이클 계속 진행 → CheckRecipeNo 이동"); Log.I($"[CheckCycleStop] 사이클 계속 진행 → CheckPageNo 이동");
return SEQ_CHECK_RECIPENO; return SEQ_CHECK_PAGENO;
} }
private SequenceActionResult SaveResultData(SequenceContext context, string id, SequenceActionResult param) private SequenceActionResult SaveResultData(SequenceContext context, string id, SequenceActionResult param)
@ -954,7 +920,6 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
Log.I($"[RestoreDeviceStatus] 노드 Start"); Log.I($"[RestoreDeviceStatus] 노드 Start");
////PLC All BIT OFF ////PLC All BIT OFF
_PLC.WriteBit((int)eCommonBitOutput.PC_PAGE_CHANGE_COMPLETE, false); // 페이지 false _PLC.WriteBit((int)eCommonBitOutput.PC_PAGE_CHANGE_COMPLETE, false); // 페이지 false
_PLC.WriteBit((int)eCommonBitOutput.PC_RECIPE_CHANGE_COMPLETE, false); // 260618 레시피 false
_PLC.WriteBit((int)eCommonBitOutput.PC_WELD_READY_ACK, false); // 용접 준비 false _PLC.WriteBit((int)eCommonBitOutput.PC_WELD_READY_ACK, false); // 용접 준비 false
_PLC.WriteBit((int)eCommonBitOutput.PC_WELD_BUSY, false); // 동작 중 false _PLC.WriteBit((int)eCommonBitOutput.PC_WELD_BUSY, false); // 동작 중 false
@ -1129,7 +1094,6 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
Log.I("[OnSeqActionStop] PLC Cathod(+) Output All false"); Log.I("[OnSeqActionStop] PLC Cathod(+) Output All false");
_PLC.WriteBit((int)eBitOutput.PC_PAGE_CHANGE_COMPLETE, false); _PLC.WriteBit((int)eBitOutput.PC_PAGE_CHANGE_COMPLETE, false);
_PLC.WriteBit((int)eBitOutput.PC_RECIPE_CHANGE_COMPLETE, false); // 260618 레시피 완료비트 init
Log.I("[OnSeqActionStop] PLC IO Output PAGECHANGE Init"); Log.I("[OnSeqActionStop] PLC IO Output PAGECHANGE Init");
// 260512 워드 // 260512 워드

View File

@ -92,6 +92,31 @@
- 이 시점 싱글톤=이전 레시피(stale), WorkRecipeFile=새 경로 → **SaveFieldHelper가 stale을 새 레시피 FieldHelper.json에 덮어써 디스크 손상.** 1차 수정의 로드는 손상된 파일을 다시 읽어 무력화. - 이 시점 싱글톤=이전 레시피(stale), WorkRecipeFile=새 경로 → **SaveFieldHelper가 stale을 새 레시피 FieldHelper.json에 덮어써 디스크 손상.** 1차 수정의 로드는 손상된 파일을 다시 읽어 무력화.
- 이 손상은 **수정 전부터 잠재**한 기존 거동 → 기존 레시피 전환 반복 시 일부 FieldHelper.json 오염 가능성(현장 점검 권장). - 이 손상은 **수정 전부터 잠재**한 기존 거동 → 기존 레시피 전환 반복 시 일부 FieldHelper.json 오염 가능성(현장 점검 권장).
## 야간 작업 — PLC Page Change bit 단일 트리거 통합 (Recipe→Page)
### 배경/목표
- 기존: PLC가 Page Change bit(`PLC_PAGE_CHANGE_REQ` 0x08)와 Recipe Change bit(`PLC_RECIPE_CHANGE_REQ` 0x09)를 각각 줘서 Recipe/Page를 따로 처리.
- 변경: **Page Change bit(0x08) 한 bit만 트리거.** ON되면 Recipe No·Page No를 함께 읽어 **Recipe 먼저 전환 → Page 전환**. 0x09는 트리거로 미사용(enum 정의는 보존).
### 변경 파일: `Machines/ILSOS_PrismaticRD_PTOP/Sequences/SeqAutoRunPTOP.cs` (단일 파일)
- 흐름 재배선: `CheckInitCondition → CheckPageNo(0x08 대기) → CheckRecipeNoComp(레시피 먼저) → CheckPageNoComp(페이지)`. `CheckRecipeNo` Decision(0x09 peek) 노드/메서드/`SEQ_CHECK_RECIPENO` 상수 제거. 루프백·`CheckInitCondition`·`CheckCycleStop` 반환을 `SEQ_CHECK_PAGENO`로 변경.
- `CheckRecipeNoComp`: `PLC_RECIPE_NO` **1회 읽기**(3회 검증 제거→tact 절감). 0/음수/미등록/현재활성과 동일 → 전환·워드 재기록 생략(시퀀스 정지 없음). 실제 전환 시에만 `ActivateRecipe`+Id폴링 + **`Dispatcher.Invoke(RecipeHelper.OnActiveReloaded)` 동기 Model 갱신** + `PC_RECIPE_NO` 에코.
- **B-1(race 차단)**: `CheckPageNoComp`의 `Model.Layers.Count()` 검증과 `SetUploadWeldingList_SeqParams`의 레이어 선택을 `Dispatcher.Invoke`로 UI 스레드 직렬화.
- `PC_RECIPE_CHANGE_COMPLETE` write 3곳(CheckRecipeNoComp/RestoreDeviceStatus/OnSeqActionStop) 모두 제거(미사용 확정). `PC_RECIPE_NO` 에코는 유지.
### 핵심 근거 (Serena 추적)
- `ActivateRecipe`→`SYS_RECIPE_ACTIVATED`(UI dispatcher)→`EVENT_CHANGE_RECIPE_INFO`(UI dispatcher)→`RecipeHelper.OnActiveReloaded`→`MainPage.ReloadRecipe`→`UpdateLayersInfo`에서 `Model.Layers` 채움. 시퀀스(worker)는 `ActivatedRecipe().Id`만 폴링 → Layers 갱신과 **비동기 race**. 노드 순서만 바꾸면 race 악화 → B(동기 reload)+B-1(읽기 UI 직렬화)로 해소.
### 검증
- Serena LSP 진단 0건.
- 서브에이전트 시나리오 점검 ①무효 recipe+무효 page ②recipe=현재값 ③recipe 0/음수 ④없는 번호 ⑤PC 에코/표시: `short` 음수 recipe는 `==0`이 아니라 "미등록" 경로로 안전 차단 확인. 시2·3 PASS.
- 결정: **G1(무효/0/음수 recipe = 현 레시피 유지)이 정상**, **G2 `PC_RECIPE_CHANGE_COMPLETE` 미사용**.
- **VS Rebuild(MSBuild Debug|x64) EXITCODE=0, CS/MSB 에러 0건**(경고는 기존 코드, 이번 변경 무관).
- 인코딩 UTF-8 BOM+CRLF, 한글 정상.
### 후속(참고)
- page 무효 정지 시 알람/`PC_ERROR_CODE` 기록은 여전히 주석 처리 상태(별도 과제).
### 최종 수정 (적용·빌드 검증 완료) ### 최종 수정 (적용·빌드 검증 완료)
- `MainPage.cs` 한 파일, 기존 소스 보존: - `MainPage.cs` 한 파일, 기존 소스 보존:
1. 가드 플래그 `private bool _suppressPageFieldSync = false;` 추가 1. 가드 플래그 `private bool _suppressPageFieldSync = false;` 추가