feat: PLC Recipe Change 핸드셰이크 구현 (SeqAutoRunPTOP) + 주소 정합

근거: 가이드 + Excel 260618(빨간셀 M70509/M70009/D70502/D70002). Page Change 미러링.
- 주소 enum: PLC_RECIPE_CHANGE_REQ(0x09)/PC_RECIPE_CHANGE_COMPLETE(0x09)/
  PLC_RECIPE_NO(idx2)/PC_RECIPE_NO(idx2). 공용 eCommonBitOutput 동기화.
  충돌: SeqStatusMonitor RMS_USED 읽기 주석, SeqAutoRunPrismatic 참조 이름만 교체.
- 시퀀스: CheckRecipeNo(논블로킹 Decision) + CheckRecipeNoComp(.Id 1:1 조회/활성화/
  반영폴링/에코·완료비트), Builder 흐름·엣지·루프백, 리셋 비트·워드, _RecipeManager Enter 확보.
- Page 검증 상한 하드코딩 제거 → 활성 Recipe Layers.Count() 가변.
- (별도) D:\ILSOSConfig\...\External\PLC CSV 디바이스맵 4종 라벨 정합(.bak260618 백업).

빌드는 VS Rebuild로 검증 필요(레거시 포함). STEP5 UI/PLC 사양 최종정합은 후속.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
노도연 2026-06-18 15:31:43 +09:00
parent de06a4c897
commit 42c5565249
7 changed files with 394 additions and 19 deletions

View File

@ -22,7 +22,8 @@ namespace ILSOS_PrismaticRD_PTOP.Device
PLC_EMO_STATUS = 0x07,
PLC_PAGE_CHANGE_REQ = 0x08,
PLC_RMS_USED = 0x09,
// 260618: M70509 Recipe 변경 요청 (구 PLC_RMS_USED, RMS 미사용으로 폐기 후 재정의)
PLC_RECIPE_CHANGE_REQ = 0x09,
// 0x0A ~ 0x0F
PLC_WELD_READY_REQ = 0x10,
PLC_WELD_WAIT = 0x11,
@ -57,8 +58,9 @@ namespace ILSOS_PrismaticRD_PTOP.Device
PC_DOOR_INTERLOCK = 0x06,
PC_EMO_STATUS = 0x07,
PC_PAGE_CHANGE_COMPLETE = 0x08,
PC_EMPTY01 = 0x09,
// 0x09 ~ 0x0F
// 260618: M70009 Recipe 변경 완료 응답 (구 PC_EMPTY01, 미사용 → 재정의)
PC_RECIPE_CHANGE_COMPLETE = 0x09,
// 0x0A ~ 0x0F
PC_WELD_READY_ACK = 0x10,
PC_WELD_BUSY = 0x11,
PC_WELD_COMPLETE = 0x12,
@ -81,7 +83,7 @@ namespace ILSOS_PrismaticRD_PTOP.Device
{
PLC_ERROR_CODE = 0X00,
PLC_PAGE_NO,
PLC_STEP_NO,
PLC_RECIPE_NO, // 260618: D70502 Recipe No (구 PLC_STEP_NO, index 2 유지)
PLC_CURRENT_SLOT_NO, // EL / CLEAN 사용
EMPTY_04,
EMPTY_05,
@ -149,7 +151,7 @@ namespace ILSOS_PrismaticRD_PTOP.Device
{
PC_ERROR_CODE = 0X00,
PC_PAGE_NO,
EMPTY_02,
PC_RECIPE_NO, // 260618: D70002 Recipe No 에코 (구 EMPTY_02, index 2 유지)
EMPTY_03,
EMPTY_04,
EMPTY_05,

View File

@ -103,7 +103,9 @@ namespace WeldingCommon.Sequences
private static SequenceActionResult UpdateIO(SequenceContext context, string id, SequenceActionResult param)
{
Status.RMSUse = _plc.ReadBit(PLCControllerPTOP.eBitInput.PLC_RMS_USED).data;
// 260618: M70509(구 PLC_RMS_USED)를 Recipe 변경 요청(PLC_RECIPE_CHANGE_REQ)으로 재정의.
// RMS 핸들러는 비활성(EquipmentSetup) 상태이고 Status.RMSUse 소비처가 없어 읽기 제거.
//Status.RMSUse = _plc.ReadBit(PLCControllerPTOP.eBitInput.PLC_RMS_USED).data;
Thread.Sleep(NODE_SLEEP);
return SequenceAction.Ok();
}

View File

@ -55,6 +55,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
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_RECIPENO = "[SeqAutoRunPTOP] CHECK RecipeNo"; // 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_COMPLETE = "[SeqAutoRunPTOP] CHECK PageNo Complete"; // 260513
@ -107,10 +109,12 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
private static TimeSpan _WeldingSpanTime; //TeacingRun 시퀀스에서 용접 소요시간
private int _CurrentPageNo;
private short plcPageNo = 0;// [0] 페이지 1 =음극 A [1]페이지 2= 양극 P(C)
private short plcPageNo = 0;// [0] 페이지 1 =음극 A [1]페이지 2= 양극 P(C)
private List<short> plcPageNoList = new List<short>();
private short plcRecipeNo = 0; // 260618 PLC Recipe No (11/21/22/31/32)
int laserEmissionGateDelayMs = 15; // 260518
public SequenceBuilder Builder()
@ -119,6 +123,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
sb.AddAction(SEQ_ENTER, Sequence_Enter);
sb.AddDecision(SEQ_CHECK_INIT_CONDITION, CheckInitCondition); //분기
sb.AddDecision(SEQ_CHECK_RECIPENO, CheckRecipeNo); // 260618 Recipe(상위) — 논블로킹 peek 분기
sb.AddAction(SEQ_CHECK_RECIPENO_COMPLETE, CheckRecipeNoComp); // 260618 Recipe 확정/활성화
sb.AddAction(SEQ_CHECK_PAGENO, CheckPageNo);
sb.AddAction(SEQ_CHECK_PAGENO_COMPLETE, CheckPageNoComp); // 260513 ㅍㅔㅇㅣㅈㅣ ㅅㅓㄹㅈㅓㅇ
@ -144,6 +150,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
sb.Flows(sb.Init
> sb[SEQ_ENTER]
> sb[SEQ_CHECK_INIT_CONDITION]
> sb[SEQ_CHECK_RECIPENO] // 260618 Recipe 먼저(상위)
> sb[SEQ_CHECK_RECIPENO_COMPLETE] // REQ ON 경로
> sb[SEQ_CHECK_PAGENO]
> sb[SEQ_CHECK_PAGENO_COMPLETE]
@ -162,7 +170,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
> sb[SEQ_COMPLETE] // 다시 enter로 올리기
> sb.Final);
sb.Flows(sb[SEQ_CHECK_CYCLE_STOP] > sb[SEQ_CHECK_PAGENO]);
sb.Flows(sb[SEQ_CHECK_CYCLE_STOP] > sb[SEQ_CHECK_RECIPENO]); // 260618 루프백: 매 사이클 Recipe부터 재확인
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.OnStopped(OnStopped);
sb.OnForceError(OnForceError);
@ -211,8 +220,11 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
MessageUtil.ShowToast("Sequence_Enter");
//Thread.Sleep(1000);
// 260618 RecipeManager 미리 확보(Recipe Change 노드 CheckRecipeNoComp에서 사용)
_RecipeManager = Application.Current.AuroraEnv().RecipeManager;
// 활성화된레시피ID
var activatedRecipeId = Application.Current.AuroraEnv().RecipeManager.ActivatedRecipe().Id;
var activatedRecipeId = _RecipeManager.ActivatedRecipe().Id;
var recipeName = RecipeHelper.SearchRecipeItem(activatedRecipeId);
_InitParam = param.Param as SeqTeachingRun_InitParam; // 260511 - param NULL
@ -271,14 +283,113 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
// return SEQ_COMPLETE;
//}
Thread.Sleep(5);
Log.I($"[CheckInitCondition] 조건 확인 완료 → CheckPageNo 이동");
return SEQ_CHECK_PAGENO;
Log.I($"[CheckInitCondition] 조건 확인 완료 → CheckRecipeNo 이동");
return SEQ_CHECK_RECIPENO;
}
private int _CenterBeamPower;
private int _RingBeamPower;
private int _CenterRingRatio;
// 페이지 체크 - 페이지만 감시 AutoRunStatus는 실제 상위에서 동작 체크하고 시퀸스 동작한다.
// ── 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회 확인)
bool req = _PLC.ReadBit(eBitInput.PLC_RECIPE_CHANGE_REQ).data;
if (!req)
{
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)
{
Log.I("[CheckRecipeNoComp] PLC Recipe 번호 읽기 시작 (3회 연속 검증)");
int checknum = 0;
var recipeNoList = new List<short>();
while (!context.Stopped)
{
recipeNoList.Add(_PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data);
plcRecipeNo = recipeNoList[checknum];
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
// _RecipeManager는 Enter 노드에서 미리 확보됨.
var recipe = _RecipeManager.Recipes().FirstOrDefault(x => x.Id == plcRecipeNo);
if (recipe == null)
{
Log.E($"[CheckRecipeNoComp] {plcRecipeNo}번 레시피 없음");
MessageUtil.ShowErrorToast($"PLC Recipe No [{plcRecipeNo}] 에 해당하는 레시피가 없습니다.");
Thread.Sleep(3000);
return null;
}
// 활성화 + 반영 확인 (비동기 race 주의: ActivatedRecipe().Id 일치까지 폴링)
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})");
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)
{
Log.W("[CheckRecipeNoComp] ActivateRecipe 반영 대기 TIMEOUT...");
actStart = DateTime.Now;
}
Thread.Sleep(10);
}
if (context.Stopped) return null;
}
else
{
Log.I($"[CheckRecipeNoComp] 이미 활성 레시피({recipe.Id}) → 재활성화 생략");
}
Thread.Sleep(5);
// 에코 + 완료 비트 (결정 3 — 기존 Page 미러형: COMPLETE 비트만 처리, REQ-OFF 대기 없음)
_PLC.WriteWord(eWordOutput.PC_RECIPE_NO, plcRecipeNo);
Log.I($"---> [CheckRecipeNoComp] Set PC_RECIPE_NO: {plcRecipeNo}");
_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();
}
// 페이지 체크 - 페이지만 감시 AutoRunStatus는 실제 상위에서 동작 체크하고 시퀸스 동작한다.
private SequenceActionResult CheckPageNo(SequenceContext context, string id, SequenceActionResult param)
{
Log.I($"[CheckPageNo] PLC_PAGE_CHANGE_REQ 신호 대기 중...");
@ -372,9 +483,11 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
if (checknum > 2)
{
if (plcPageNo > 2 || plcPageNo < 1)
// 260618: 상한 하드코딩(>2) 제거 → 활성 Recipe의 페이지 수(레이어 수)로 가변 검증 (Recipe별 2~5페이지)
int recipePageCount = _ScannerCanvasUserCtrl.Model.Layers.Count();
if (plcPageNo > recipePageCount || plcPageNo < 1)
{
Log.E($"[CheckPageNoComp] Invalid PageNo received: {plcPageNo}");
Log.E($"[CheckPageNoComp] Invalid PageNo received: {plcPageNo} (활성 Recipe 페이지 수: {recipePageCount})");
// 디버그 끝내고 삭제
//MessageUtil.ShowErrorToast($"ReadWord -> {plcPageNo}");
@ -711,8 +824,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
Log.I($"[CheckCycleStop] 사이클 중지 여부 확인 중");
Status.SetWorkingPosition(eWorkingPosition.CHECKING_CYCLE);
Log.I($"[CheckCycleStop] 사이클 계속 진행 → CheckPageNo 이동");
return SEQ_CHECK_PAGENO;
Log.I($"[CheckCycleStop] 사이클 계속 진행 → CheckRecipeNo 이동");
return SEQ_CHECK_RECIPENO;
}
private SequenceActionResult SaveResultData(SequenceContext context, string id, SequenceActionResult param)
@ -805,7 +918,8 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
{
Log.I($"[RestoreDeviceStatus] 노드 Start");
////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_BUSY, false); // 동작 중 false
@ -980,11 +1094,13 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
Log.I("[OnSeqActionStop] PLC Cathod(+) Output All 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");
// 260512 워드
_PLC.WriteDWord((int)eWordOutput.PC_ERROR_CODE, 0);
_PLC.WriteDWord((int)eWordOutput.PC_PAGE_NO, 0);
_PLC.WriteDWord((int)eWordOutput.PC_RECIPE_NO, 0); // 260618 레시피 No init
Log.I("[OnSeqActionStop] PLC IO Output Word Init");

View File

@ -419,7 +419,7 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
_PreviousTotalWeldingStep = _TotalWeldingStep;
_PageNo = resultOutParam.PLCPageNo;
// Total은 항상 같으므로 상관 없을 듯.
_TotalWeldingStep = Convert.ToInt16(_PLC.ReadWord(eWordInput.PLC_STEP_NO).data);
_TotalWeldingStep = Convert.ToInt16(_PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data); // 260618: index 2 enum rename(PLC_STEP_NO→PLC_RECIPE_NO), 레거시 참조 이름만 교체(로직 불변)
_SequenceSetting.LastPageNo = _PageNo;
context.Settings.Save(CommonSettingIds.Sequence, _SequenceSetting, "SeqAutoRunPrismatic_New");

View File

@ -265,7 +265,8 @@ namespace WeldingCommon.Devices
PC_DOOR_INTERLOCK = 0x06,
PC_EMO_STATUS = 0x07,
PC_PAGE_CHANGE_COMPLETE = 0x08,
// 0x09 ~ 0x0F
PC_RECIPE_CHANGE_COMPLETE = 0x09, // 260618: PTOP enum과 동기화 (RestoreDeviceStatus가 common enum 사용)
// 0x0A ~ 0x0F
PC_WELD_READY_ACK = 0x10,
PC_WELD_BUSY = 0x11,
PC_WELD_COMPLETE = 0x12,

View File

@ -0,0 +1,226 @@
# PLC Recipe Change 인터페이스 구현 — 수정 가이드
> 작성일: 2026-06-18
> 대상 시퀀스: **ILSOS_PrismaticRD_PTOP / SeqAutoRunPTOP** (현재 활성 AutoRun)
> 근거 문서: `Doc/유니셀 PtoP 단동기 Laser Interface_260618.xlsx` (시트 `M70K`, `D70K`, `Recipe Number`)
> 이 문서는 **다른 agent가 읽고 직접 구현**하기 위한 구현 명세서다. 분석은 끝났고, 아래 절차대로 코드를 추가하면 된다.
> 기존 **Page Change** 핸드셰이크를 그대로 본떠서 **Recipe Change** 핸드셰이크를 추가하는 것이 목표다.
---
## 1. 목표
PLC가 **Recipe 변경을 요청**하면, PC(Laser SW)가 해당 Recipe를 활성화하고 완료를 응답하는 **비트/워드 핸드셰이크**를 추가한다.
구조는 기존 Page Change와 동일하며, **Recipe가 상위 개념, Page가 하위 개념**이다 (Recipe 1개 = Page 여러 개).
핵심 원칙:
- 신규 코드는 **기존 Page Change 노드(`CheckPageNo` / `CheckPageNoComp`)를 미러링**한다.
- **런타임 실행** 시퀀스는 **`SeqAutoRunPTOP`** 하나뿐이다 (시작 근거: `UCMainAutoRunViewModel.cs:243`, 등록 `EquipmentSetup.cs:214`).
- ⚠️ 단, `SeqAutoRunPrismatic`는 **빌드 대상이다**: `EquipmentSetup.cs:202`에서 `.Builder()`가 호출되고 `:207`에서 `SequenceIds.SeqAutoRun`으로 **등록**되어 있다(다만 `.Start()`는 호출 안 됨 → 런타임 미실행). `SeqAutoRunComponentWeld`도 `:203`에서 빌드된다. 즉 **"미사용=미실행"이지 "미컴파일"이 아니다.** enum rename(충돌 ②) 시 이들이 깨질 수 있으니 STEP 1·3절을 반드시 확인.
---
## 2. 인터페이스 사양 (Excel 260618 기준)
> **출처/변경 표기**: 엑셀에는 변경이력 시트·셀 코멘트가 없고, 이번 개정의 신규 주소는 **빨간 글씨(font FFFF0000)** 로만 표시돼 있다. 워크북 전체 빨간 셀은 **5개**: `Recipe_CHGRequest`(M70509), `Recipe_CHGComplete`(M70009), `PLC_RecipeNo`(D70502), `PC_Recipe No`(D70002) — 본 문서가 다루는 4개 — 과 **별개 1개 `Head_Theta_Position`(D70510, Recipe 무관, 본 문서 범위 외)**. 즉 아래 표의 "신규" 항목은 모두 엑셀 빨간 글씨 근거.
### 2-1. 비트 핸드셰이크 — Page 대비 **오프셋 +1**
| 신호 | 방향 | M-Device | enum / 오프셋 | 비고 |
|---|---|---|---|---|
| `Page_CHGRequest` (기존) | PLC→PC (Input) | M70508 | `eBitInput.PLC_PAGE_CHANGE_REQ = 0x08` | 참고용 |
| **`Recipe_CHGRequest` (신규)** | **PLC→PC (Input)** | **M70509** | **0x09** | ⚠️ **충돌**: 현재 `PLC_RMS_USED = 0x09` |
| `Page_CHGComplete` (기존) | PC→PLC (Output) | M70008 | `eBitOutput.PC_PAGE_CHANGE_COMPLETE = 0x08` | 참고용 |
| **`Recipe_CHGComplete` (신규)** | **PC→PLC (Output)** | **M70009** | **0x09** | 현재 `PC_EMPTY01 = 0x09` (미사용, 안전) |
### 2-2. 워드 — Page 대비 **인덱스 +1**
| 신호 | 방향 | D-Device | enum index | 비고 |
|---|---|---|---|---|
| `PLC_PageNo` (기존) | PLC→PC (Input) | D70501 | `eWordInput.PLC_PAGE_NO = 1` | 참고용 |
| **`PLC_RecipeNo` (신규)** | **PLC→PC (Input)** | **D70502** | **index 2** | ⚠️ **충돌**: 현재 `PLC_STEP_NO`(index 2) |
| `PC_PageNo` (기존) | PC→PLC (Output) | D70001 | `eWordOutput.PC_PAGE_NO = 1` | 참고용 |
| **`PC_RecipeNo` (신규)** | **PC→PLC (Output)** | **D70002** | **index 2** | 현재 `EMPTY_02`(index 2) (미사용, 안전) |
> 주소 베이스: `eBitInput`=M70500, `eBitOutput`=M70000, `eWordInput`=D70500, `eWordOutput`=D70000.
> (⚠️ 베이스 절대주소 자체는 enum 코드가 아니라 외부 device-map/mmap 설정에서 온다 → 본 표의 오프셋 산술은 Excel·디바이스맵 기준이며 코드만으로는 베이스 검증 불가.)
> 정의 위치: `Machines/ILSOS_PrismaticRD_PTOP/Device/PLC/PLCController.Address.cs`
> (eBitInput L13–47, eBitOutput L49–78, eWordInput L80–146, eWordOutput L148–214)
### 2-3. Recipe Number ↔ Page 매핑 (`Recipe Number` 시트)
| Recipe No | Page 수 | 용접부 |
|---|---|---|
| 11 | 1, 2 | 양극 탭 비드 용접 / 음극 탭 비드 용접 |
| 21 | 1 ~ 5 | 전면 용접 |
| 22 | 1 ~ 5 | 후면 용접 |
| 31 | 1, 2 | 상면 용접 |
| 32 | 1, 2 | 하면 용접 |
> **중요**: Recipe No는 `11/21/22/31/32` 로 **불연속**이며, Recipe마다 **Page 수가 2~5개로 다르다**(파일 갱신으로 21/22가 4→5페이지). 특정 상한을 절대 하드코딩하지 말 것.
> 따라서 기존 `CheckPageNoComp`의 하드코딩 검증 `plcPageNo > 2 || plcPageNo < 1`(`SeqAutoRunPTOP.cs:370`)은 **Recipe별 Page 수에 따라 가변**으로 바꿔야 한다. (STEP 6 참고)
---
## 3. ⚠️ 주소 충돌 — 구현 전 반드시 확인할 것
### 충돌 ① `PLC_RMS_USED = 0x09` (eBitInput) — **현재 사용 중**
- 사용처: `Sequences/Monitoring/SeqStatusMonitor.cs:106`
```csharp
Status.RMSUse = _plc.ReadBit(PLCControllerPTOP.eBitInput.PLC_RMS_USED).data;
```
- Excel 260618은 M70509(=0x09)를 `Recipe_CHGRequest`로 재정의함.
- **즉 동일 비트(0x09)가 RMS_USED 상태와 Recipe 변경 요청으로 충돌한다.**
- **결정 필요(PLC 담당자 확인)**: RMS_USED 비트를 폐기/이설하고 0x09를 Recipe_CHGRequest로 넘길 것인지. → 4절 "미해결 질문" 참조.
### 충돌 ② `PLC_STEP_NO` (eWordInput index 2) — **레거시에서 컴파일됨**
- 사용처: `Sequences/SeqAutoRunPrismatic.cs:414`
- 활성 `SeqAutoRunPTOP`는 PLC_STEP_NO를 쓰지 않으나, `SeqAutoRunPrismatic`는 **등록·빌드 대상**(`EquipmentSetup.cs:202,207`)이다.
- ⚠️ **빌드 영향**: `PLC_STEP_NO` → `PLC_RECIPE_NO`로 **이름만 바꾸면 `:414`가 컴파일 에러**가 난다.
- **결정 필요(택1)**:
- (a) `:414`도 같이 정리(레거시 수정 허용 시) — 6절 "레거시 미수정" 원칙과 충돌하므로 사용자 승인 필요, **또는**
- (b) `PLC_STEP_NO`(index 2)는 **그대로 두고**, Recipe용으로 **별도 신규 멤버를 추가**(단, eWordInput은 암묵 순번이라 index 2를 두 이름이 가질 수 없음 → 명시값 부여 `PLC_RECIPE_NO = 2`로 두되 `PLC_STEP_NO`도 `= 2`로 명시해 공존시키거나, PLC_STEP_NO를 의미상 PLC_RECIPE_NO로 **rename + :414 동시 수정**).
→ 결국 index 2 한 칸을 누가 쓰느냐의 문제이므로 **(a) rename + :414 동시 수정**이 가장 깔끔. STEP 1에서 확정.
### 안전 (그대로 rename 가능)
- `PC_EMPTY01 = 0x09` (eBitOutput): 참조 없음 → `PC_RECIPE_CHANGE_COMPLETE`로 교체 안전.
- `EMPTY_02` (eWordOutput index 2): 참조 없음 → `PC_RECIPE_NO`로 교체 안전.
### ⚠️ enum 이중 정의 (Recipe 완료 비트 구현에 직접 영향)
- `PC_PAGE_CHANGE_COMPLETE`는 **두 곳에 0x08로 중복 정의**되어 있고 코드가 혼용한다:
- PTOP: `Device/PLC/PLCController.Address.cs` `eBitOutput.PC_PAGE_CHANGE_COMPLETE = 0x08`
- 공용: `WeldingComponents/WeldingCommon/Devices/PLC/PLCController.Address.cs` `eCommonBitOutput.PC_PAGE_CHANGE_COMPLETE = 0x08`
- 사용처가 갈린다: `RestoreDeviceStatus`(`SeqAutoRunPTOP.cs:803`)는 **`eCommonBitOutput`**, `OnSeqActionStop`(`:977`)은 **`eBitOutput`** 사용.
- **즉 신규 `PC_RECIPE_CHANGE_COMPLETE`를 PTOP `eBitOutput`에만 추가하면 `RestoreDeviceStatus`(common 경로)에서 못 쓴다.** → STEP 1에서 **양쪽 enum 동기화** 또는 캐스팅 통일 필요(STEP 4 참조).
---
## 4. 확정된 설계 결정 (2026-06-18, 사용자 협의)
| # | 결정 사항 | 채택안 | 비고 |
|---|---|---|---|
| 1 | **시퀀스 흐름/배치** | **매 사이클 Page 앞에서 확인** | `CheckInitCondition → CheckRecipeNo → CheckRecipeNoComp → CheckPageNo …`. 루프백(CheckCycleStop)은 **CheckRecipeNo로** 회귀. |
| 2 | **CheckRecipeNo 동작** | **논블로킹 peek** | Page와 달리 REQ ON까지 무한대기 ❌. REQ OFF면 현 Recipe 유지하고 즉시 Page로 통과. |
| 3 | **핸드셰이크 형태** | **기존 Page 미러형(B)** | `CheckPageNoComp` 방식 그대로. COMPLETE 비트만 처리, REQ-OFF 대기 없음(일관성 우선). |
| 4 | **RecipeNo→레시피 매핑** | **`.Id` 직접 1:1** | `Recipes().FirstOrDefault(x => x.Id == plcRecipeNo)` → 없으면 알람 → `ActivateRecipe(recipe.Id, "PLC Recipe Change")`. 선례: `RMSHandlerForPTOP.cs:125-173`. |
| 5 | **비트 0x09 충돌(①)** | **RMS_USED 폐기 → Recipe 전환** | `PLC_RMS_USED`→`PLC_RECIPE_CHANGE_REQ`. `SeqStatusMonitor.cs:106` RMSUse 읽기 제거/주석. RMS 핸들러 이미 비활성(`EquipmentSetup.cs:92-94`)이라 기능 영향 없음. |
| 6 | **Page 수 가변(2~5)** | **런타임 동적 산출** | 상한 하드코딩 금지. `Models.Layers.Count()` 또는 활성 Recipe 메타 기준(STEP 6). |
| 7 | **워드 idx 2 충돌(②)** | **rename + 레거시 :414 동시 수정** | `PLC_STEP_NO`→`PLC_RECIPE_NO` rename, `SeqAutoRunPrismatic.cs:414`의 참조도 **이름만** 교체(로직 불변). 사용자 승인 완료. |
### 사양 전제 (PLC 담당자와 정합성 확인 권장, 코드 결정 아님)
- PLC가 보내는 RecipeNo가 실제 등록 레시피의 `.Id`(`11/21/22/31/32`)와 일치한다는 전제. 불일치 시 결정 4를 매핑 테이블로 보강.
> ⚠️ 참고 — **기존 활성 Page Change 핸드셰이크는 미완결**이다. `CheckPageNoComp`(`SeqAutoRunPTOP.cs:408`)는 `PC_PAGE_CHANGE_COMPLETE`를 **false로만** 쓴다. 결정 3(미러형)에 따라 Recipe도 이 방식을 **그대로** 본뜬다(향후 Page 핸드셰이크가 보정되면 Recipe도 동일하게 따라간다).
---
## 5. 수정 절차
### STEP 1 — 주소 enum 추가
파일: `Machines/ILSOS_PrismaticRD_PTOP/Device/PLC/PLCController.Address.cs`
- `eBitInput`: `PLC_RMS_USED = 0x09` → **`PLC_RECIPE_CHANGE_REQ = 0x09`** 로 rename (결정 5). 동시에 `SeqStatusMonitor.cs:106`의 `Status.RMSUse = _plc.ReadBit(...PLC_RMS_USED)` 줄을 **제거 또는 주석** 처리(`Status.RMSUse`는 write-only·소비처 0건이라 안전).
- ⚠️ 공용 `WeldingCommon/Devices/PLC/PLCController.Address.cs`의 `eCommonBitInput.PLC_RMS_USED = 0x09`는 **별도 enum이며 참조 0건** → 빌드 영향 없음. 이름 일관성을 원하면 동반 rename(선택).
- `eBitOutput`: `PC_EMPTY01 = 0x09` → `PC_RECIPE_CHANGE_COMPLETE = 0x09` 로 교체.
- `eWordInput`: index 2(충돌 ②, 결정 7) `PLC_STEP_NO` → `PLC_RECIPE_NO` rename + **`SeqAutoRunPrismatic.cs:414`의 참조도 이름만 교체**(로직 불변, 승인 완료).
- `eWordOutput`: `EMPTY_02` → `PC_RECIPE_NO` 로 교체.
- **공용 enum 동기화(필수)**: 위 PTOP enum과 동일하게 `WeldingCommon/Devices/PLC/PLCController.Address.cs`의 `eCommonBitOutput` 0x09 자리에도 `PC_RECIPE_CHANGE_COMPLETE = 0x09`를 추가한다. (STEP 4의 `RestoreDeviceStatus`가 `eCommonBitOutput`을 쓰기 때문.) — §3 "enum 이중 정의" 참조.
> enum은 **암묵적 순번**(eWordInput/eWordOutput)이라 멤버 **이름만** 바꾸면 인덱스는 유지된다. 새 값을 끼워 넣어 뒤 순번이 밀리지 않도록 **이름 교체** 방식으로 처리할 것.
> ⚠️ index 2는 한 칸뿐이라 `PLC_STEP_NO`와 `PLC_RECIPE_NO`가 공존할 수 없다 → rename 단일화가 원칙(충돌 ② 참조).
### STEP 2 — Recipe Change 시퀀스 노드 추가
파일: `Machines/ILSOS_PrismaticRD_PTOP/Sequences/SeqAutoRunPTOP.cs`
기존 `CheckPageNo`(L277) / `CheckPageNoComp`(L331)를 본떠 **`CheckRecipeNo` / `CheckRecipeNoComp`** 추가. (결정 1~5 반영)
- **`CheckRecipeNo` (논블로킹 peek — 결정 2)**: `_PLC.ReadBit(eBitInput.PLC_RECIPE_CHANGE_REQ)`를 **1회 확인**.
- REQ **OFF** → Recipe 변경 없음. **즉시 `CheckPageNo`로 통과**(현 Recipe 유지). ⚠️ `CheckPageNo`처럼 ON까지 무한대기하지 **말 것.**
- REQ **ON** → `CheckRecipeNoComp`로 진행.
- **분기는 Decision 노드로 구현**(결정 2 확정) — STEP 3 참고.
- **`CheckRecipeNoComp`**:
1. `_PLC.ReadWord(eWordInput.PLC_RECIPE_NO)` 읽기 (필요 시 `CheckPageNoComp`의 3회 적재 패턴 재사용).
2. **레시피 조회/매핑 (결정 4 — `.Id` 직접 1:1)**:
```csharp
var recipe = AuroraEnv.RecipeManager /* 또는 _recipeEntry */
.Recipes().FirstOrDefault(x => x.Id == plcRecipeNo);
if (recipe == null) { /* "{plcRecipeNo}번 레시피 없음" 알람 */ return null; }
```
- 선례 그대로: `RMSHandlerForPTOP.cs:125-130`(조회/없음 알람), `:173`(활성화).
3. **Recipe 활성화**: `RecipeManager.ActivateRecipe(recipe.Id, "PLC Recipe Change")`
- 현재값 조회: `RecipeManager.ActivatedRecipe().Id`.
- ⚠️ **반영 확인 필수(비동기 race 주의)**: `ActivateRecipe`는 `EVENT_CHANGE_RECIPE_INFO` 이벤트 체인으로 반영되며, RMS 선례(`RMSHandlerForPTOP.cs:134,145`)는 `Dispatcher.Invoke`로 UI스레드에서 Reload/Save한다. 본 시퀀스는 **워커 스레드**이므로, 활성화 직후 `ActivatedRecipe().Id == recipe.Id` 가 될 때까지 **폴링/대기**한 뒤 다음(PageNo)으로 진행할 것. 누락 시 직후 Page 검증이 **이전 레시피 기준**으로 돌 위험.
4. `_PLC.WriteWord(eWordOutput.PC_RECIPE_NO, plcRecipeNo)` 에코.
5. **핸드셰이크 응답 (결정 3 — 기존 Page 미러형)**: `CheckPageNoComp`(`:401-409`)의 COMPLETE 비트 처리 방식을 **그대로** 본떠 `PC_RECIPE_CHANGE_COMPLETE` 처리. **REQ-OFF 대기 단계는 두지 않는다**(현 Page와 동일).
6. (선택) UI 알림: `EVENT_AUTO_RUN_UPDATE_PAGENUM` 패턴 본떠 Recipe 표시 이벤트 publish (STEP 5).
### STEP 3 — 상태머신에 노드 연결
같은 파일 `Builder()` (L114–171). 현재 흐름(L142–163):
```
ENTER → CheckInitCondition → CheckPageNo → CheckPageNoComp → (UploadWeldingList) → ... → CheckCycleStop ─(loop)→ CheckPageNo
```
Recipe는 Page의 상위이므로 **`CheckPageNo` 앞에** 삽입 (결정 1):
```
... CheckInitCondition → CheckRecipeNo → CheckRecipeNoComp → CheckPageNo → CheckPageNoComp → ...
```
- `sb.AddAction(SEQ_CHECK_RECIPENO, CheckRecipeNo);`
- `sb.AddAction(SEQ_CHECK_RECIPENO_COMPLETE, CheckRecipeNoComp);`
- 본 흐름(`Builder()` L142-161)에 두 노드를 `CheckInitCondition`과 `CheckPageNo` 사이에 끼움.
- **분기(결정 2 논블로킹)**: `CheckRecipeNo`를 **Decision 노드(`sb.AddDecision`)**로 만들어 `REQ ON → SEQ_CHECK_RECIPENO_COMPLETE`, `REQ OFF → SEQ_CHECK_PAGENO` 문자열을 반환 분기(Decision은 다음 노드 id 문자열을 반환 — `CheckInitCondition` `:259,269,273` 패턴).
- ⚠️ **엣지 선언 필수**: Decision의 반환 타깃이 유효하려면 두 분기 엣지가 그래프에 모두 존재해야 한다. 메인 Flows에 ON 경로(`CheckRecipeNo > CheckRecipeNoComp > CheckPageNo`)를 넣고, OFF 경로는 **별도로** `sb.Flows(sb[SEQ_CHECK_RECIPENO] > sb[SEQ_CHECK_PAGENO]);` 를 추가 선언할 것(선례: `CheckCycleStop`의 대체 분기 `:163`, `CheckInitCondition`의 `:164`).
- **루프백 변경**: 현재 `sb.Flows(sb[SEQ_CHECK_CYCLE_STOP] > sb[SEQ_CHECK_PAGENO]);`(L163)을 **`> sb[SEQ_CHECK_RECIPENO]`** 로 바꿔, 다음 사이클마다 Recipe부터 재확인.
### STEP 4 — 리셋/정지 시 비트 초기화
같은 파일:
- `RestoreDeviceStatus()` (L799~803): 이 메서드는 **`eCommonBitOutput`** 을 쓴다(`:803`). 따라서 `eCommonBitOutput.PC_RECIPE_CHANGE_COMPLETE` false 를 추가 — **STEP 1에서 공용 enum에 멤버를 추가했어야 컴파일된다.** (안 했으면 여기서 빌드 에러)
- `OnSeqActionStop` 계열(L977 비트, L981-982 워드, PC_PAGE 초기화 지점): 이쪽은 **`eBitOutput`/`eWordOutput`(PTOP)** 사용. `PC_RECIPE_CHANGE_COMPLETE` false, `PC_RECIPE_NO` 0 추가.
- ⚠️ 두 지점이 서로 다른 enum(common vs ptop)을 쓰므로, **사용하는 enum을 지점별로 맞출 것**(혼동 시 컴파일 에러).
### STEP 5 — (선택) UI 표시
Recipe 번호를 UI에 노출하려면 `Views/Main/UCMainAutoRunViewModel.cs`의 `CurrentPageNum`(L71-72) 패턴을 본떠 `CurrentRecipeNo` 속성 + 이벤트 구독 추가.
### STEP 6 — Page 검증 Recipe-aware 화
`CheckPageNoComp`의 페이지 범위 검증 `plcPageNo > 2 || plcPageNo < 1`(`SeqAutoRunPTOP.cs:370`)을 **활성 Recipe의 Page 수**(질문 5)에 따른 상한으로 교체.
- ⚠️ 이 검증은 **3회 폴링 게이트 `if (checknum > 2)`(`:368`) 안**에 있다. 상한만 바꾸고 `checknum > 2` 누적 폴링 구조(`plcPageNoList` 3회 적재)는 그대로 유지할 것.
- Page 수 출처 후보: 활성 Recipe 메타, 또는 `Models.Layers.Count()`(레이어 수). Recipe 21/22는 5페이지, 11/31/32는 2페이지(2-3절 표) — **상한 하드코딩 금지, 런타임 동적 산출.**
---
## 6. 변경 대상 파일 요약
| 파일 | 변경 |
|---|---|
| `ILSOS_PrismaticRD_PTOP/Device/PLC/PLCController.Address.cs` | PTOP enum 4종에 Recipe 신호 추가/교체 (STEP 1) |
| `WeldingCommon/Devices/PLC/PLCController.Address.cs` | `eCommonBitOutput`에 `PC_RECIPE_CHANGE_COMPLETE=0x09` 동기화 (STEP 1·4) |
| `Sequences/SeqAutoRunPTOP.cs` | 노드 2개 추가, Builder 흐름, 리셋, Page 검증 (STEP 2·3·4·6) |
| `Views/Main/UCMainAutoRunViewModel.cs` | (선택) Recipe 표시 (STEP 5) |
| `Sequences/Monitoring/SeqStatusMonitor.cs` | 충돌 ① 처리 시 RMS_USED 참조 정리 |
| `Sequences/SeqAutoRunPrismatic.cs` | 충돌 ②(결정 7): `:414` PLC_STEP_NO → PLC_RECIPE_NO 이름만 교체 (로직 불변, 승인 완료) |
> **런타임 미실행이지만 빌드 대상**인 레거시(`SeqAutoRunPrismatic.cs`, `SeqAutoRunComponentWeld.cs`)는 enum rename 영향이 미친다 — 컴파일 통과를 위해 필요한 최소 수정만 하되, 로직 변경은 하지 말 것.
> **완전 미참여 파일**(`SeqWaitWeldOrChangePage.cs`, `SeqTestRun.cs`)은 참고만 하고 수정하지 않는다.
---
## 7. 검증 체크리스트
- [ ] STEP 1 enum 인덱스가 밀리지 않았는가 (이름 교체 방식 확인).
- [ ] 충돌 ①(RMS_USED) / 충돌 ②(STEP_NO) 처리 방침이 확정·반영되었는가.
- [ ] `recipeNo` 유효값 `{11,21,22,31,32}` 외 입력 시 알람/중단 처리.
- [ ] `ActivateRecipe` 호출 후 실제 레시피가 바뀌고 `ActivatedRecipe().Id`가 일치하는가.
- [ ] 핸드셰이크: REQ ON → (work) → COMPLETE 응답 → REQ OFF → COMPLETE 해제 순서가 PLC 사양과 맞는가.
- [ ] 정지/리셋 시 `PC_RECIPE_CHANGE_COMPLETE`, `PC_RECIPE_NO` 초기화.
- [ ] Recipe별 Page 수 가변 검증(STEP 6)이 11/21/22/31/32 모두에서 동작.
- [ ] 솔루션 빌드 통과(레거시 포함). 빌드 실패를 경고로 넘기지 말 것.
---
## 8. 핵심 참고 코드 위치 (active path)
- 상태머신: `SeqAutoRunPTOP.Builder()` — `SeqAutoRunPTOP.cs:114-171` (흐름 L142-163)
- Page REQ 대기: `CheckPageNo` — `SeqAutoRunPTOP.cs:277-328`
- Page 확정/에코: `CheckPageNoComp` — `SeqAutoRunPTOP.cs:331-422`
- 리셋: `RestoreDeviceStatus` — `SeqAutoRunPTOP.cs:799+` / `OnSeqActionStop` 인근 L976-981
- 주소맵: `Device/PLC/PLCController.Address.cs`
- Recipe 활성화 API: `Recipes/RMSHandlerForPTOP.cs:173` (`RecipeManager.ActivateRecipe`), 조회 `:125-130`
- 레거시 완전형 핸드셰이크(참고): `Sequences/SeqWaitWeldOrChangePage.cs:251-318`

View File

@ -129,3 +129,31 @@
- DA1/DA2 Voltage UniformGrid에 `Visibility="{Binding IsNoLaser, Converter={StaticResource convTrueVisible}}"` 추가.
- Time이 Use Spot Time 체크 시 표시되는 것과 동일하게, **No Laser 체크(전압만) 시에만 DA1/DA2 Voltage 표시**, Laser Fire(해제) 시 숨김.
## 14:33
### IOInterfaceView DI 경로 PTOP 전환 분석 (소스 무수정)
- Monitoring IOInterfaceView의 DI 표시가 Scanner DI를 보던 원인 분석. `IODevice.MappingDIO`가 `IO_Board.json`의 `IOInterfaceMapDI`(절대경로)를 그대로 읽음(path-agnostic) → 소스 수정 불필요.
- PTOP 전환 = `D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\_config_device\IO_Board.json`의 DI/DO 경로를 PTOP로 + DI/DO.csv 존재 + DataPath 확인. (Device 설정에서 파일 위치만 지정하면 됨)
- 분석일지: `docs/분석/2026/06/18/143345_io_interface_di_path_ptop_analysis.md`
## 15:30
### PLC Recipe Change 인터페이스 구현 (SeqAutoRunPTOP) + 주소/디바이스맵 정합
- 근거: `docs/PLC_RecipeChange_구현_수정가이드.md` + Excel `유니셀 PtoP 단동기 Laser Interface_260618.xlsx`(빨간셀 M70509/M70009/D70502/D70002). 기존 Page Change 핸드셰이크를 미러링해 Recipe Change(상위 개념) 추가.
- **STEP1 주소 enum** (`Device/PLC/PLCController.Address.cs`):
- eBitInput `PLC_RMS_USED`→`PLC_RECIPE_CHANGE_REQ`(0x09), eBitOutput `PC_EMPTY01`→`PC_RECIPE_CHANGE_COMPLETE`(0x09)
- eWordInput `PLC_STEP_NO`→`PLC_RECIPE_NO`(idx2), eWordOutput `EMPTY_02`→`PC_RECIPE_NO`(idx2)
- 공용 `eCommonBitOutput`에 `PC_RECIPE_CHANGE_COMPLETE=0x09` 동기화(RestoreDeviceStatus가 common enum 사용)
- 충돌: `SeqStatusMonitor`의 RMS_USED 읽기 주석(소비처 0), `SeqAutoRunPrismatic`의 PLC_STEP_NO 참조 이름만 교체(로직 불변)
- **STEP2~4,6 시퀀스** (`Sequences/SeqAutoRunPTOP.cs`):
- `CheckRecipeNo`(Decision, 논블로킹 peek: REQ OFF면 Page로, ON이면 Comp로) + `CheckRecipeNoComp`(3회 검증→`.Id` 1:1 조회→활성화+반영 폴링→에코/완료비트, Page 미러형) 추가
- Builder: `CheckInitCondition → CheckRecipeNo → CheckRecipeNoComp → CheckPageNo`, REQ-OFF 엣지 추가, 루프백(CheckCycleStop)을 RecipeNo로 회귀
- 리셋: `RestoreDeviceStatus`/`OnSeqActionStop`에 Recipe 비트·워드 초기화
- `_RecipeManager`는 **Enter 노드에서 미리 확보**해 사용
- STEP6: 페이지 상한 하드코딩(`>2`) 제거 → `_ScannerCanvasUserCtrl.Model.Layers.Count()` 가변 검증(Recipe별 2~5페이지)
- **CSV 디바이스맵 4종**(`D:\ILSOSConfig\…PTOP\External\PLC`): M70509/M70009/D70502/D70002 라벨을 enum과 정합(`.bak260618` 백업). enum 값/인덱스 ↔ CSV 주소 ↔ Excel 빨간셀 전부 1:1 일치 확인.
- STEP5(UI Recipe 표시)는 선택이라 생략. 핸드셰이크는 기존 Page 미러형(COMPLETE 비트만) — PLC 사양 최종 정합/base 절대주소는 PLC 담당자 확인 권장.
- ⚠️ 빌드는 VS Rebuild로 검증 필요(레거시 포함). 작업 중 Excel 워킹트리본(57249)을 커밋본(46119)으로 되돌린 이슈 있었음(도연님 확인 → 문제없음).