NohDoyeon241104 7c974628d7 fix: 메뉴얼 모드 Door Open 시 레이저 출사 차단 (ISSUE-260902-01)
## 배경 (현장 사고 2026-09-02)
파워측정 / 파워캘 / 롱텀(1시간 연속출사) 작업 중 Door Open 상태에서 레이저가
출사되었다. 현장 요청: "메뉴얼에서도 Door open 상태에서 레이저 출사 안 되도록
Interlock 필요".

## 원인 (수정 전)

1) SeqComponentTeachingRun.CheckCondition_Interlock()
   Door 차단 코드가 '#warning to KSJ > 지워도 무방해보임' 과 함께 전체 주석 처리
   되어 있었다. 남은 검사는 HasAlarmLevelHigh() 뿐이라 Door Open 이어도 통과한다.
   이 함수가 Manual 경로(Teaching Run / Laser Shot On / Scanner Calibration)의
   유일한 출사 게이트였다.

2) UCScannerLaserControlViewModel.PerformCmdLaserShotOnPercent / ...OnWatt
   IsUseLaserAutoOffChecked == false 분기(= 파워측정 직접 출사)는 시퀀스를 아예
   타지 않고 _Scanner.LaserSignalOn() 을 직접 호출한다. 이 분기에는 Door / EMO /
   Busy / LaserReady 어떤 인터락도 없었다. LaserSignalOn 은 자동 오프가 없어
   Shot Off 를 누를 때까지 계속 출사되며, 이것이 롱텀 1시간 연속출사의 구현
   경로다. 이번 사고의 실제 경로.

3) Door 신호 2계통이 모두 출사를 막지 않고 있었다
   - PLC 비트: AlarmManager.IsPlcDoorOpen() 을 호출하는 살아있는 코드가 0곳
     (SeqComponentTeachingRun 주석, UCMainAutoRunViewModel 블록 주석)
   - IO DI 접점: IODevice.MonitorIOInterfaceMap 이 DoorLockOpen1~4 를 읽지만
     타워램프 점등과 EVENT_DOOR_INTERLOCK 발행만 하고, 그 이벤트의 구독자가 없다

## 변경 (수정 후)

[WeldingCommon/Alarm/AlarmManager.cs]
- IsDoorOpen(caller) 신규: PLC 비트(PLC_DOOR_INTERLOCK)와 IO DI 접점
  (DoorLockOpen1~4)을 OR 판정. 한쪽 배선/맵이 틀려도 다른 쪽이 잡도록 했다.
- IsIoDoorOpen(), GetDoorStateText() 신규.
- PAIXIO.ReadDiBit 은 비트 1개마다 DI 배열 전체를 읽으므로(PAIXIO.cs:305~307)
  80ms TTL 캐시를 두어 100ms 감시 1틱당 실제 읽기 1회로 제한했다.
- IsPlcDoorOpen(): _Plc 지연 조회 보완. 기존에는 AlarmManager 생성 시점에 PLC
  등록이 안 끝났으면 _Plc 가 null 로 남아 영구히 false 를 반환 → Door 인터락이
  조용히 무력화될 수 있었다.
- PLC/IO 미연결 계통은 판정에서 제외(의도적 fail-open). Door 맵이 없는 타 설비가
  이 변경으로 갑자기 출사 불가가 되는 것을 막기 위함. PTOP 는 둘 다 연결되어 있다.

[WeldingCommon/Views/Manual/Scanner/UCScannerLaserControlViewModel.cs]
- PerformCmdLaserShotOnPercent / ...OnWatt 진입부에 Door 게이트 추가
  -> 시퀀스 분기와 직접 출사 분기를 모두 커버한다.
- OnMonitoring(100ms)에 MonitorDoorInterlock() 추가: 출사 중 Door 가 열리면
  StopExecution + LaserSignalOff + SetStandby 로 강제 차단한다. 시작 게이트만으로는
  롱텀 도중 문이 열리는 이번 사고 상황을 막을 수 없어 추가했다.
- VisibleChanged: 직접 출사 중에는 화면을 벗어나도 감시 타이머를 유지한다.
  기존처럼 Stop 하면 롱텀 출사 중 Door 감시가 통째로 사라진다.
- 롱텀 추적용 10초 주기 하트비트 로그(Door 상태 + DA1/DA2 전압) 추가.

[ILSOS_PrismaticRD_PTOP/Sequences/Manual/SeqComponentTeachingRun.cs]
- CheckCondition_Interlock(): 주석 처리돼 있던 Door 차단을 IsDoorOpen() 으로 복구.
- DoComponentWelding(): ExecuteList 직전 최종 게이트 추가. 시작 게이트는 시퀀스
  진입 시 1회만 돌아, 객체가 여러 개일 때 매 출사 직전 재확인이 필요하다.

## 영향 범위
- SeqAutoRunPTOP 은 자체 ExecuteWelding(SeqAutoRunPTOP.cs:1314)을 사용하며
  SeqComponentTeachingRun 을 서브시퀀스로 타지 않는다 -> Auto 생산 동작 변경 없음.
- WeldingCommon 공용 파일을 수정했으나, Door 맵이 없는 설비는 fail-open 으로
  기존 동작이 유지된다.

## 남은 사항 (별건)
- 본 변경은 SW 방어이지 안전 인터락이 아니다. Door 감시가 UI 스레드
  DispatcherTimer 기반이라 UI 정지 시 함께 멈춘다. Class 4 레이저 기준 최종
  차단은 PLC/하드와이어 안전회로로 별도 협의가 필요하다.
- SeqComponentTeachingRun 의 EMO 차단(IsPlcEmoAlarm)도 함께 주석 처리되어 있다.
  이번 범위에서 제외했으며, PLC_EMO_STATUS 맵 확인 후 복구 여부를 결정한다.
- UCMainAutoRunViewModel.ApproveAutorunByUserOrAuto() 전체가 블록 주석
  (399~431행)이다. Auto 시작 게이트 복구는 Vision Recipe 검사까지 함께 살아나므로
  별건으로 검토한다.
2026-09-02 22:34:17 +09:00

384 lines
16 KiB
C#

using Aurora;
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;
using System.Linq;
using System.Reactive.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using WeldingCommon.Devices;
using WeldingCommon.Sequences;
using static WeldingCommon.Devices.PLCController;
namespace WeldingCommon.Alarm
{
public class AlarmManager
{
private static readonly Logger AlarmLog = new Logger("ALARM");
protected static AlarmManager _Instance;
protected static IAuroraEnvironment _AuroraEnv;
public readonly IAlarmService _AlarmService;
private readonly IDeviceManager _DeviceManager;
private static PLCController _Plc;
public AlarmManager(IAuroraEnvironment aurora)
{
_AuroraEnv = aurora;
_AlarmService = aurora.AlarmService;
_DeviceManager = aurora.DeviceManager;
_Plc = _DeviceManager.GetDevice(CommonDeviceIds.PLCDevice) as PLCController;
// Reset Alarm Event Subscribe > Clear All Device Alarms
// TODO:: Check when event below generated. If event does not generated, remove.
aurora.EventObservable.AsObservable(AuroraSysEvent.SYS_ALARM_RESET_ALL_ALARM)
.Select(msg => msg.Param)
.Subscribe(ClearAllAlarm);
aurora.EventObservable.AsObservable(AuroraSysEvent.SYS_ALARM_RESET_ALARM)
.Select(msg => msg.Param)
.Subscribe(ClearAlarm);
aurora.EventObservable.AsObservable(CommonEventIds.EVENT_RESET_ALL_ALARMS_REQUEST)
.Select(msg => msg.Param)
.Subscribe(ClearAllAlarmRequest);
}
public static void Init(IAuroraEnvironment aurora)
{
if (_Instance == null) {
_Instance = new AlarmManager(aurora);
}
}
private void ClearAlarm(object obj)
{
//각 Alarm별 clear 처리를 따로 해줘야 할까 ?
}
private void ClearAllAlarm(object mAlarmList)
{
if (!(mAlarmList is List<AlarmDescription> alarmList)) {
return;
}
//_Plc?.WriteBit(_Plc.LightAlarmStatusAddr, false);
//_Plc?.WriteBit(_Plc.HeavyAlarmStatusAddr, false);
}
private void ClearAllAlarmRequest(object obj)
{
Application.Current.Dispatcher.InvokeAsync(() => _Instance._AlarmService.ClearAllAlarm());
//_Plc?.WriteBit(_Plc.LightAlarmStatusAddr, false);
//_Plc?.WriteBit(_Plc.HeavyAlarmStatusAddr, false);
}
public static bool Alarm(int code, bool isLog = true, bool useReport = true)
{
// 모든 Alarm 은 띄워 줌
if (!_Instance._AlarmService.GetCurrentAlarmCodes().Contains(code)) {
_Instance._AlarmService.SetAlarm(code, useReport);
AlarmDescription alarm = _Instance._AlarmService.GetAlarmDescription(code);
if (isLog) {
if (alarm.Level == AlarmLevel.H) {
AlarmLog.E($"Code : {code} , Description : {alarm.Name}");
} else {
AlarmLog.W($"Code : {code} , Description : {alarm.Name}");
}
}
////PLC의 중알람의 경우 PC에서 Dialog만 발생하고 Alarm 중알람 Word를 써주지 않는다
//if (alarm.Level == AlarmLevel.H && alarm.Unit == "PLC") {
// return true;
//}
//if (alarm.Level == AlarmLevel.H) {
// _Plc?.WriteBit(_Plc.HeavyAlarmStatusAddr, true);
//} else {
// _Plc?.WriteBit(_Plc.LightAlarmStatusAddr, true);
//}
return true;
}
return false;
}
public static bool ChangeAlarmDescription(int code, string description, bool isLog = true, bool useReport = true)
{
// 중복 알람은 로그로 남기지 않도록 하는 코드
if (IsOccuredAlarm(code))
return true;
AlarmDescription alarm = _Instance._AlarmService.GetAlarmDescription(code);
alarm.Description = description;
// 모든 Alarm 은 띄워 줌
if (!_Instance._AlarmService.GetCurrentAlarmCodes().Contains(code)) {
_Instance._AlarmService.SetAlarm(code, useReport);
if (isLog) {
if (alarm.Level == AlarmLevel.H) {
AlarmLog.E($"Code : {code} , Description : {alarm.Name}");
} else {
AlarmLog.W($"Code : {code} , Description : {alarm.Name}");
}
}
////PLC의 중알람의 경우 PC에서 Dialog만 발생하고 Alarm 중알람 Word를 써주지 않는다
//if (alarm.Level == AlarmLevel.H && alarm.Unit == "PLC") {
// return true;
//}
//if (alarm.Level == AlarmLevel.H) {
// _Plc?.WriteBit(_Plc.HeavyAlarmStatusAddr, true);
//} else {
// _Plc?.WriteBit(_Plc.LightAlarmStatusAddr, true);
//}
return true;
}
return false;
}
public static void StopCommonSequenceByAlarm(int code)
{
AlarmDescription alarm = _Instance._AlarmService.GetAlarmDescription(code);
// 바로 Sequence 를 정지해야 하는 경우 발생할 수 있음.
// 불필요 할까?? Device 의 상태에 따라서 멈춰야 하는 Sequence 가 다를 수도 있다.
// 만약에 Sequence 가 종료되었을 때, pass, fail 여부에 따라 다를 지도.
if (alarm.Level == AlarmLevel.H) {
StopSequence(CommonSequenceIds.SeqMarkGrid);
StopSequence(CommonSequenceIds.SeqScannerCalibration);
StopSequence(CommonSequenceIds.SeqReceiveCellInfo);
StopSequence(CommonSequenceIds.SeqReportCellResult);
StopSequence(CommonSequenceIds.SeqScoutFlyingVision);
StopSequence(CommonSequenceIds.SeqScoutNormal);
StopSequence(CommonSequenceIds.SeqUploadWeldingList);
StopSequence(CommonSequenceIds.SeqTeachingRun);
}
}
protected static void StopSequence(string sequenceId)
{
if (_AuroraEnv.SequenceEntry[sequenceId].RunState == SequenceRunState.Running) {
_AuroraEnv.SequenceEntry[sequenceId].EmergencyStop();
AlarmLog.I($"{_AuroraEnv.SequenceEntry[sequenceId]} EmergencyStop");
}
}
// 공통부. 현재 발생한 중알람 여부 조회
public static bool HasAlarmLevelHigh()
{
try {
if (ApplicationExt.IsSimulation)
return false;
var alarmList = _Instance._AlarmService.GetCurrentAlarmCodes();
if (alarmList.Count > 0) {
foreach (var item in alarmList) {
if (IsAlarmLevelHigh(item)) {
return true;
}
}
}
return false;
} catch (Exception ex) {
AlarmLog.E($"{ex.Message} Exception Accured");
return false;
}
}
public static bool IsAlarmLevelHigh(int item)
{
var desc = _Instance._AlarmService.GetAlarmDescription(item);
if (desc != null) {
if (desc.Level == AlarmLevel.H) {
return true;
}
} else {
// Alarm Description을 알 수 없는 경우 : 위험할 수 있으니 일단 H로 취급
return true;
}
return false;
}
public static bool HasAlarmLevelLow()
{
var alarmList = _Instance._AlarmService.GetCurrentAlarmCodes();
if (alarmList.Count > 0) {
foreach (var item in alarmList) {
if (IsAlarmLevelLow(item)) {
return true;
}
}
}
return false;
}
public static bool IsAlarmLevelLow(int item)
{
var desc = _Instance._AlarmService.GetAlarmDescription(item);
if (desc != null) {
if (desc.Level == AlarmLevel.L) {
return true;
}
} else {
// Alarm Description을 알 수 없는 경우 : 위험할 수 있으니 일단 H로 취급
return true;
}
return false;
}
public static bool IsOccuredAlarm(int AlarmCode)
{
var CurAlarmList = _Instance._AlarmService.GetCurrentAlarmCodes();
if (CurAlarmList.FirstOrDefault(x => x == AlarmCode) != 0)
return true;
return false;
}
public static bool IsPlcEmoAlarm()
{
if (_Plc == null) return false;
else return _Plc.ReadBit(eCommonBitInput.PLC_EMO_STATUS).data;
}
public static bool IsPlcDoorOpen()
{
// [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<string, bool> _DoorLogState = new Dictionary<string, bool>();
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;
}
}
/// <summary>
/// IO DI 접점(DoorLockOpen1~4) 기준 Door Open 여부.
/// IO 미연결/미활성이면 판정 불가로 보고 false 를 반환한다.
/// </summary>
public static bool IsIoDoorOpen()
{
RefreshDoorCache();
return _DoorIoOpenCache;
}
/// <summary>
/// PLC 비트와 IO DI 접점을 OR 하여 Door Open 여부를 판정한다.
/// caller 는 로그 식별용 호출부 이름.
/// </summary>
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;
}
/// <summary>차단 로그에 붙일 Door 신호 상세 문자열. 캐시값을 쓰므로 추가 IO 읽기가 없다.</summary>
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
}
}