## 배경 (현장 사고 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 검사까지 함께 살아나므로
별건으로 검토한다.
384 lines
16 KiB
C#
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
|
|
}
|
|
}
|