diff --git a/Machines/ILSOS_PrismaticRD_PTOP/Sequences/Manual/SeqComponentTeachingRun.cs b/Machines/ILSOS_PrismaticRD_PTOP/Sequences/Manual/SeqComponentTeachingRun.cs index e0d7af6..20cf896 100644 --- a/Machines/ILSOS_PrismaticRD_PTOP/Sequences/Manual/SeqComponentTeachingRun.cs +++ b/Machines/ILSOS_PrismaticRD_PTOP/Sequences/Manual/SeqComponentTeachingRun.cs @@ -278,14 +278,20 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences return false; } -#warning to KSJ > 지워도 무방해보임 - //boolian = AlarmManager.IsPlcDoorOpen(); - //if (boolian) { - // Log.E($"PLC Door Opened!! Can't Weld Now."); - // MessageUtil.ShowWarningToast(string.Format("Close Door ! Clear PLC DoorInterlock!")); - // return false; - //} + // [Door Interlock 2026-09-02] 주석 처리되어 있던 Door 차단을 복구 (ISSUE-260902-01) + // 기존: '#warning to KSJ > 지워도 무방해보임' 과 함께 전체 주석 → Manual 경로에서 Door Open 이어도 출사됨. + // 변경: PLC 비트 + IO DI 접점 OR 판정(AlarmManager.IsDoorOpen)으로 복구. + // IsPlcDoorOpen() 단독이 아니라 OR 을 쓰는 이유는 한쪽 배선/맵이 틀려도 다른 쪽이 잡도록 하기 위함. + boolian = AlarmManager.IsDoorOpen("SeqComponentTeachingRun"); + if (boolian) { + Log.E($"[DoorInterlock] Door Open 상태 → 용접(출사) 차단. {AlarmManager.GetDoorStateText()}"); + MessageUtil.ShowWarningToast(string.Format("Door Open 상태입니다. 문을 닫고 다시 시도하세요. (Laser Interlock)")); + return false; + } + Log.I($"[DoorInterlock] Door Close 확인 → 용접(출사) 허용. {AlarmManager.GetDoorStateText()}"); + // TODO(ISSUE-260902-01): EMO 차단도 함께 주석 처리되어 있음. 이번 변경 범위(Door)에서 제외했으니 + // PLC_EMO_STATUS 맵 확인 후 별도로 복구 여부 결정할 것. //boolian = AlarmManager.IsPlcEmoAlarm(); //if (boolian) { // Log.E($"PLC EMO Pushed!! Can't Weld Now."); @@ -612,7 +618,17 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences } } + // [Door Interlock 2026-09-02] 출사 직전 최종 게이트 (ISSUE-260902-01) + // CheckInitCondition 의 시작 게이트는 시퀀스 진입 시 1회만 돈다. + // 객체가 여러 개면 이 구간이 반복되므로, 매 출사 직전마다 Door 를 다시 확인한다. + if (AlarmManager.IsDoorOpen("DoComponentWelding")) { + Log.E($"[DoorInterlock] 출사 직전 Door Open 감지 → ExecuteList 중단. {AlarmManager.GetDoorStateText()}"); + MessageUtil.ShowWarningToast(string.Format("Door Open 상태입니다. 출사를 중단합니다. (Laser Interlock)")); + return null; + } + DateTime weldingTime = DateTime.Now; + Log.I($"[DoorInterlock] 출사 직전 Door Close 확인 → ExecuteList 실행 (ListNo:{_WeldingScannerListNo})"); _Scanner.ExecuteList(_WeldingScannerListNo); _Scanner.WaitForListTaskCompleted(1000);//(_SystemParameter.WeldLinesTaskTimeOut); _MeasurementSpanTime = DateTime.Now.Subtract(weldingTime); diff --git a/WeldingComponents/WeldingCommon/Alarm/AlarmManager.cs b/WeldingComponents/WeldingCommon/Alarm/AlarmManager.cs index 86bf626..95559e5 100644 --- a/WeldingComponents/WeldingCommon/Alarm/AlarmManager.cs +++ b/WeldingComponents/WeldingCommon/Alarm/AlarmManager.cs @@ -2,6 +2,8 @@ using Aurora.Api; using Aurora.Api.Manager; using Aurora.Framework.Alarm; +using AuroraIO; +using AuroraIO.Vendor.PAIX; using AuroraPLC; using System; using System.Collections.Generic; @@ -249,8 +251,133 @@ namespace WeldingCommon.Alarm public static bool IsPlcDoorOpen() { - if (_Plc == null) return false; - else return _Plc.ReadBit(eCommonBitInput.PLC_DOOR_INTERLOCK).data; + // [Door Interlock 2026-09-02] 지연 조회 보완. + // 기존에는 AlarmManager 생성 시점에 PLC Device 등록이 끝나지 않았으면 _Plc 가 null 로 남고, + // 이후 영구히 false 를 반환해 Door 인터락이 조용히 무력화될 수 있었다. + var plc = ResolvePlc(); + if (plc == null) return false; + else return plc.ReadBit(eCommonBitInput.PLC_DOOR_INTERLOCK).data; } + + #region [Door Interlock 2026-09-02] 메뉴얼 출사 Door 인터락 (ISSUE-260902-01) + // 배경(현장 사고 2026-09-02): + // 파워측정 / 파워캘 / 롱텀(1시간 연속출사) 작업 중 Door Open 상태에서도 레이저가 출사되었다. + // 해당 경로(UCScannerLaserControlViewModel 의 직접 출사 분기)에는 인터락이 전혀 없었고, + // SeqComponentTeachingRun 의 Door 차단은 주석 처리되어 무력화된 상태였다. + // + // 판정 원칙: + // Door 신호는 2계통(PLC 비트 PLC_DOOR_INTERLOCK, IO DI 접점 DoorLockOpen1~4)이며 + // "어느 한쪽이라도 Open 이면 Open" 으로 본다(OR). 한쪽 배선/맵이 틀려도 다른 쪽이 잡도록 하기 위함. + // + // 미연결 처리: + // PLC / IO 가 미연결·미활성이면 해당 계통은 판정에서 제외한다(false). + // Door 맵이 없는 타 설비가 이 변경으로 갑자기 출사 불가가 되는 것을 막기 위한 의도적 fail-open 이다. + // PTOP 는 PLC·IO 가 모두 연결되어 있으므로 실사용상 fail-open 으로 빠지지 않는다. + + private static IODevice _IO; + + // 100ms 주기 감시에서 호출되므로, 상태가 바뀔 때만 로그를 남겨 로그 폭주를 막는다. + private static readonly Dictionary _DoorLogState = new Dictionary(); + + private static PLCController ResolvePlc() + { + // 생성 시점에 Device 등록이 끝나지 않았을 수 있어 지연 조회로 보완한다. + if (_Plc == null && _Instance != null) { + _Plc = _Instance._DeviceManager?.GetDevice(CommonDeviceIds.PLCDevice) as PLCController; + if (_Plc != null) AlarmLog.I("[DoorInterlock] PLCController 지연 조회 성공"); + } + return _Plc; + } + + private static IODevice ResolveIO() + { + if (_IO == null && _Instance != null) { + _IO = _Instance._DeviceManager?.GetDevice(CommonDeviceIds.IODevice) as IODevice; + if (_IO != null) AlarmLog.I("[DoorInterlock] IODevice 지연 조회 성공"); + } + return _IO; + } + + // PAIXIO.ReadDiBit 은 비트 1개를 읽을 때마다 DI 배열 전체를 조회한다(PAIXIO.cs:305~307). + // 출사 중 감시가 100ms 주기로 돌기 때문에 매 호출마다 4비트를 읽으면 IO 보드/UI 스레드에 부담이 된다. + // → 짧은 TTL 캐시를 두어 감시 1틱당 1회만 실제로 읽도록 한다. + private const int DOOR_CACHE_MS = 80; + private static bool _DoorPlcOpenCache; + private static bool _DoorIoOpenCache; + private static DateTime _DoorCacheTime = DateTime.MinValue; + + private static void RefreshDoorCache() + { + if (DateTime.Now.Subtract(_DoorCacheTime).TotalMilliseconds < DOOR_CACHE_MS) return; + + _DoorCacheTime = DateTime.Now; + _DoorPlcOpenCache = IsPlcDoorOpen(); + _DoorIoOpenCache = ReadIoDoorOpenRaw(); + } + + private static bool ReadIoDoorOpenRaw() + { + var io = ResolveIO(); + if (io == null || io.IsActivated == false || io._iOUnit == null) return false; + + try { + return io.ReadDI((int)PAIXIO.ePDIAddress.DoorLockOpen1) + || io.ReadDI((int)PAIXIO.ePDIAddress.DoorLockOpen2) + || io.ReadDI((int)PAIXIO.ePDIAddress.DoorLockOpen3) + || io.ReadDI((int)PAIXIO.ePDIAddress.DoorLockOpen4); + } catch (Exception ex) { + AlarmLog.E($"[DoorInterlock] IO DI 읽기 실패 → IO 계통 판정 제외: {ex.Message}"); + return false; + } + } + + /// + /// IO DI 접점(DoorLockOpen1~4) 기준 Door Open 여부. + /// IO 미연결/미활성이면 판정 불가로 보고 false 를 반환한다. + /// + public static bool IsIoDoorOpen() + { + RefreshDoorCache(); + return _DoorIoOpenCache; + } + + /// + /// PLC 비트와 IO DI 접점을 OR 하여 Door Open 여부를 판정한다. + /// caller 는 로그 식별용 호출부 이름. + /// + public static bool IsDoorOpen(string caller = "") + { + RefreshDoorCache(); + + bool plcOpen = _DoorPlcOpenCache; + bool ioOpen = _DoorIoOpenCache; + bool open = plcOpen || ioOpen; + + if (IsDoorStateChanged(caller, open)) { + if (open) + AlarmLog.W($"[DoorInterlock][{caller}] Door OPEN 감지 → 이후 레이저 출사 차단 (PLC={plcOpen}, IO_DI={ioOpen})"); + else + AlarmLog.I($"[DoorInterlock][{caller}] Door CLOSE 복귀 → 출사 허용 (PLC={plcOpen}, IO_DI={ioOpen})"); + } + + return open; + } + + /// 차단 로그에 붙일 Door 신호 상세 문자열. 캐시값을 쓰므로 추가 IO 읽기가 없다. + public static string GetDoorStateText() + { + RefreshDoorCache(); + return $"PLC비트(PLC_DOOR_INTERLOCK)={_DoorPlcOpenCache}, IO접점(DoorLockOpen1~4)={_DoorIoOpenCache}, " + + $"PLC연결={(ResolvePlc() != null)}, IO연결={(ResolveIO() != null)}"; + } + + private static bool IsDoorStateChanged(string caller, bool open) + { + string key = caller ?? string.Empty; + if (_DoorLogState.TryGetValue(key, out bool prev) && prev == open) return false; + _DoorLogState[key] = open; + return true; + } + #endregion } } diff --git a/WeldingComponents/WeldingCommon/Views/Manual/Scanner/UCScannerLaserControlViewModel.cs b/WeldingComponents/WeldingCommon/Views/Manual/Scanner/UCScannerLaserControlViewModel.cs index 046507f..30d7efb 100644 --- a/WeldingComponents/WeldingCommon/Views/Manual/Scanner/UCScannerLaserControlViewModel.cs +++ b/WeldingComponents/WeldingCommon/Views/Manual/Scanner/UCScannerLaserControlViewModel.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows.Input; using System.Windows.Threading; +using WeldingCommon.Alarm; using WeldingCommon.Models; using WeldingCommon.Sequences; using WeldingCommon.Settings; @@ -113,6 +114,12 @@ namespace WeldingCommon.Views private bool _ToggleSimulateEncoder = false; // 왜 있는지는 모르겠음. + // [Door Interlock 2026-09-02] 직접 출사(LaserSignalOn) 진행 여부. + // LaserSignalOn 은 자동 오프가 없어 Shot Off 를 누를 때까지 계속 출사되므로(롱텀 1시간 출사가 이 경로), + // 출사 중 Door 가 열리면 강제 OFF 해야 한다. 그 판단에 쓰는 래치. + private bool _IsManualEmitting = false; + private DateTime _LastEmitHeartbeat = DateTime.MinValue; + private readonly DispatcherTimer _MonitorTimer; private ISequence _SeqTeachingRun; @@ -170,12 +177,22 @@ namespace WeldingCommon.Views //Init_XYLimitPos(); _MonitorTimer.Start(); } else { - _MonitorTimer.Stop(); + // [Door Interlock 2026-09-02] 직접 출사(자동 오프 없음) 중이면 화면을 벗어나도 타이머를 멈추지 않는다. + // 멈추면 롱텀 출사 도중 Door 감시가 통째로 사라지기 때문. Shot Off 시점에 정리된다. + if (_IsManualEmitting) { + Log.W("[DoorInterlock] 직접 출사 중이므로 화면 이탈에도 Door 감시 타이머를 유지합니다."); + } else { + _MonitorTimer.Stop(); + } } } private void OnMonitoring(object sender, EventArgs e) { + // [Door Interlock 2026-09-02] 출사 중 Door 감시를 최우선으로 수행한다. + // 아래 스캐너 읽기에서 예외가 나더라도 차단 로직은 먼저 돌아야 하므로 맨 앞에 둔다. + MonitorDoorInterlock(); + if (_Scanner.IsActivated == false) { Log.D(string.Format("[OnMonitoring] Check Scanner device Connect. ScannerLaserControl view OnMonitoring Stop")); MessageUtil.ShowWarningToast(string.Format("Check Scanner device Connect.")); @@ -261,6 +278,9 @@ namespace WeldingCommon.Views private void PerformCmdLaserShotOnPercent(object obj) { + // [Door Interlock 2026-09-02] 두 분기(시퀀스 / 직접 출사) 공통 게이트 + if (!CheckDoorInterlockBeforeEmit("LaserShotOn(Percent)")) return; + if (IsUseLaserAutoOffChecked) { if (_Scanner.GetBusyStatus()) { MessageUtil.ShowWarningToast(string.Format("Laser, Scanner is busy.")); @@ -334,11 +354,21 @@ namespace WeldingCommon.Views //_Scanner.SetStandby(LaserPeriod * 1000, LaserPulseWidth); if (!IsNoLaser) { _Scanner.LaserSignalOn(); // No Laser면 발진 신호 생략 → 전압만 인가 + + // [Door Interlock 2026-09-02] 자동 오프가 없는 연속 출사 → 출사 중 Door 감시 대상으로 등록 + _IsManualEmitting = true; + _LastEmitHeartbeat = DateTime.Now; + Log.I($"[DoorInterlock] 직접 출사 시작(자동 오프 없음, Shot Off 까지 계속 출사). {AlarmManager.GetDoorStateText()}"); + Log.W("[DoorInterlock] 출사 중 Door 감시는 이 화면이 떠 있는 동안에만 동작합니다(_MonitorTimer). 롱텀 출사 중 화면 이탈 금지."); + } else { + Log.I("[DoorInterlock] No Laser 체크됨 → 발진 신호 생략(전압만 인가). 출사 감시 대상 아님."); } } } private void PerformCmdLaserShotOnWatt(object obj) { + // [Door Interlock 2026-09-02] 두 분기(시퀀스 / 직접 출사) 공통 게이트 + if (!CheckDoorInterlockBeforeEmit("LaserShotOn(Watt)")) return; if (IsUseLaserAutoOffChecked) { if (_Scanner.GetBusyStatus()) { @@ -415,6 +445,14 @@ namespace WeldingCommon.Views //_Scanner.SetStandby(LaserPeriod * 1000, LaserPulseWidth); if (!IsNoLaser) { _Scanner.LaserSignalOn(); // No Laser면 발진 신호 생략 → 전압만 인가 + + // [Door Interlock 2026-09-02] 자동 오프가 없는 연속 출사 → 출사 중 Door 감시 대상으로 등록 + _IsManualEmitting = true; + _LastEmitHeartbeat = DateTime.Now; + Log.I($"[DoorInterlock] 직접 출사 시작(자동 오프 없음, Shot Off 까지 계속 출사). {AlarmManager.GetDoorStateText()}"); + Log.W("[DoorInterlock] 출사 중 Door 감시는 이 화면이 떠 있는 동안에만 동작합니다(_MonitorTimer). 롱텀 출사 중 화면 이탈 금지."); + } else { + Log.I("[DoorInterlock] No Laser 체크됨 → 발진 신호 생략(전압만 인가). 출사 감시 대상 아님."); } } } @@ -439,8 +477,88 @@ namespace WeldingCommon.Views } _Scanner.SetStandby(10, 0); + + // [Door Interlock 2026-09-02] 출사 종료 → 감시 래치 해제 + if (_IsManualEmitting) { + Log.I("[DoorInterlock] Shot Off 실행 → 직접 출사 종료, 출사 중 Door 감시 해제"); + _IsManualEmitting = false; + } } + #region [Door Interlock 2026-09-02] 메뉴얼 출사 Door 인터락 (ISSUE-260902-01) + // 배경: 파워측정/파워캘/롱텀(1시간 연속출사) 중 Door Open 상태에서 레이저가 출사되어 현장 사고 발생. + // 아래 else 분기(직접 출사)에는 Door/EMO/Busy/LaserReady 어떤 인터락도 없었다. + + /// + /// 출사 시작 전 Door 인터락. Open 이면 출사를 막고 false 를 반환한다. + /// + private bool CheckDoorInterlockBeforeEmit(string cmdName) + { + if (!AlarmManager.IsDoorOpen("ScannerLaserControl")) { + Log.I($"[DoorInterlock][{cmdName}] Door CLOSE 확인 → 출사 허용. {AlarmManager.GetDoorStateText()}"); + return true; + } + + Log.E($"[DoorInterlock][{cmdName}] Door OPEN 상태 → 레이저 출사 차단. {AlarmManager.GetDoorStateText()}"); + MessageUtil.ShowWarningToast("Door Open 상태입니다. 문을 닫고 다시 시도하세요. (Laser Interlock)"); + return false; + } + + /// + /// 출사 중 Door 가 열렸을 때의 강제 차단. + /// 안전 경로이므로 어느 분기로 출사 중이든 확실히 끊도록 StopExecution / LaserSignalOff 를 모두 호출한다. + /// + private void ForceLaserOffByDoorOpen(string reason) + { + Log.E($"[DoorInterlock] 출사 중 Door OPEN 감지 → 레이저 강제 OFF 실행 ({reason}). {AlarmManager.GetDoorStateText()}"); + + try { + _Scanner.StopExecution(); // 시퀀스/리스트 실행 중단 + } catch (Exception ex) { + Log.E($"[DoorInterlock] StopExecution 실패: {ex.Message}"); + } + + try { + _Scanner.LaserSignalOff(); // 직접 출사(LaserSignalOn) 중단 + } catch (Exception ex) { + Log.E($"[DoorInterlock] LaserSignalOff 실패: {ex.Message}"); + } + + try { + _Scanner.SetStandby(10, 0); + } catch (Exception ex) { + Log.E($"[DoorInterlock] SetStandby 실패: {ex.Message}"); + } + + _IsManualEmitting = false; + Log.E("[DoorInterlock] 레이저 강제 OFF 완료. 재출사하려면 문을 닫고 Shot On 을 다시 누르십시오."); + MessageUtil.ShowErrorToast("Door Open 감지 → 레이저를 강제 OFF 했습니다. (Laser Interlock)"); + } + + /// + /// 100ms 주기 감시. 출사 중 Door 가 열리면 즉시 차단한다. + /// 롱텀(1시간 연속출사) 도중 문이 열리는 상황을 잡기 위한 것으로, 시작 게이트만으로는 막을 수 없다. + /// + private void MonitorDoorInterlock() + { + bool doorOpen = AlarmManager.IsDoorOpen("ScannerLaserControl"); + bool seqRunning = _SeqTeachingRun != null && _SeqTeachingRun.RunState == SequenceRunState.Running; + bool emitting = _IsManualEmitting || seqRunning; + + if (doorOpen && emitting) { + ForceLaserOffByDoorOpen(_IsManualEmitting ? "직접 출사 중" : "TeachingRun 시퀀스 실행 중"); + return; + } + + // 롱텀 출사 추적용 하트비트(10초 주기). 1시간 출사 구간의 로그 공백을 없애기 위한 것. + if (_IsManualEmitting && DateTime.Now.Subtract(_LastEmitHeartbeat).TotalSeconds >= 10) { + _LastEmitHeartbeat = DateTime.Now; + Log.I($"[DoorInterlock] 직접 출사 중... Door={(doorOpen ? "OPEN" : "CLOSE")}, " + + $"DA1={AnalogOut1Voltage:F3}V, DA2={AnalogOut2Voltage:F3}V, {AlarmManager.GetDoorStateText()}"); + } + } + #endregion + private void PerformCmdResetEncoderValue(object obj) { try {