using Aurora; using Aurora.Api; using Aurora.Api.Manager; using Aurora.View; using AuroraIO; 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; using System.Linq; using System.Threading; using VisionCommon; using WeldingCommon; using WeldingCommon.Devices; using WeldingCommon.Devices.Laser; using WeldingCommon.Sequences; using WeldingCommon.Utils; using WeldingCommon.Alarm; using ILSOS_PrismaticRD_PTOP.Alarm; using ILSOS_PrismaticRD_PTOP.Settings; using static AuroraIO.AJINEXTEK.AjinIO.IOModuleInfo; using static ILSOS_PrismaticRD_PTOP.Device.PLCControllerPTOP; using static WeldingCommon.Devices.PLCController; using static WeldingCommon.Devices.Laser.PAIX_PrismaticScanner; // 260824 IO 채널 enum 단일화 using Application = System.Windows.Application; using SystemParameter = WeldingCommon.Settings.SystemParameter; namespace ILSOS_PrismaticRD_PTOP.Sequences { /* * * [Sequence_Enter] 시퀀스 진입 - 레시피 초기화 및 장비 연결 [CheckInitCondition] 조건 확인 완료 → CheckPageNo 이동 [CheckPageNo] PLC_PAGE_CHANGE_REQ 신호 대기 중... [CheckPageNo] PLC_PAGE_CHANGE_REQ ON 감지 → CheckPageNoComp 이동 [CheckPageNoComp] 페이지 확정 - PC_PAGE_NO: [1] 에코 완료 [SetUploadWeldingList] Group Count: [2], Total Line Count: [34] [WeldingStartReadBit] PLC_WELD_TRIGGER_REQ ON 감지 → WaitWeldingStart 이동 [WaitWeldingStart] READY_ACK/BUSY ON, COMPLETE OFF 완료 [ExecuteWelding] Laser Gate ON (Core+Ring) [ExecuteWelding] Gate ON Delay 15ms 완료 → ExecuteList 시작 [ExecuteWelding] WaitForListTaskCompleted 완료 [ExecuteWelding] Laser Gate OFF (Core+Ring) [ExecuteWelding] 용접 완료 - TactTime: [285ms] [CheckCycleStop] 사이클 계속 진행 → CheckPageNo 이동 * * */ class SeqAutoRunPTOP { static readonly Logger Log = new Logger("SeqAutoRunPTOP"); static readonly IAuroraEnvironment aurora; // 완료 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_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 #region 260813 const string SEQ_RESYNC_RECIPE_PAGE = "[SeqAutoRunPTOP] Resync Recipe/Page"; #endregion const string SEQ_CHECK_WELDING_START_READ_BIT = "[SeqAutoRunPTOP] Check Welding Start ReadBit"; // 26 05 13 const string SEQ_WAIT_WELDING_START = "[SeqAutoRunPTOP] Wait Welding Start"; #region 티칭런에 있던 주요 노드 복사 const string SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ = "[SeqAutoRunPTOP] Set Sequence Param (UploadWeldingList Seq)"; const string SEQ_DO_ID_UPLOAD_WELDING_LIST_SEQ = "[SeqAutoRunPTOP] Process (UploadWeldingList Seq)"; const string SEQ_GET_RESULT_UPLOAD_WELDING_LIST_SEQ = "[SeqAutoRunPTOP] Get Sequence Result (UploadWeldingList Seq)"; #endregion const string SEQ_EXECUTE_WELDING = "[SeqAutoRunPTOP] Execute Welding"; const string SEQ_CHECK_CYCLE_STOP = "[SeqAutoRunPTOP] Check Cycle Stop"; const string SEQ_SAVE_RESULT_DATA = "[SeqAutoRunPTOP] Save Result Data"; const string SEQ_WRITE_COMPLETION_PLC_BIT = "[SeqAutoRunPTOP] Write Completion PLC Bit"; const string SEQ_COMPLETE = "[SeqAutoRunPTOP] SEQ_COMPLETE"; // 완료 #region 260622: M70509(Scout Enable) 기반 용접 전/후 Scout // 용접 전: M70516(Weld Trigger) 감지 후 M70509 ON이면 scout(pre) → 0.1s → 용접 const string SEQ_PRE_SCOUT_CHECK = "[SeqAutoRunPTOP] Pre-Weld Scout Check"; // 분기: M70509 ON/OFF const string SEQ_PRE_SCOUT_SET_PARAMS = "[SeqAutoRunPTOP] Pre-Weld Scout SetParams"; const string SEQ_PRE_SCOUT_RUN = "[SeqAutoRunPTOP] Pre-Weld Scout Run"; // SeqScoutNormal 서브시퀀스 const string SEQ_PRE_SCOUT_DONE = "[SeqAutoRunPTOP] Pre-Weld Scout Done"; // 0.1s 후 진행 // 용접 후: 발진 완료 후 완료비트 송신 전 M70509 ON이면 scout(post) → 0.1s → 완료비트 const string SEQ_POST_SCOUT_CHECK = "[SeqAutoRunPTOP] Post-Weld Scout Check"; const string SEQ_POST_SCOUT_SET_PARAMS = "[SeqAutoRunPTOP] Post-Weld Scout SetParams"; const string SEQ_POST_SCOUT_RUN = "[SeqAutoRunPTOP] Post-Weld Scout Run"; const string SEQ_POST_SCOUT_DONE = "[SeqAutoRunPTOP] Post-Weld Scout Done"; // 0.1s 후 진행 // 용접 완료 비트 송신(post-scout 분기 뒤로 분리): TriggerACK off / BUSY off / COMPLETE on const string SEQ_WRITE_WELD_COMPLETE = "[SeqAutoRunPTOP] Write Weld Complete"; private const int SCOUT_PROCEED_DELAY_MS = 100; // scout 완료 후 진행 지연(0.1초) #endregion #region 260824 이전 원본 (로컬 복사본 — WeldingCommon 의 PAIX_PrismaticScanner 로 단일화) // ↑ 상단 using static WeldingCommon.Devices.Laser.PAIX_PrismaticScanner 로 대체. // 원복 시 아래 블록을 살리고 그 using static 을 제거한다. //public enum ePrismaticDOAddress //{ // CoreLaser_Control = 9, // CoreLaser_ErrorClearing, // RingLaser_Control = 13, // RingLaser_ErrorClearing, // EX_Red_Control = 17, //} #endregion static class ContextKey { public const string KEY_SEQUENCE_RESULT = "KEY_SEQUENCE_RESULT_SeqAutoRunPTOP"; public const string KEY_SEQUENCE_START_TIME = "KEY_SEQUENCE_START_TIME_SeqAutoRunPTOP"; } public IAuroraEnvironment AuroraEnv { get; set; } // 장비 3개만 initDev private Scanner _Scanner; private PLCControllerPTOP _PLC; //private PLCController _PLC; private IODevice _IO; private SystemParameter _SystemParameter; private IRecipeManager _RecipeManager; private ScannerCanvasUserCtrl _ScannerCanvasUserCtrl; //스캐너 동작관련 객체 선언 private SeqTeachingRun_InitParam _InitParam; // Enter에 기본으로 적용할 Param // 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 #region 260813 private bool _InResyncNode = false; private bool _ResyncAborted = false; private DateTime _ResyncStartTime; const int RESYNC_TIMEOUT_SEC = 10; #endregion #region [로그계측 2026-09-16] 자동운전 관측용 필드 // 원복: 이 파일과 Alarm/AlarmManager_PTOP.cs 에서 "[로그계측 2026-09-16]" 표시가 붙은 것을 전부 삭제하면 된다 // (이 region 포함, 현재 두 파일 합쳐 10곳 — region 5개 + 단일 라인 5개). // 자동 전환 사전점검(Doc/인수인계/260916_자동운전_전환_사전점검.md)의 관측 항목을 남기기 위한 값들이다. // 전부 기록 전용이며 분기·리턴·비트쓰기 어디에도 관여하지 않는다. private static long _CycleCount = 0; // Enter 이후 누적 사이클(=페이지 용접) 수 private TimeSpan _LastWeldSpan; // 직전 ExecuteList 소요(실제 용접 구간) private double _LastGateOnMs; // 직전 Laser Gate ON→OFF 유지 시간 private DateTime _GateOnTime; // Gate ON 시각(위 계산용) private bool _ReqAlreadyOnAtEntry; // CheckPageNo 진입 시 REQ 가 이미 ON 이었는가 #endregion private eListNo _WeldingScannerListNo; private List _SelectedObjectList; private static TimeSpan _WeldingSpanTime; //TeacingRun 시퀀스에서 용접 소요시간 private int _CurrentPageNo; // 260621: bit15 Resume — 정지 시 상태보존 + 재개 시 핸드셰이크 스킵 (VM·시퀀스 공유, 인스턴스 1개라 static) public static bool ResumeMode = false; private short plcPageNo = 0;// [0] 페이지 1 =음극 A [1]페이지 2= 양극 P(C) #region 260806 구버전 보존 — Page 3회 수집용 필드 (원복: 아래 주석을 푼다) // 수렴 루프로 대체돼 참조가 없어졌다. 삭제하지 않고 남긴다. //private List plcPageNoList = new List(); #endregion private short plcRecipeNo = 0; // 260618 PLC Recipe No (11/21/22/31/32) int laserEmissionGateDelayMs = 15; // 260518 public SequenceBuilder Builder() { var sb = new SequenceBuilder(100000, true); sb.AddAction(SEQ_ENTER, Sequence_Enter); sb.AddDecision(SEQ_CHECK_INIT_CONDITION, CheckInitCondition); //분기 sb.AddAction(SEQ_RESYNC_RECIPE_PAGE, ResyncRecipePage); sb.AddAction(SEQ_CHECK_PAGENO, CheckPageNo); // 단일 트리거: PLC_PAGE_CHANGE_REQ(0x08) 대기 sb.AddAction(SEQ_CHECK_RECIPENO_COMPLETE, CheckRecipeNoComp); // Recipe 먼저 확정/전환 (Page보다 상위) sb.AddAction(SEQ_CHECK_PAGENO_COMPLETE, CheckPageNoComp); // 그다음 Page 확정 #region sb.AddAction(SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ, SetUploadWeldingList_SeqParams); sb.AddSubsequence(SEQ_DO_ID_UPLOAD_WELDING_LIST_SEQ, CommonSequenceManager.GetSeqBuilder_SeqUploadWeldingList()); sb.AddAction(SEQ_GET_RESULT_UPLOAD_WELDING_LIST_SEQ, GetUploadWeldingList_SeqResult); #endregion // 260513 plc sb.AddAction(SEQ_CHECK_WELDING_START_READ_BIT, WeldingStartReadBit); // 260622: 용접 전 Scout (M70509 ON 시) sb.AddDecision(SEQ_PRE_SCOUT_CHECK, PreScoutCheck); sb.AddAction(SEQ_PRE_SCOUT_SET_PARAMS, PreScoutSetParams); sb.AddSubsequence(SEQ_PRE_SCOUT_RUN, SequenceManager.GetSeqBuilder_SeqScoutNormal()); sb.AddAction(SEQ_PRE_SCOUT_DONE, PreScoutDone); sb.AddAction(SEQ_WAIT_WELDING_START, WaitWeldingStart); sb.AddAction(SEQ_EXECUTE_WELDING, ExecuteWelding); // 260622: 용접 후 Scout (M70509 ON 시) → 완료비트 송신 sb.AddDecision(SEQ_POST_SCOUT_CHECK, PostScoutCheck); sb.AddAction(SEQ_POST_SCOUT_SET_PARAMS, PostScoutSetParams); sb.AddSubsequence(SEQ_POST_SCOUT_RUN, SequenceManager.GetSeqBuilder_SeqScoutNormal()); sb.AddAction(SEQ_POST_SCOUT_DONE, PostScoutDone); sb.AddAction(SEQ_WRITE_WELD_COMPLETE, WriteWeldComplete); sb.AddDecision(SEQ_CHECK_CYCLE_STOP, CheckCycleStop); // 체크페이지 sb.AddAction(SEQ_SAVE_RESULT_DATA, SaveResultData); // 해당 공정의 정보를 기록 함. sb.AddAction(SEQ_WRITE_COMPLETION_PLC_BIT, WriteCompletionPLCBit); // 정상적으로 종료 되면, complete bit write sb.AddAction(SEQ_COMPLETE, Complete); // Enter로 올린다. sb.Flows(sb.Init > sb[SEQ_ENTER] > sb[SEQ_CHECK_INIT_CONDITION] > sb[SEQ_RESYNC_RECIPE_PAGE] > sb[SEQ_CHECK_PAGENO] // 단일 트리거 PLC_PAGE_CHANGE_REQ(0x08) ON 대기 > sb[SEQ_CHECK_RECIPENO_COMPLETE] // Recipe 먼저 확정/전환 > sb[SEQ_CHECK_PAGENO_COMPLETE] // 그다음 Page 확정 #region TeachingRun 시퀸스의 중간 용접리스트 생성 구간 > sb[SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ] // 1 파라미터 세팅 - TeachingRun 노드 참고 > sb[SEQ_DO_ID_UPLOAD_WELDING_LIST_SEQ] // 2 리스트 업로드 (서브시퀀스) - TeachingRun 노드 참고 > sb[SEQ_GET_RESULT_UPLOAD_WELDING_LIST_SEQ] // 3 업로드 결과 확인 - TeachingRun 노드 참고 #endregion > sb[SEQ_CHECK_WELDING_START_READ_BIT] // while read bit (M70516 대기) > sb[SEQ_PRE_SCOUT_CHECK] // 260622: M70509 ON → pre scout > sb[SEQ_PRE_SCOUT_SET_PARAMS] > sb[SEQ_PRE_SCOUT_RUN] > sb[SEQ_PRE_SCOUT_DONE] // 0.1s 후 용접 > sb[SEQ_WAIT_WELDING_START] // 4 업로드 완료 PLC 완료 신호 및 BUSY 신호로 변경 > sb[SEQ_EXECUTE_WELDING] // 5 Scanner 실행 (실제 용접, 완료비트는 보류) > sb[SEQ_POST_SCOUT_CHECK] // 260622: M70509 ON → post scout > sb[SEQ_POST_SCOUT_SET_PARAMS] > sb[SEQ_POST_SCOUT_RUN] > sb[SEQ_POST_SCOUT_DONE] // 0.1s 후 완료비트 > sb[SEQ_WRITE_WELD_COMPLETE] // 용접 완료 비트 송신 → 다음 시퀀스 > sb[SEQ_CHECK_CYCLE_STOP] > sb[SEQ_SAVE_RESULT_DATA] > sb[SEQ_WRITE_COMPLETION_PLC_BIT] // PLC 비트 완료 신호 보내기 > sb[SEQ_COMPLETE] // 다시 enter로 올리기 > sb.Final); // 260622: Scout 스킵 분기 (M70509 OFF → 기존과 동일하게 용접만) sb.Flows(sb[SEQ_PRE_SCOUT_CHECK] > sb[SEQ_WAIT_WELDING_START]); sb.Flows(sb[SEQ_POST_SCOUT_CHECK] > sb[SEQ_WRITE_WELD_COMPLETE]); sb.Flows(sb[SEQ_CHECK_CYCLE_STOP] > sb[SEQ_CHECK_PAGENO]); // 루프백: 매 사이클 Page Change bit(0x08) 대기부터 재확인 sb.Flows(sb[SEQ_CHECK_INIT_CONDITION] > sb[SEQ_SET_PARAMS_UPLOAD_WELDING_LIST_SEQ]); // 260621: Resume 시 핸드셰이크 스킵 sb.Flows(sb[SEQ_CHECK_INIT_CONDITION] > sb[SEQ_COMPLETE] > sb.Final); sb.OnStopped(OnStopped); sb.OnForceError(OnForceError); sb.Draw("SeqAutoRunPTOP.pdf"); return sb; } private bool InitSequenceVariable(SequenceContext context) { Log.I($"[InitSequenceVariable] 캔버스 핸들 확보 + 용접대상 오브젝트 선택 시작"); var mainPage = CommonHelper.GetMainPage(); _ScannerCanvasUserCtrl = mainPage.ScannerCanvasCtrlTeaching; _WeldingScannerListNo = eListNo.LIST1; if (_InitParam.IsObjectSelectAll) { //_SelectedObjectList = _ScannerCanvasUserCtrl.Model.GetLayer(_CurrentPageNo).Objects; _SelectedObjectList = _ScannerCanvasUserCtrl.Model.ActiveLayer.Objects; } else { _SelectedObjectList = _ScannerCanvasUserCtrl.Canvas.Model.SelectedObjects; } //Status.GetProcessingWeldingData().WeldingScannerListNo = _WeldingScannerListNo; //Status.GetProcessingWeldingData().SelectedDrawObjectList = _SelectedObjectList; Log.I($"[InitSequenceVariable] 변수 초기화 완료 (선택 오브젝트 {_SelectedObjectList.Count()}개)"); return true; } public SeqAutoRunPTOP() { _SystemParameter = Application.Current.AuroraEnv().SettingEntry[CommonSettingIds.SystemParameter] as SystemParameter; } private SequenceActionResult Sequence_Enter(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[Sequence_Enter] 시퀀스 진입 - 레시피 초기화 및 장비 연결"); // 디버그 끝내고 삭제 MessageUtil.ShowToast("Sequence_Enter"); //Thread.Sleep(1000); // 260618 RecipeManager 미리 확보(Recipe Change 노드 CheckRecipeNoComp에서 사용) _RecipeManager = Application.Current.AuroraEnv().RecipeManager; // 활성화된레시피ID var activatedRecipeId = _RecipeManager.ActivatedRecipe().Id; var recipeName = RecipeHelper.SearchRecipeItem(activatedRecipeId); _InitParam = param.Param as SeqTeachingRun_InitParam; // 260511 - param NULL Status.GetProcessingWeldingData().AutoRunStartTime = DateTime.Now; Status.GetProcessingWeldingData().Clear(); Log.I($"[Sequence_Enter] 레시피 [{activatedRecipeId}], {recipeName.ToString()} 활성화, 장비 초기화 완료"); //레시피의 현재 변수 false인지 여부 확인할 것 . #region [TEST_BYPASS] Alarm Check - Init Variable /* if (InitSequenceVariable(context) == false) { AlarmManager.Alarm(AlarmCode.ALARM_SEQUENCE_TeachingRun_InitVariableError); return null; } */ InitSequenceVariable(context); #endregion #region [로그계측 2026-09-16] 활성 레시피 페이지별 객체 수 1회 스캔 (원복: 이 region 삭제) // 객체가 0개인 레이어를 PLC 가 지정하면 SetUploadWeldingList_SeqParams 가 시퀀스를 중단한다. // 그 사실을 운전이 멈춘 뒤가 아니라 시작 시점에 알 수 있도록 페이지별 객체 수를 한 번만 남긴다. // 읽기 전용이며, 조회가 실패해도 계측만 건너뛰고 운전은 그대로 진행한다. _CycleCount = 0; try { string pageSummary = Application.Current.Dispatcher.Invoke(() => string.Join(", ", _ScannerCanvasUserCtrl.Model.Layers .Select((layer, idx) => $"P{idx + 1}:{layer.Objects.Count()}"))); Log.I($"[로그계측][Enter] 활성 레시피 페이지별 객체 수 — {pageSummary} (0 인 페이지는 그 페이지 지시 시 운전이 멈춘다)"); } catch (Exception ex) { Log.W($"[로그계측][Enter] 페이지별 객체 수 조회 실패({ex.GetType().Name}: {ex.Message}) — 계측만 생략, 운전에는 영향 없음"); } #endregion // 장비 체크 InitDev(context); _PLC.WriteBit(eBitOutput.PC_AUTO_STATUS, true); // 음극 (양극 P 미사용) #region [TEST_BYPASS] Alarm Check - DeFvice Ready /* if (!CheckDeviceReady(context)) { //Check Device AlarmManager.Alarm(AlarmCode.ALARM_SEQUENCE_AutoRun_DeviceNotReady); Log.E("The devices are not active."); return null; } */ #endregion Log.I($"[Sequence_Enter] 장비 초기화 완료"); Thread.Sleep(5); // 추후 제거 Log.I($"[Sequence_Enter] 완료 → CheckInitCondition 이동"); return SequenceAction.Ok(); } private string CheckInitCondition(SequenceContext context, string id, ICollection branches, SequenceActionResult param) { Log.I($"[CheckInitCondition] 초기 조건 확인 중"); Status.SetWorkingPosition(eWorkingPosition.CHECKING_CONDITION); //if (!CheckCondition(context)) //{ // AlarmManager.Alarm(AlarmCode.ALARM_SEQUENCE_AutoRun_CheckInitCondition); // Log.E("Condition is not in state to start sequence."); // context.PutValue(ContextKey.KEY_SEQUENCE_RESULT, false); // return SEQ_COMPLETE; //} Thread.Sleep(5); Log.I($"[CheckInitCondition] 조건 확인 완료 → ResyncRecipePage 이동"); return SEQ_RESYNC_RECIPE_PAGE; } private int _CenterBeamPower; 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 에코. private SequenceActionResult CheckRecipeNoComp(SequenceContext context, string id, SequenceActionResult param) { // Recipe No 1회 읽기 (Page Change bit와 함께 래치되어 안정적이라고 가정 → 노이즈 검증 생략) plcRecipeNo = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data; Log.I($"[CheckRecipeNoComp] PLC_RECIPE_NO: [{plcRecipeNo}]"); // 0이면 레시피 전환 의사 없음 → 현 Recipe 유지하고 Page로 if (plcRecipeNo == 0) { Log.I("[CheckRecipeNoComp] PLC_RECIPE_NO=0 → 레시피 전환 생략, 현 Recipe 유지 → CheckPageNoComp 이동"); return SequenceAction.Ok(); } // 등록 레시피 조회(.Id 1:1, 하드코딩 금지). 미등록이면 전환 생략(정지 안 함) var recipe = _RecipeManager.Recipes().FirstOrDefault(x => x.Id == plcRecipeNo); if (recipe == null) { Log.W($"[CheckRecipeNoComp] {plcRecipeNo}번 레시피 미등록 → 전환 생략, 현 Recipe 유지"); return SequenceAction.Ok(); } // 이미 활성 레시피면 재활성화·Model reload는 생략하되, PC_RECIPE_NO 에코는 반드시 유지. // (정지 시 OnSeqActionStop이 D70002=0으로 지우므로, 동일 레시피 재진입에서 에코를 건너뛰면 PLC가 0/이전값을 봄) var preActivated = _RecipeManager.ActivatedRecipe(); if (preActivated != null && preActivated.Id == recipe.Id) { bool bRecipeCheck = true; int count = 0; while(bRecipeCheck) { plcRecipeNo = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data; recipe = _RecipeManager.Recipes().FirstOrDefault(x => x.Id == plcRecipeNo); // 널 가드: 재폴링 중 0/미등록이면 recipe==null → 전환하지 않고 계속 재확인 if (recipe != null && recipe.Id != preActivated.Id) { bRecipeCheck = false; break; } Thread.Sleep(200); if(count++ < 3) // 3번 체크하자 { Log.I($"[CheckRecipeNoComp] RecipeCheckCount : {count} -> {recipe?.Id}"); continue; } // 끝까지 동일/무효 → 현 활성 레시피(preActivated) 번호로 에코 (null-safe) _PLC.WriteWord(eWordOutput.PC_RECIPE_NO, (short)preActivated.Id); Log.I($"[CheckRecipeNoComp] 이미 활성 레시피({preActivated.Id}) → 재활성화 생략, PC_RECIPE_NO 에코({preActivated.Id}) 유지 → CheckPageNoComp 이동"); return SequenceAction.Ok(); } } // ── 실제 전환 ── _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) { Log.W("[CheckRecipeNoComp] ActivateRecipe 반영 대기 TIMEOUT..."); actStart = DateTime.Now; } Thread.Sleep(10); } if (context.Stopped) return null; // ★ 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)"); // 에코 (실제 전환 시에만). PC_RECIPE_CHANGE_COMPLETE는 미사용(PLC가 감시 안 함). _PLC.WriteWord(eWordOutput.PC_RECIPE_NO, plcRecipeNo); Log.I($"---> [CheckRecipeNoComp] Recipe 전환 완료 - Set PC_RECIPE_NO: [{plcRecipeNo}] → CheckPageNoComp 이동"); 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++; if (AbortIfResyncTimedOut()) { Log.W($"[CheckRecipeNoComp] 재동기 상한 {RESYNC_TIMEOUT_SEC}s 초과 — 수렴 미완으로 포기 " + $"(재시도 {retry}회차, 직전 회차 에코가 남아 있을 수 있음)"); return SequenceAction.Ok(); } // ── 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 #region 260813 private SequenceActionResult ResyncRecipePage(SequenceContext context, string id, SequenceActionResult param) { if (ResumeMode) { Log.I("[ResyncRecipePage] ResumeMode=true → 재동기 생략 → CheckPageNo 이동"); return SequenceAction.Ok(); } if (_PLC == null) { Log.E("[ResyncRecipePage] _PLC instance is null! 재동기 불가"); return null; } if (_PLC.IsSimul) { Log.I("[ResyncRecipePage] 시뮬레이션 → 재동기 생략 → CheckPageNo 이동"); return SequenceAction.Ok(); } var recipeRead = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO); var pageRead = _PLC.ReadWord(eWordInput.PLC_PAGE_NO); bool recipeValid = recipeRead.err == Hermes.E1.OK && recipeRead.data != 0 && _RecipeManager.Recipes().Any(x => x.Id == recipeRead.data); if (pageRead.err != Hermes.E1.OK || pageRead.data < 1 || !recipeValid) { Log.W($"[ResyncRecipePage] PLC 워드가 재동기 조건 미충족 " + $"(RecipeNo:{recipeRead.data}/err:{recipeRead.err}/유효:{recipeValid}, " + $"PageNo:{pageRead.data}/err:{pageRead.err}) → 재동기 생략, CheckPageNo 의 REQ 대기로 진행"); return SequenceAction.Ok(); } Log.I($"[ResyncRecipePage] 재기동 재동기 시작 — PLC 값(RecipeNo:{recipeRead.data}, " + $"PageNo:{pageRead.data})으로 Recipe/Page 확정 후 에코"); try { Application.Current.Dispatcher.Invoke(() => RecipeHelper.OnActiveReloaded()); Log.I("[ResyncRecipePage] Model.Layers 선(先) reload 완료 (UI thread)"); } catch (Exception ex) { Log.E($"[ResyncRecipePage] Model.Layers 선(先) reload 실패 ({ex.GetType().Name}: {ex.Message}) " + $"→ 재동기 생략, CheckPageNo 의 REQ 대기로 진행"); MessageUtil.ShowErrorToast("레시피 로드 실패\n재동기를 건너뜁니다. 레시피를 확인해주세요."); return SequenceAction.Ok(); } _ResyncStartTime = DateTime.Now; _ResyncAborted = false; _InResyncNode = true; try { if (CheckRecipeNoComp(context, id, param) == null) return null; if (_ResyncAborted) return ResyncGiveUp("Recipe"); if (CheckPageNoComp(context, id, param) == null) return null; if (_ResyncAborted) return ResyncGiveUp("Page"); } finally { _InResyncNode = false; } Log.I($"---> [ResyncRecipePage] 재동기 완료 (RecipeNo:{plcRecipeNo}, PageNo:{plcPageNo}, " + $"PC_Index:{_CurrentPageNo}) → CheckPageNo 이동"); return SequenceAction.Ok(); } private bool AbortIfResyncTimedOut() { if (!_InResyncNode) return false; if (DateTime.Now.Subtract(_ResyncStartTime).TotalSeconds <= RESYNC_TIMEOUT_SEC) return false; _ResyncAborted = true; return true; } private SequenceActionResult ResyncGiveUp(string stage) { Log.W($"[ResyncRecipePage] {stage} 수렴 실패(상한 {RESYNC_TIMEOUT_SEC}s 초과) → 재동기 포기, " + $"CheckPageNo 의 REQ 대기로 진행 " + $"(직전 회차에 쓴 에코/내부 상태는 남아 있을 수 있음 — RecipeNo:{plcRecipeNo}, PageNo:{plcPageNo})"); return SequenceAction.Ok(); } #endregion // 페이지 체크 - 페이지만 감시 AutoRunStatus는 실제 상위에서 동작 체크하고 시퀸스 동작한다. private SequenceActionResult CheckPageNo(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[CheckPageNo] PLC_PAGE_CHANGE_REQ 신호 대기 중..."); Status.SetWorkingPosition(eWorkingPosition.LOADING_CELL); #region [로그계측 2026-09-16] REQ 진입 시점 상태 (원복: 이 region 삭제) // 이 노드는 REQ 가 ON 이면 통과하는 레벨 판정이다(아래 :726). 상승엣지를 보지 않으므로, // CheckCycleStop 루프백 시점에 PLC 가 아직 REQ 를 내리지 않았다면 대기 없이 즉시 통과한다. // 그 경우 같은 페이지를 다시 확정·용접할 수 있어, 엣지 판정 전환이 필요한지 가리기 위해 // 진입 시점 값을 남긴다. 판정은 하지 않고 기록만 한다. _ReqAlreadyOnAtEntry = false; if (_PLC != null) { var reqAtEntry = _PLC.ReadBit(eBitInput.PLC_PAGE_CHANGE_REQ); _ReqAlreadyOnAtEntry = reqAtEntry.err == Hermes.E1.OK && reqAtEntry.data; if (_ReqAlreadyOnAtEntry) Log.W($"[로그계측][CheckPageNo] 진입 시점에 REQ 가 이미 ON — 대기 없이 통과 (직전 사이클 #{_CycleCount})"); else Log.I($"[로그계측][CheckPageNo] 진입 시점 REQ OFF — 정상 대기 진입 (직전 사이클 #{_CycleCount})"); } #endregion // 디버그 끝내고 삭제 //MessageUtil.ShowToast("CheckPageNo -> Wait..."); //Thread.Sleep(1000); // 1. PLC_PAGE_CHANGE_REQ (bit 8) ON 대기 var interfaceStartTime = DateTime.Now; #region [진단계측 2026-08-26] page5→6 REQ 미발행 추적 (원복: 이 region + 아래 2개 삽입부 + 헬퍼 2개 삭제) // 배경: 2026-08-26 로그에서 레시피 23/24 의 page 5 Resume 후 PLC_PAGE_CHANGE_REQ 가 오지 않아 교착. // 레시피 21/22 는 같은 5→6 전환이 2.4~3.1초 만에 성공한다(그 구간만 REQ 가 OFF 로 떨어진다). // 기존 로그는 "TIMEOUT..." 한 줄뿐이라 대기 중 PLC 워드/비트가 어떻게 움직이는지 알 수 없었다. // 특히 PLC_PAGE_NO 가 6 으로 올라갔는데 REQ 만 안 오는지, 5 에서 멈춘 건지 구분이 안 된다. // · PAGE:6 + REQ:0 → PLC 가 페이지는 올렸으나 REQ 발행 조건 미충족 // · PAGE:5 + REQ:0 → PLC 스텝이 page 5 에서 아예 진행하지 않음 // 방식: REQ 폴링(10ms)은 그대로 두고, 전체 스냅샷은 1초 주기로만 뜬다(MC 개별읽기 부하 억제). // 값이 바뀔 때만 남기므로 정상 통과(0~3초) 구간에서는 1~2줄이고 로그 폭주가 없다. // 분석: docs/분석/2026/08/26/Page5_Page6_REQ_미발행_PLC문의_260826.md var diagWaitStart = DateTime.Now; var diagNextPoll = DateTime.Now; string diagLastSnapshot = null; Log.W($"[진단계측][CheckPageNo] 대기 진입 {BuildHandshakeSnapshot()}"); #endregion while (!context.Stopped) { //plc if (_PLC == null) { Log.E("[CheckPageNo] _PLC instance is null! Handshake cannot proceed."); return null; } if (_PLC.ReadBit(eBitInput.PLC_PAGE_CHANGE_REQ).data) { Log.I("---> [CheckPageNo] Check OK -> PLC_PAGE_CHANGE_REQ : ON"); // 임시 미리 한 번 더 읽기 260515 //plcPageNo = _PLC.ReadWord(eWordInput.PLC_PAGE_NO).data; //Log.I($"---> [CheckPageNo] Check OK -> PLC_PAGE_CHANGE_REQ : {plcPageNo}"); // 디버그 끝내고 삭제 //MessageUtil.ShowToast("PLC_PAGE_CHANGE_REQ : ON"); //Thread.Sleep(1000); break; } else { //Log.I("[CheckPageNo] Detected PLC_PAGE_CHANGE_REQ: f"); } if (DateTime.Now.Subtract(interfaceStartTime).TotalSeconds > PLC_TIMEOUT) { // [진단계측 2026-08-26] 기존 한 줄에 스냅샷을 붙인다. 원복 시 뒤쪽 보간만 지우면 된다. Log.W($"[CheckPageNo] Waiting for PLC_PAGE_CHANGE_REQ -> TIMEOUT... " + $"(누적 {DateTime.Now.Subtract(diagWaitStart).TotalSeconds:F0}s) {BuildHandshakeSnapshot()}"); interfaceStartTime = DateTime.Now; } #region [진단계측 2026-08-26] 1초 주기 변화 감지 (원복: 이 region 삭제) if (DateTime.Now >= diagNextPoll) { diagNextPoll = DateTime.Now.AddSeconds(1); var snapshot = BuildHandshakeSnapshot(); if (snapshot != diagLastSnapshot) { Log.W($"[진단계측][CheckPageNo] 변화 " + $"(대기 {DateTime.Now.Subtract(diagWaitStart).TotalSeconds:F1}s) {snapshot}"); diagLastSnapshot = snapshot; } } #endregion Thread.Sleep(10); } if (context.Stopped) return null; // [진단계측 2026-08-26] REQ 수신 시점 확정값. 21/22(성공) 대비 23/24(실패) 비교의 기준선이 된다. Log.W($"[진단계측][CheckPageNo] REQ 수신 " + $"(대기 {DateTime.Now.Subtract(diagWaitStart).TotalSeconds:F2}s) {BuildHandshakeSnapshot()}"); Log.I($"[CheckPageNo] PLC_PAGE_CHANGE_REQ ON 감지 → CheckPageNoComp 이동"); return SequenceAction.Ok(); } #region [진단계측 2026-08-26] 핸드셰이크 스냅샷 헬퍼 (원복: 이 region 통째로 삭제) // PLC 입력 / PC 출력 되읽기를 한 줄로 만든다. 개별 MC 읽기 16회라 호출 주기는 1초 이상으로 유지할 것. // 읽기 실패는 값 대신 E 로 표기해 "0 인지 못 읽은 건지"를 구분한다. private string BuildHandshakeSnapshot() { if (_PLC == null) return "PLC=null"; try { var req = _PLC.ReadBit(eBitInput.PLC_PAGE_CHANGE_REQ); var plcAuto = _PLC.ReadBit(eBitInput.PLC_AUTO_STATUS); var plcAReq = _PLC.ReadBit(eBitInput.PLC_AUTO_REQ); var bit15 = _PLC.ReadBit(eBitInput.PLC_WELD_READY_REQ); var trigReq = _PLC.ReadBit(eBitInput.PLC_WELD_TRIGGER_REQ); var cmpAck = _PLC.ReadBit(eBitInput.PLC_WELD_COMPLETE_ACK); var plcPage = _PLC.ReadWord(eWordInput.PLC_PAGE_NO); var plcRcp = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO); var theta = _PLC.ReadWord(eWordInput.HEAD_THETA_POSITION); var pcAuto = _PLC.ReadOutBit(eBitOutput.PC_AUTO_STATUS); var pcAck = _PLC.ReadOutBit(eBitOutput.PC_WELD_READY_ACK); var pcPgCmp = _PLC.ReadOutBit(eBitOutput.PC_PAGE_CHANGE_COMPLETE); var pcWldCmp = _PLC.ReadOutBit(eBitOutput.PC_WELD_COMPLETE); var pcBusy = _PLC.ReadOutBit(eBitOutput.PC_WELD_BUSY); var pcPage = _PLC.ReadOutWord(eWordOutput.PC_PAGE_NO); var pcRcp = _PLC.ReadOutWord(eWordOutput.PC_RECIPE_NO); return $"PLC[REQ:{DiagBit(req)} PAGE:{DiagWord(plcPage)} RCP:{DiagWord(plcRcp)} " + $"AUTO:{DiagBit(plcAuto)} AUTO_REQ:{DiagBit(plcAReq)} bit15:{DiagBit(bit15)} " + $"TRIG_REQ:{DiagBit(trigReq)} CMP_ACK:{DiagBit(cmpAck)} THETA:{DiagWord(theta)}] " + $"PC[AUTO:{DiagBit(pcAuto)} READY_ACK:{DiagBit(pcAck)} PAGE_CMP:{DiagBit(pcPgCmp)} " + $"WELD_CMP:{DiagBit(pcWldCmp)} BUSY:{DiagBit(pcBusy)} " + $"PAGE:{DiagWord(pcPage)} RCP:{DiagWord(pcRcp)}] " + $"ResumeMode:{(ResumeMode ? "1" : "0")}"; } catch (Exception ex) { return $"스냅샷 실패({ex.GetType().Name}: {ex.Message})"; } } private static string DiagBit((int err, bool data) read) => read.err == Hermes.E1.OK ? (read.data ? "1" : "0") : "E"; private static string DiagWord((int err, short data) read) => read.err == Hermes.E1.OK ? read.data.ToString() : "E"; #endregion #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(); while (!context.Stopped) { //Thread.Sleep(1000); // 2. PLC 데이터 읽기 및 인덱스 변환 (1,2 -> 0,1) //plcPageNo = _PLC.ReadWord(eWordInput.PLC_PAGE_NO).data; plcPageNoList.Add(_PLC.ReadWord(eWordInput.PLC_PAGE_NO).data); plcPageNo = plcPageNoList[checknum]; checknum++; _CurrentPageNo = plcPageNo - 1; // 1-based to 0-based // page index // 디버그 끝내고 삭제 //MessageUtil.ShowToast($"ReadWord -> {plcPageNo}"); Thread.Sleep(200); string cellId = _PLC.CellId(); string lotId = _PLC.LotId(); Status.GetProcessingWeldingData().CellId = cellId; Status.GetProcessingWeldingData().LotId = lotId; Log.I($"[CheckPageNoComp]페이지 확인 [{checknum}/3] - PLC_Page: [{plcPageNo}], PC_Index: [{_CurrentPageNo}], CellId: [{cellId}], LotId: [{lotId}]"); if (checknum > 2) { // 260618: 상한 하드코딩(>2) 제거 → 활성 Recipe의 페이지 수(레이어 수)로 가변 검증 (Recipe별 2~5페이지) // B-1: Model.Layers 접근을 UI 스레드로 직렬화 (레시피 reload와의 race 방지) int recipePageCount = Application.Current.Dispatcher.Invoke(() => _ScannerCanvasUserCtrl.Model.Layers.Count()); if (plcPageNo > recipePageCount || plcPageNo < 1) { // 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}회차, 시퀀스 중단하지 않음"); if (invalidPageWait == 1 || invalidPageWait % TOAST_EVERY == 0) MessageUtil.ShowErrorToast($"PLC Page No 이상: {plcPageNo}\n모니터링 확인해주세요."); plcPageNoList.Clear(); checknum = 0; Thread.Sleep(3000); continue; } else { //for (int i = 0; i > plcPageNoList.Count; i++) //{ Log.E($"[CheckPageNoComp] List {plcPageNoList[0]}/{plcPageNoList[1]}/{plcPageNoList[2]}"); //} break; } } } Thread.Sleep(5); // 3. 에코 및 완료 비트(Ack) ON _PLC.WriteWord(eWordOutput.PC_PAGE_NO, plcPageNo); Log.I($"---> [CheckPageNoComp] Set PC_PAGE_NO: {plcPageNo}"); //Thread.Sleep(1000); //MessageUtil.ShowErrorToast($"WriteWord -> {plcPageNo}"); //Thread.Sleep(1000); // 에코한 PC_PAGE_NO(plcPageNo)와 현재 PLC_PAGE_NO 비교 → 같으면 ACK ON, 다르면 OFF short curPlcPageNo = _PLC.ReadWord(eWordInput.PLC_PAGE_NO).data; bool pageMatched = (plcPageNo == curPlcPageNo); _PLC.WriteBit(eBitOutput.PC_PAGE_CHANGE_COMPLETE, pageMatched); Log.I($"[CheckPageNoComp] PC_PAGE_NO({plcPageNo}) == PLC_PAGE_NO({curPlcPageNo}) ? → PC_PAGE_CHANGE_COMPLETE {pageMatched}"); //Thread.Sleep(5); // UI 페이지 업데이트 알림 //if (Application.Current != null && Application.Current.AuroraEnv() != null) { Application.Current.AuroraEnv().EventPublisher.Publish(new EventMessage("", CommonEventIds.EVENT_AUTO_RUN_UPDATE_PAGENUM, (int)_CurrentPageNo)); } 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) { Status.SetWorkingPosition(eWorkingPosition.LOADING_CELL); // 260818: Recipe/Page 최종 확인 (원복: 이 outer while 과 아래 "최종 확인" 블록을 제거하고 // 안쪽 블록의 들여쓰기만 되돌리면 260806 상태가 된다) // 문제(260812 로그 15:17:29): CheckRecipeNoComp 가 Recipe 를 22로 확정하고 나온 뒤 // Page 수렴 중 PLC 가 Page 를 6→2로 바꿨다. Page 만 재수렴하고 넘어갔으나 PLC 는 그때 // Recipe 도 함께 바꿨을 수 있고, PC 는 옛 값(22)을 에코한 채였다. PLC 게이트는 두 워드를 // 모두 보므로 PLC_WELD_TRIGGER_REQ 가 끝내 오지 않고 타임아웃만 반복됐다. // → Page 수렴이 끝나면 PLC 의 Recipe/Page 를 다시 읽어 PC 확정값과 둘 다 같은지 확인하고, // 어긋났으면 Recipe 부터 다시 확정(CheckRecipeNoComp)한 뒤 Page 를 재수렴한다. int joint = 0; while (!context.Stopped) { joint++; Log.I("[CheckPageNoComp] PLC_PAGE_NO 수렴 시작"); int retry = 0; while (!context.Stopped) { if (retry > 0) Thread.Sleep(ECHO_RETRY_MS); retry++; if (AbortIfResyncTimedOut()) { Log.W($"[CheckPageNoComp] 재동기 상한 {RESYNC_TIMEOUT_SEC}s 초과 — 수렴 미완으로 포기 " + $"(재시도 {retry}회차, 직전 회차 에코가 남아 있을 수 있음)"); return SequenceAction.Ok(); } // ── 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. 되읽어 수렴 확인 ── Thread.Sleep(200); 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; // ── 5. 최종 확인: Recipe/Page 를 다시 읽어 둘 다 PC 확정값과 같아야 넘어간다 ── // 시뮬레이션은 PLC 워드가 항상 0이라 최종 확인 대상이 아니다. if (_PLC.IsSimul) break; short curRecipe = _PLC.ReadWord(eWordInput.PLC_RECIPE_NO).data; short curPage = _PLC.ReadWord(eWordInput.PLC_PAGE_NO).data; if (curRecipe == plcRecipeNo && curPage == plcPageNo) { Log.I($"---> [CheckPageNoComp] Recipe/Page 최종 확인 완료 ({joint}회차) - " + $"PC_RECIPE_NO: [{plcRecipeNo}], PC_PAGE_NO: [{plcPageNo}]"); break; } if (joint == 1 || joint % ECHO_TOAST_EVERY == 0) MessageUtil.ShowErrorToast($"확정 후 Recipe/Page 변경 감지\n" + $"Recipe {plcRecipeNo}→{curRecipe}, Page {plcPageNo}→{curPage}, 재동기화 중"); Log.W($"[CheckPageNoComp] 최종 확인 불일치 - Recipe PLC:[{curRecipe}]/에코:[{plcRecipeNo}], " + $"Page PLC:[{curPage}]/에코:[{plcPageNo}] — Recipe 재확정 후 Page 재수렴 {joint}회차"); // Recipe 부터 다시 확정한다(ActivateRecipe + Model.Layers reload + PC_RECIPE_NO 재에코). // 레시피가 바뀌면 Layers(페이지 수)도 바뀌므로 Page 는 위에서 다시 수렴한다. if (CheckRecipeNoComp(context, id, param) == null) return null; } 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은 유지한다. if (_InResyncNode) { Log.I("[CheckPageNoComp] 재동기 경로 → PC_PAGE_CHANGE_COMPLETE 쓰기 생략 (REQ 미수신 상태)"); } else { _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)"); //if (ApplicationExt.IsSimulation) return true; } private SequenceActionResult SetUploadWeldingList_SeqParams(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[SetUploadWeldingList_SeqParams] PageNo [{_CurrentPageNo}] 용접 리스트 레이어 선택 시작"); Status.SetWorkingPosition(eWorkingPosition.UPLOADING_WELD_LIST); #region 페이지 번호에 따라 용접 리스트 레이어 선택 됨. 아래 데이터가 정확한지는 확인 필요 // 페이지 번호에 따라 용접 리스트(레이어) 선택 int totalLineCount = 0; // B-1: Model.Layers 접근(레이어 선택)을 UI 스레드로 직렬화 (레시피 reload와의 race 방지) Application.Current.Dispatcher.Invoke(() => { int layerCount = _ScannerCanvasUserCtrl.Model.Layers.Count(); if (layerCount > _CurrentPageNo && _CurrentPageNo >= 0) { var layer = _ScannerCanvasUserCtrl.Model.Layers[_CurrentPageNo]; _SelectedObjectList = layer.Objects; Log.I($"[SetUploadWeldingList] Selected PageNo [{_CurrentPageNo}] → Layer Name: [{layer.No}], Object Count: [{_SelectedObjectList.Count()}]"); } else { 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) { Log.E("[SetUploadWeldingList] No welding objects found! Sequence will fail."); return null; } // 공통 로그 - if/else 밖으로 이동 var groups = _SelectedObjectList.OfType().ToList(); totalLineCount = groups.Sum(g => g.SubObjects.OfType().Count()); Log.I($"[SetUploadWeldingList] Group Count: [{groups.Count}], Total Line Count: [{totalLineCount}]"); for (int i = 0; i < groups.Count; i++) { int lineCount = groups[i].SubObjects.OfType().Count(); Log.I($"[SetUploadWeldingList] Group[{i}] Line Count: [{lineCount}]"); } #endregion // 각도(D70510) 기반 Front/Down offset 선택 (기존 단일 CellCenterOffset 대체) var thetaOffsetSetting = Application.Current.AuroraEnv().SettingEntry[SettingIds.LaserInterlock] as LaserInterlockSetting; var (angleOffX, angleOffY) = LaserAngleOffsetSelector.Select(_PLC, _SystemParameter, thetaOffsetSetting, Log, "ExecuteWelding"); SeqUploadWeldingList_InitParam seqParams = new SeqUploadWeldingList_InitParam() { NeedSetLaserTiming = _InitParam.NeedSetLaserTiming, IncludeListStartEnd = true, CellMeasureCenterOffsetX = angleOffX + _InitParam.CellCenterOffsetX, CellMeasureCenterOffsetY = angleOffY + _InitParam.CellCenterOffsetY, SelectedDrawObjectList = _SelectedObjectList, // 이게 중요 포인트 WeldingScannerListNo = _WeldingScannerListNo, }; //Thread.Sleep(1000); // 추후 제거 Log.I($"[SetUploadWeldingList_SeqParams] 용접 파라미터 세팅 완료 → UploadWeldingList 서브시퀀스 실행"); return SequenceAction.Ok(seqParams); } private SequenceActionResult GetUploadWeldingList_SeqResult(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[GetUploadWeldingList_SeqResult] 용접 리스트 업로드 결과 확인 중"); Status.SetWorkingPosition(eWorkingPosition.UPLOADING_WELD_LIST); var resultOutParam = param.Param as SeqUploadWeldingList_OutParam; if (resultOutParam.Success == false) { //Thread.Sleep(1000); // 추후 제거 Log.E($"[GetUploadWeldingList_SeqResult] 업로드 실패 → 시퀀스 중단"); return null; } //Thread.Sleep(1000); // 추후 제거 Log.I($"[GetUploadWeldingList_SeqResult] 업로드 성공 → WeldingStartReadBit 이동"); return SequenceAction.Ok(); } // 260513 추가노드 private SequenceActionResult WeldingStartReadBit(SequenceContext context, string id, SequenceActionResult param) { Log.I($"---> [WeldingStartReadBit] PLC_WELD_TRIGGER_REQ 신호 대기 중... "); // 1. PLC 용접 요청 대기 var interfaceStartTime = DateTime.Now; while (!context.Stopped) { if (_PLC == null) { Log.E("[WeldingStartReadBit] _PLC instance is null!"); return null; } if (_PLC.ReadBit(eBitInput.PLC_WELD_TRIGGER_REQ).data) { Log.I($"[WeldingStartReadBit] Check OK -> PLC_WELD_TRIGGER_REQ : ON"); // 디버그 끝내고 삭제 MessageUtil.ShowToast("PLC_WELD_TRIGGER_REQ : ON"); //Thread.Sleep(1000); break; } if (DateTime.Now.Subtract(interfaceStartTime).TotalSeconds > PLC_TIMEOUT) { Log.W($"[WeldingStartReadBit] PLC_WELD_TRIGGER_REQ -> TIMEOUT..."); interfaceStartTime = DateTime.Now; } Thread.Sleep(10); } Log.I($"---> [WeldingStartReadBit] PLC_WELD_TRIGGER_REQ ON 감지 → WaitWeldingStart 이동"); return SequenceAction.Ok(); } private SequenceActionResult WaitWeldingStart(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[---> WaitWeldingStart] 용접 준비 신호 송신 - PageNo: [{plcPageNo}] CurrentPage: {_CurrentPageNo + 1})"); Status.SetWorkingPosition(eWorkingPosition.WAITING_WELD_START); // 양극(P) 미사용 → 모든 페이지 음극 용접 비트로 응답 (PLC 트리거가 음극 PLC_WELD_TRIGGER_REQ라 정합) _PLC.WriteBit((int)eBitOutput.PC_WELD_TRIGGER_ACK, true); Thread.Sleep(200); _PLC.WriteBit((int)eBitOutput.PC_WELD_COMPLETE, false); Thread.Sleep(5); Log.I($"[WaitWeldingStart] Welding READY_ACK=ON / BUSY=ON / COMPLETE=OFF 송신 (PageNo:{plcPageNo})"); // 디버그 끝내고 삭제 //MessageUtil.ShowToast("WaitWeldingStart -> WriteBit True"); //Thread.Sleep(3000); Log.I($"---> [WaitWeldingStart] READY_ACK/BUSY ON, COMPLETE OFF 완료 → ExecuteWelding 이동"); return SequenceAction.Ok(); } // 이곳에서 SeqTeachingRun.cs 를 태운다. (레시피의 WeldingRun 호출 구간) private SequenceActionResult ExecuteWelding(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[ExecuteWelding] 용접 실행 시작 - PageNo: [{plcPageNo}], ListNo: [{_WeldingScannerListNo}]"); // ← 여기 #region 코드 확인 필요 Status.SetWorkingPosition(eWorkingPosition.EXECUTING_WELD); // 현재 시퀸스 위치 // === Head Theta 인터락: 수평/수직(±tol)에서만 발진 허용 (체크박스 사용 시) === var thetaSetting = Application.Current.AuroraEnv().SettingEntry[SettingIds.LaserInterlock] as LaserInterlockSetting; if (thetaSetting != null && thetaSetting.HeadThetaInterlockEnabled) { bool blocked = false; if (_PLC == null || !_PLC.IsActivated) { Log.W("[ExecuteWelding] HeadTheta 인터락 - PLC 미연결/미활성 → 발진 차단"); blocked = true; // fail-closed } else { short thetaRaw = _PLC.ReadWord(eWordInput.HEAD_THETA_POSITION).data; double scale = thetaSetting.HeadThetaPlcScale != 0 ? thetaSetting.HeadThetaPlcScale : 1; // 0 가드 double thetaDeg = thetaRaw / scale; bool okFront = Math.Abs(thetaDeg - thetaSetting.HeadThetaFront) <= thetaSetting.HeadThetaTolerance; bool okDown = Math.Abs(thetaDeg - thetaSetting.HeadThetaDown) <= thetaSetting.HeadThetaTolerance; Log.I($"[ExecuteWelding] HeadTheta raw={thetaRaw}, deg={thetaDeg:F1} (Front={thetaSetting.HeadThetaFront}/Down={thetaSetting.HeadThetaDown}/±{thetaSetting.HeadThetaTolerance})"); // ① 안전 게이트(기존): 안전 구역(Front/Down 부근) 아니면 차단 if (!okFront && !okDown) { Log.W($"[ExecuteWelding] ① HeadTheta 인터락 → Front/Down ±{thetaSetting.HeadThetaTolerance}° 벗어남(deg={thetaDeg:F1}) → 발진 차단"); blocked = true; } else { // ② 레시피별 방향 차단(신규): 체크된 방향의 구역이면 차단 var rl = WeldingCommon.Settings.RecipeLaserInterlock.Load(); if ((rl.Front && okFront) || (rl.Down && okDown)) { Log.W($"[ExecuteWelding] ② 레시피 차단 deg={thetaDeg:F1} Front?{rl.Front}/{okFront} Down?{rl.Down}/{okDown} → 발진 차단"); blocked = true; } } } if (blocked) { try { AlarmManager.Alarm(AlarmCode_PTOP.ALARM_LASER_HEAD_THETA_INTERLOCK); } catch (Exception ex) { Log.E($"[ExecuteWelding] HeadTheta 알람 표시 실패(테이블 미등록?): {ex.Message}"); } return null; // 발진 차단 + 시퀀스 중단 } } // 260626 Busy bit for Laser On Display _PLC.WriteBit((int)eBitOutput.PC_WELD_BUSY, true); _Scanner.SelectCorrectionTable(2); #region 260824 이전 원본 (Core/Ring 2발) //_IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.CoreLaser_Control), true); //_IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.RingLaser_Control), true); //Log.I($"[ExecuteWelding] Laser Gate ON (Core+Ring)"); #endregion #region [로그계측 2026-09-16] 자동 출사 직전 Door 상태 (원복: 이 region 삭제) // ★ 기록 전용이다. 차단하지 않는다. // 자동 경로에는 Door 인터락이 없다(수동 경로에만 존재 — SeqComponentTeachingRun:285/624, // UCScannerLaserControlViewModel:295/387). 그렇다고 여기서 바로 차단을 넣으면, // Door 비트 극성이 실장비 미검증(ISSUE-260902-01 체크리스트 7번)이라 극성이 반대일 경우 // 자동운전이 전면 차단된다. 먼저 판정값만 쌓아 극성을 확인한 뒤 차단 여부를 결정한다. // IsDoorOpen 은 80ms TTL 캐시(AlarmManager.cs:304)라 사이클당 1회 호출은 IO 부담이 없고, // caller 키가 분리돼 있어 수동 경로의 상태 전이 로그와 섞이지 않는다. try { bool doorOpen = AlarmManager.IsDoorOpen("AutoRunExecuteWelding"); Log.I($"[로그계측][DoorCond] 자동 출사 직전 판정 — DoorOpen:{doorOpen} / {AlarmManager.GetDoorStateText()} " + $"(Recipe:{plcRecipeNo}, Page:{plcPageNo}, 사이클 #{_CycleCount + 1}) ※ 기록 전용 — 차단하지 않음"); } catch (Exception ex) { Log.W($"[로그계측][DoorCond] Door 상태 조회 실패({ex.GetType().Name}: {ex.Message}) — 계측만 생략, 출사에는 영향 없음"); } #endregion // 260824 본 레이저 단일 발진기 — 제어권 요청 1발 (구 Core/Ring_Control 자리) _IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.Laser_REMOTE), true); Log.I($"[ExecuteWelding] Laser Gate ON (REMOTE)"); _GateOnTime = DateTime.Now; // [로그계측 2026-09-16] Gate 유지시간 계산용 (원복: 이 줄 삭제) Thread.Sleep(laserEmissionGateDelayMs); // 260518 매뉴얼 기준 ≥10ms, 여유분 포함 =15ms 적용함. Log.I($"[ExecuteWelding] Gate ON Delay {laserEmissionGateDelayMs}ms 완료 → ExecuteList 시작"); // ── SeqTeachingRun SubSequence 실행 ────────────────────────────── DateTime weldingTime = DateTime.Now; Status.GetProcessingWeldingData().WeldingStartTime = weldingTime; _Scanner.ExecuteList(_WeldingScannerListNo); // 레이저 출사 막을 땐 주석 Log.I($"[ExecuteWelding] ExecuteList 시작 - ListNo: {_WeldingScannerListNo}"); _Scanner.WaitForListTaskCompleted(1000);//(_SystemParameter.WeldLinesTaskTimeOut); Log.I($"[ExecuteWelding] WaitForListTaskCompleted 완료"); #region Gate OFF 위치 변경 - 용접 완료 즉시 OFF (TruPulse nano 매뉴얼 기준 ≥10ms) // ↓ 이 두 줄 삭제 #region 260824 이전 원본 (Core/Ring 2발) //_IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.CoreLaser_Control), false); //_IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.RingLaser_Control), false); //Log.I($"[ExecuteWelding] Laser Gate OFF (Core+Ring)"); #endregion // 260824 본 레이저 단일 발진기 — 제어권 해제 1발 _IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.Laser_REMOTE), false); Log.I($"[ExecuteWelding] Laser Gate OFF (REMOTE)"); _LastGateOnMs = DateTime.Now.Subtract(_GateOnTime).TotalMilliseconds; // [로그계측 2026-09-16] (원복: 이 줄 삭제) #endregion // 260626 Busy bit 위치 변경 - Laser On Display bit _PLC.WriteBit((int)eBitOutput.PC_WELD_BUSY, false); Log.I($"[WriteWeldComplete] 용접완료 송신 - BUSY=false (PageNo:{plcPageNo})"); Thread.Sleep(50); _WeldingSpanTime = DateTime.Now.Subtract(weldingTime); _LastWeldSpan = _WeldingSpanTime; // [로그계측 2026-09-16] 이후 _WeldingSpanTime 은 다른 구간 측정에 재사용된다 (원복: 이 줄 삭제) Status.GetProcessingWeldingData().WeldingEndTime = DateTime.Now; //if (_CurrentPageNo == 1) //{ // Log.D(string.Format($"[ExecuteWelding] -->(Wedling TackTime) {_CurrentPageNo} Welding Complete Time : {_WeldingSpanTime.TotalMilliseconds}")); //} //else //{ // Log.D(string.Format($"[ExecuteWelding] -->(Wedling TackTime) {_CurrentPageNo} Welding Complete Time : {_WeldingSpanTime.TotalMilliseconds}")); //} double dExecuteListTime = _Scanner.GetTime(); //Log.I(string.Format($"[ExecuteWelding] ExecuteList Time : {dExecuteListTime * 1000} msec")); // File Format Record Test weldingTime = DateTime.Now; _WeldingSpanTime = DateTime.Now.Subtract(weldingTime); Log.D(string.Format($"[ExecuteWelding] SaveScannerRecordFileForamt Time : {_WeldingSpanTime.TotalMilliseconds}")); weldingTime = DateTime.Now; _Scanner.JumpAbs(0, 0, eFunctionType.COMMAND); //용접 완료 후 CorrectionFile Vision으로 원복 _Scanner.SelectCorrectionTable(1); _WeldingSpanTime = DateTime.Now.Subtract(weldingTime); Log.D($"[ExecuteWelding] JumpAbs + SelectCorrectionTable Time: {_WeldingSpanTime.TotalMilliseconds}ms"); Log.D(string.Format($"[ExecuteWelding] Change SelectCorrectionTable Time : {_WeldingSpanTime.TotalMilliseconds}")); Thread.Sleep(500); #endregion // 260622: 완료비트(TriggerACK off/BUSY off/COMPLETE on) 송신은 post-scout(M70509) 분기 뒤로 분리 → WriteWeldComplete 노드 Log.I($"[ExecuteWelding] 발진 완료 (완료비트 송신 보류) → PostScoutCheck 이동"); return SequenceAction.Ok(); } private string CheckCycleStop(SequenceContext context, string id, ICollection branches, SequenceActionResult param) { Log.I($"[CheckCycleStop] 사이클 중지 여부 확인 중"); Status.SetWorkingPosition(eWorkingPosition.CHECKING_CYCLE); #region [로그계측 2026-09-16] 사이클 요약 1줄 (원복: 이 region 삭제) // 노드별로 흩어진 값을 한 줄로 모은다. 현장 로그 대조 시간을 줄이기 위한 것이며 동작에 관여하지 않는다. // PAGE_CMP: CheckPageNoComp 가 매 사이클 ON 으로 쓰지만(:1076) 이를 끄는 RestoreDeviceStatus 는 // 아래 무조건 루프백 때문에 도달하지 않는다. 실제로 계속 ON 으로 latch 되는지 확인용. // Resume : 아래 ResumeMode 해제 블록보다 앞에 두어야 해제 전 값이 찍힌다. _CycleCount++; try { int objCount = _SelectedObjectList == null ? -1 : _SelectedObjectList.Count(); var pageCmp = _PLC.ReadOutBit(eBitOutput.PC_PAGE_CHANGE_COMPLETE); string pageCmpText = pageCmp.err == Hermes.E1.OK ? (pageCmp.data ? "ON" : "OFF") : "E"; Log.I($"[로그계측][사이클요약] #{_CycleCount} Recipe:{plcRecipeNo} Page:{plcPageNo}(Idx:{_CurrentPageNo}) " + $"객체:{objCount} 용접:{_LastWeldSpan.TotalMilliseconds:F0}ms GateON:{_LastGateOnMs:F0}ms " + $"PAGE_CMP:{pageCmpText} REQ진입시ON:{(_ReqAlreadyOnAtEntry ? "Y" : "N")} " + $"Resume:{(ResumeMode ? "1" : "0")}"); } catch (Exception ex) { Log.W($"[로그계측][사이클요약] 요약 로그 실패({ex.GetType().Name}: {ex.Message}) — 계측만 생략, 운전에는 영향 없음"); } #endregion // 260621: Resume 사이클 1회 완료 → 출력 bit15 OFF + ResumeMode 해제 (이후 정상 루프 복귀) if (ResumeMode) { _PLC.WriteBit((int)eBitOutput.PC_WELD_READY_ACK, false); ResumeMode = false; Log.I("[CheckCycleStop] Resume 사이클 완료 → PC_WELD_READY_ACK OFF, ResumeMode 해제"); } Log.I($"[CheckCycleStop] 사이클 계속 진행 → CheckPageNo 이동"); return SEQ_CHECK_PAGENO; } #region 260622: M70509(Scout Enable) 기반 용접 전/후 Scout // 이미지 파일명 접미어: auto_{recipeNo}_{pageNo}_{pre|post} → 최종 {기존}_auto_{recipeNo}_{pageNo}_{phase}.PNG private string ScoutSaveTag(string phase) => $"auto_{plcRecipeNo}_{plcPageNo}_{phase}"; private SeqScoutNormal_InitParam BuildScoutParams(string saveTag) { return new SeqScoutNormal_InitParam() { IsRealTimeScout = false, ScoutSeqParams = new ScoutSeqParams(VisionRecipeManager.GetFovInfos()[0], eDisplayType.Machine_B), PageIndex = _CurrentPageNo, // 현재 처리 중인 페이지(0-base) SaveTag = saveTag, }; } // ── 용접 전 Scout (M70516 감지 후) ────────────────────────── private string PreScoutCheck(SequenceContext context, string id, ICollection branches, SequenceActionResult param) { bool scout = _PLC.ReadBit(eBitInput.PLC_SCOUT_ENABLE).data; // M70509 Log.I($"[PreScoutCheck] M70509(Scout Enable) = {scout}"); // M70509 ON → pre scout / OFF → 기존과 동일하게 바로 용접 return scout ? SEQ_PRE_SCOUT_SET_PARAMS : SEQ_WAIT_WELDING_START; } private SequenceActionResult PreScoutSetParams(SequenceContext context, string id, SequenceActionResult param) { Log.I("[PreScout] Scout 파라미터 구성(pre)"); return SequenceAction.Ok(BuildScoutParams(ScoutSaveTag("pre"))); } private SequenceActionResult PreScoutDone(SequenceContext context, string id, SequenceActionResult param) { var result = param.Param as SeqScoutNormal_OutParam; if (result == null || result.Success == false) { Log.E("[PreScout] Scout 실패 → 시퀀스 중단"); return null; } Thread.Sleep(SCOUT_PROCEED_DELAY_MS); // 0.1초 후 용접 진행 Log.I($"[PreScout] 완료 → {SCOUT_PROCEED_DELAY_MS}ms 후 WaitWeldingStart 이동"); return SequenceAction.Ok(); } // ── 용접 후 Scout (완료비트 송신 전) ──────────────────────── private string PostScoutCheck(SequenceContext context, string id, ICollection branches, SequenceActionResult param) { bool scout = _PLC.ReadBit(eBitInput.PLC_SCOUT_ENABLE).data; // M70509 Log.I($"[PostScoutCheck] M70509(Scout Enable) = {scout}"); // M70509 ON → post scout / OFF → 바로 완료비트 송신 return scout ? SEQ_POST_SCOUT_SET_PARAMS : SEQ_WRITE_WELD_COMPLETE; } private SequenceActionResult PostScoutSetParams(SequenceContext context, string id, SequenceActionResult param) { Log.I("[PostScout] Scout 파라미터 구성(post)"); return SequenceAction.Ok(BuildScoutParams(ScoutSaveTag("post"))); } private SequenceActionResult PostScoutDone(SequenceContext context, string id, SequenceActionResult param) { var result = param.Param as SeqScoutNormal_OutParam; if (result == null || result.Success == false) { Log.E("[PostScout] Scout 실패 → 시퀀스 중단"); return null; } Thread.Sleep(SCOUT_PROCEED_DELAY_MS); // 0.1초 후 완료비트 송신 Log.I($"[PostScout] 완료 → {SCOUT_PROCEED_DELAY_MS}ms 후 WriteWeldComplete 이동"); return SequenceAction.Ok(); } // 용접 완료 비트 송신(post-scout 분기 뒤로 분리). 음극 비트로 응답(양극 P 미사용). private SequenceActionResult WriteWeldComplete(SequenceContext context, string id, SequenceActionResult param) { _PLC.WriteBit((int)eBitOutput.PC_WELD_TRIGGER_ACK, false); Log.I($"[WriteWeldComplete] 용접완료 송신 - READY_ACK=false (PageNo:{plcPageNo})"); Thread.Sleep(50); _PLC.WriteBit((int)eBitOutput.PC_WELD_COMPLETE, true); Log.I($"[WriteWeldComplete] 용접완료 송신 - COMPLETE=true (PageNo:{plcPageNo})"); Thread.Sleep(5); Status.SetWorkingPosition(eWorkingPosition.UNLOADING_CELL); Log.I($"[WriteWeldComplete] 완료 → CheckCycleStop 이동"); return SequenceAction.Ok(); } #endregion private SequenceActionResult SaveResultData(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[SaveResultData] 용접 결과 데이터 저장 시작"); Status.SetWorkingPosition(eWorkingPosition.REPORTING_RESULT); // 스캐너의 결과를 저장하는 동작 추가 SaveScannerRecordFileForamt(); // 결과 Data 를 업데이트 함. context.PutValue(ContextKey.KEY_SEQUENCE_RESULT, true); Status.GetProcessingWeldingData().IsVisionNG = false; Log.I("[SaveResultData] Welding Process Result: SUCCESS"); // 결과 데이터 저장하는 시퀸스 //Thread.Sleep(1000); // 추후 제거 Log.I($"[SaveResultData] 결과 저장 완료 → WriteCompletionPLCBit 이동"); return SequenceAction.Ok(); } // 실제 동작 _Scanner에서 private void SaveScannerRecordFileForamt() { Log.I($"[SaveScannerRecordFileForamt] 스캐너 파형 기록 저장 시작 (SaveRecord)"); // ── 용접 완료 후 파형 데이터 저장 ──────────────────────────── _Scanner.SaveRecord("AutoRunTDC"); Log.I("[SaveScannerRecordFileForamt] SaveRecord 완료"); _Scanner.JumpAbs(0, 0, eFunctionType.COMMAND); _Scanner.SelectCorrectionTable(1); Log.D("[SaveScannerRecordFileForamt] JumpAbs(0,0) + CorrectionTable(1) 복귀 완료"); //Thread.Sleep(1000); // 추후 제거 Log.I($"[SaveScannerRecordFileForamt] 스캐너 파형 기록 저장 완료"); } // 이곳에서 bit 초기화 진행함. 260512 private SequenceActionResult WriteCompletionPLCBit(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[WriteCompletionPLCBit] PLC 완료 비트 초기화 시작"); Status.SetWorkingPosition(eWorkingPosition.REPORTING_RESULT); // 완료 비트 write 하는 Node 추가 필요. format 유지, ReceiveCellInfo 없음 if (RestoreDeviceStatus()) { // 초기화 완료 Log.D("AutoRun Sequence WriteCompletionPLCBit"); } //Thread.Sleep(1000); // 추후 제거 Log.I($"[WriteCompletionPLCBit] PLC 비트 초기화 완료 → Complete 이동"); return SequenceAction.Ok(); } private SequenceActionResult Complete(SequenceContext context, string id, SequenceActionResult param) { Log.I($"[Complete] 1사이클 완료 처리]"); context.TryGetValue(ContextKey.KEY_SEQUENCE_RESULT, out bool seqResult); SeqAutoRunPTOP_OutParam result = new SeqAutoRunPTOP_OutParam() { Success = seqResult }; if (seqResult) { uint prevTotal = _SystemParameter.TotalProductCount; uint prevToday = _SystemParameter.TodayProductCount; _SystemParameter.TotalProductCount++; _SystemParameter.TodayProductCount++; _SystemParameter.LastProductTime = DateTime.Now; //Application.Current.AuroraEnv().SettingEntry.Save(CommonSettingIds.SystemParameter, _SystemParameter); Status.GetProcessingWeldingData().TotalProduct = _SystemParameter.TotalProductCount; Status.GetProcessingWeldingData().TodayProduct = _SystemParameter.TodayProductCount; Log.I($"[Complete] Product Count Updated - Total: {prevTotal} -> {_SystemParameter.TotalProductCount}, Today: {prevToday} -> {_SystemParameter.TodayProductCount}"); } else { Status.GetProcessingWeldingData().IsVisionNG = true; Log.W("[Complete] Sequence result was FAIL. Product count not incremented."); } Status.SetWorkingPosition(eWorkingPosition.DONE); //Thread.Sleep(1000); // 추후 제거 Log.I($"[Complete] 카운트 업데이트 완료 → 다음 사이클 진입"); return SequenceAction.Ok(result); } private bool RestoreDeviceStatus() { Log.I($"[RestoreDeviceStatus] PLC 출력 비트 리셋 시작 (Page/Weld 음극)"); ////PLC All BIT OFF _PLC.WriteBit((int)eCommonBitOutput.PC_PAGE_CHANGE_COMPLETE, false); // 페이지 false _PLC.WriteBit((int)eCommonBitOutput.PC_WELD_TRIGGER_ACK, false); // 용접 준비 false _PLC.WriteBit((int)eCommonBitOutput.PC_WELD_BUSY, false); // 동작 중 false _PLC.WriteBit((int)eCommonBitOutput.PC_WELD_COMPLETE, false); // 용접 완료 false Log.I(string.Format("[PLCInterface]PC Anod Bit Off")); // 양극(P) 미사용 — 양극 용접 비트 리셋 제거 //Thread.Sleep(1000); // 추후 제거 Log.I($"[RestoreDeviceStatus] PLC 출력 비트 리셋 완료"); return true; } // 장비 3개 가져오기 완료 private void InitDev(SequenceContext context) { Log.I($"[InitDev] 디바이스 핸들 확보 시작 (Scanner/PLC/IO)"); _Scanner = context.GetLaserScanner(); _PLC = context.GetPLC() as PLCControllerPTOP; _IO = context.GetIOUnit(); Log.I($"[InitDev] 디바이스 핸들 확보 완료"); } private bool CheckDeviceReady(SequenceContext context) { Log.I($"[CheckDeviceReady] 디바이스 활성 상태 확인 시작 (Scanner/Laser/PLC/IO)"); List deviceListToCheck = new List() { CommonDeviceIds.Scanner, CommonDeviceIds.Laser, CommonDeviceIds.PLCDevice, CommonDeviceIds.IODevice }; bool isAllDeviceActivated = true; foreach (string item in deviceListToCheck) { if (!context.Devices[item].IsActivated) { isAllDeviceActivated = isAllDeviceActivated && false; } } Log.I($"[CheckDeviceReady] 디바이스 활성 상태 확인 완료 (AllActive={isAllDeviceActivated})"); return isAllDeviceActivated; } private bool CheckCondition(SequenceContext context) { Log.I($"[CheckCondition] 시작 조건 확인 (인터락 + 디바이스 초기상태)"); // 체크 신호 plc if (CheckCondition_Interlock()) { if (InitializeDeviceStatus(context)) { Log.I($"[CheckCondition] 조건 충족 → 시작 가능"); return true; } } Log.I($"[CheckCondition] 조건 불충족 → 시작 불가"); return false; } private bool CheckCondition_Interlock() { // PLC, Data, IO 등 Interlock checking Log.I($"[CheckCondition_Interlock] 인터락 확인 (현재 항상 통과)"); return true; } private bool InitializeDeviceStatus(SequenceContext context) { Log.I($"[InitializeDeviceStatus] 디바이스 초기상태 설정 시작 (AutoRun 비트 + LBAD Ready)"); // PLC Interface AutoStatus Start InitAutoRunBit(); // Laser Initialize //_Laser.PilotLaserOff(); //_Laser.LaserOn(); //Thread.Sleep(100); //if (!_Laser.GetLaserReady()) //{ // Log.E("ERROR Initialize Laser"); // return false; //} // Scanner Initialize // Scanner 없음. // OCT Initialize // TODO: EnterSequenceModeOCT 와 함께 Initialize 하는 것을 재구현 필요함. 초반에 한번만 check 하고 돌아 갈수 있도록 // PLC Initialize #region [TEST_BYPASS] Alarm Check - PLC_AUTO_STATUS /* if (_PLC.ReadBit((int)eBitInput.PLC_AUTO_STATUS).data == false) { Log.E(string.Format("ERROR Initialize PLC, Bit Off PLC_AUTO_STATUS")); return false; } */ #endregion #region 260824 제거 — DO 채널 8 충돌 (eADOAddress.PCLBADReadyStatus = 8 = 본 레이저 Laser_AC_KEY) // LBAD 는 현재 미등록 디바이스라 이 비트를 쓰는 의미가 없고, // 본 레이저 배선에서 DO 8 은 Laser_AC_KEY(레이저 AC 전원 투입) 이다. // 그대로 두면 Auto 시작이 레이저 AC KEY 릴레이를 켠다. → 제거. //_IO._iOUnit.WriteDoBit(Convert.ToInt32(eADOAddress.PCLBADReadyStatus), true); //Log.I(string.Format($"[IOInterface]SET PCLBADReadyStatus true")); #endregion Log.I($"[InitializeDeviceStatus] 디바이스 초기상태 설정 완료"); return true; } private void InitAutoRunBit() { Log.I($"[InitAutoRunBit] AutoRun 시작 PLC 비트 초기화 시작 (음극)"); _PLC.WriteBit((int)eBitOutput.PC_WELD_TRIGGER_ACK, false); Log.I(string.Format($"[PLCInterface]SET (-)PC_WELD_TRIGGER_ACK False")); _PLC.WriteBit((int)eBitOutput.PC_WELD_BUSY, false); Log.I(string.Format($"[PLCInterface]SET (-)PC_WELD_BUSY False")); _PLC.WriteBit((int)eBitOutput.PC_WELD_COMPLETE, false); Log.I(string.Format($"[PLCInterface]SET (-)PC_WELD_COMPLETE False")); _PLC.WriteBit((int)eBitOutput.PC_AUTO_STATUS, true); Log.I(string.Format($"[PLCInterface]SET (-)PC_AUTO_STATUS True")); // 양극(P) 미사용 — 양극 초기화 비트 제거 Log.I($"[InitAutoRunBit] AutoRun 시작 PLC 비트 초기화 완료"); } private void OnForceError(SequenceContext arg1, string arg2) { Log.I($"[OnForceError] ForceError 발생 → 장비 정지 처리 시작"); OnSeqActionStop(); Log.I($"[OnForceError] ForceError 정지 처리 완료"); } private void OnStopped(SequenceContext obj) { Log.I($"[OnStopped] 시퀀스 Stop 수신 → 장비 정지 처리 시작"); OnSeqActionStop(); Log.I($"[OnStopped] Stop 정지 처리 완료"); } private void OnSeqActionStop() { Log.I($"[OnSeqActionStop] 시퀀스 강제 중지 - PLC/IO/Scanner 초기화 시작"); // PLC All BIT OFF _PLC.WriteBit((int)eBitOutput.PC_AUTO_STATUS, false); _PLC.WriteBit((int)eBitOutput.PC_WELD_TRIGGER_ACK, false); _PLC.WriteBit((int)eBitOutput.PC_WELD_BUSY, false); _PLC.WriteBit((int)eBitOutput.PC_WELD_COMPLETE, false); Log.I("[OnSeqActionStop] PLC 음극 Output All false (양극 P 미사용 — 양극 비트 제거)"); _PLC.WriteBit((int)eBitOutput.PC_PAGE_CHANGE_COMPLETE, false); Log.I("[OnSeqActionStop] PLC IO Output PAGECHANGE Init"); // 260512 워드 // 260621: 에코 워드(PC_PAGE_NO/PC_RECIPE_NO)는 정지 시 0으로 지우지 않음 — PLC가 정합 확인에 쓰는 // 마지막 핸드셰이크 값을 보존(다음 사이클에 어차피 재에코). #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 #region 260820 260811 초기화 제거 (원복: 아래 3줄 주석을 풀면 260811 무조건판으로 돌아간다) // PLC측 확정(2026-08-20): PLC 는 PC 에코 워드가 0이면 레이저 출사 신호를 내지 않는다. // 그런데 bit15 가접 운전은 페이지마다 정지하므로, 여기서 0을 쓰면 페이지마다 0/0 창이 열린다. // PC 는 다음 PLC_PAGE_CHANGE_REQ 를 받아야만 재에코하므로(CheckPageNo) 그 창 동안 PLC 는 // 유효값을 못 본다 — 2026-08-20 로그에서 15 사이클 전부 이 경로를 탔다. // 0 초기화는 SeqStatusMonitor.UpdateStatus() 가 Auto OFF 시에만 담당한다(위 260806 주석과 동일 규칙). // 여기서 중복으로 쓰면 bit15 Resume 이 의존하는 보존값도 함께 깨진다. // ※ 남은 창: SeqStatusMonitor 는 Auto OFF 하강엣지에 0을 쓰고 끝이라, Auto 복귀 후 첫 REQ 까지 // 0/0 이 latch 된다(2026-08-20 10:24:47~10:43:17). 그 구간 재에코는 별건으로 검토한다. //_PLC.WriteWord(eWordOutput.PC_RECIPE_NO, 0); //_PLC.WriteWord(eWordOutput.PC_PAGE_NO, 0); //Log.I($"[OnSeqActionStop] 에코 워드 0 초기화 (PC_RECIPE_NO/PC_PAGE_NO)"); #endregion Log.I("[PLCInterface] PC All Bit Off (Auto, ReadyAck, Busy, Complete)"); // LBAD IO All BIT OFF try { #region 260824 이전 원본 (Core/Ring 2발) //_IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.CoreLaser_Control), false); //_IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.RingLaser_Control), false); #endregion // 260824 본 레이저 단일 발진기 — 정지 시 제어권 해제 1발 _IO._iOUnit.WriteDoBit(Convert.ToInt32(ePrismaticDOAddress.Laser_REMOTE), false); //_IO._iOUnit.WriteDoBit(Convert.ToInt32(eADOAddress.PCLBADWeldingTrigger), false); //_IO._iOUnit.WriteDoBit(Convert.ToInt32(eADOAddress.PCLBADReadyStatus), false); Log.I("[IOInterface] PC All Bit Off (Laser Control, LBAD Trigger, LBAD Ready)"); } catch (Exception ex) { Log.E($"[IOInterface] Failed to reset IO Bits: {ex.Message}"); } // List 실행 종료 및 원점 복귀 _Scanner.StopExecution(); _Scanner.JumpAbs(0, 0, eFunctionType.COMMAND); Log.I("[Scanner] StopExecution and JumpAbs(0,0)"); // 용접 완료 후 CorrectionFile Vision으로 원복 _Scanner.SelectCorrectionTable(1); Log.I($"[OnSeqActionStop] 모든 장비 초기화 완료"); } public class SeqAutoRunPTOP_OutParam : OutParam { } } }