using System; using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Threading; using System.Threading.Tasks; using leak_test_project.Infrastructure; using leak_test_project.Models; using leak_test_project.Utils; namespace leak_test_project.Services { /// /// 신규 4251 보드와의 통신을 관리하는 서비스. /// 키워드를 기준으로 데이터를 수신하며, 상태 확인 및 ID 읽기 기능을 제공함. /// public class Board4251Service : IDisposable { private readonly ICommunication _communication; private readonly StringBuilder _receiveBuffer = new StringBuilder(); private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); private TaskCompletionSource _responseTcs; public int TimeoutMs { get; set; } = 5000; // 보드가 응답을 주는데 2초 이상 걸리므로 무조건 길게 대기 private bool _shouldBeConnected = false; private System.Timers.Timer _reconnectTimer; public event EventHandler ErrorOccurred; public event EventHandler ConnectionChanged; public string LastResponse { get; private set; } = ""; public string GetLastBuffer() { string currentBuf = _receiveBuffer.ToString().Trim(); return string.IsNullOrEmpty(currentBuf) ? LastResponse : currentBuf; } public Board4251Service(ICommunication communication) { _communication = communication; _communication.DataReceived += OnDataReceived; _communication.ConnectionStatusChanged += (s, isConnected) => { ConnectionChanged?.Invoke(this, isConnected); if (!isConnected && _shouldBeConnected) StartReconnectTimer(); }; _reconnectTimer = new System.Timers.Timer(1000); _reconnectTimer.AutoReset = false; _reconnectTimer.Elapsed += (s, e) => { if (_shouldBeConnected && !_communication.IsOpen) { if (!_communication.Open()) { if (_shouldBeConnected) _reconnectTimer.Start(); } } else if (_shouldBeConnected) { _reconnectTimer.Start(); } }; } private void StartReconnectTimer() { if (_reconnectTimer != null && !_reconnectTimer.Enabled) _reconnectTimer.Start(); } public bool Connect() { _shouldBeConnected = true; bool opened = _communication.Open(); if (!opened) StartReconnectTimer(); return opened; } public void Disconnect() { _shouldBeConnected = false; _reconnectTimer?.Stop(); _communication.Close(); } /// /// 4251 보드의 상태를 확인합니다. /// /// 성공 여부 public async Task CheckStatusAsync(int channel = 1) { string response = await SendCommandAsync($"x00c_00{channel}101:owt28006727ea97c7801\r\n"); if (response == null) { return false; } if (response.IndexOf("Success", StringComparison.OrdinalIgnoreCase) >= 0) { return true; } if (response.IndexOf("Fail", StringComparison.OrdinalIgnoreCase) >= 0) { return false; } // Success도 Fail도 없는 알 수 없는 응답인 경우 return false; } /// /// 4251 보드로부터 16자리 ID를 읽어옵니다. /// /// 16자리 ID 문자열, 실패 시 null public async Task ReadIdAsync(int channel = 1) { string response = await SendCommandAsync($"x00c_00{channel}101:ow2800326003e\r\n"); if (response == null) { return null; } if (response.Contains("Fail")) { return null; } string id = ExtractId(response); if (string.IsNullOrEmpty(id)) { return null; } return id; } private string ExtractId(string response) { if (string.IsNullOrEmpty(response)) return null; // 보드 응답에는 보낸 명령어가 에코되어 포함되므로, 줄바꿈으로 나누어 실제 데이터 라인을 찾음 string[] lines = response.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { string trimmed = line.Trim(); if (trimmed.Contains("x00c_") || trimmed.IndexOf("", StringComparison.OrdinalIgnoreCase) >= 0 || trimmed.IndexOf("Success", StringComparison.OrdinalIgnoreCase) >= 0) continue; // 16자리 영숫자 ID 추출 var match = System.Text.RegularExpressions.Regex.Match(trimmed, @"[A-Za-z0-9]{16}"); if (match.Success) return match.Value; } return null; } public async Task SendCommandAsync(string command) { await _lock.WaitAsync(); try { int retryCount = 0; while (retryCount <= 3) { if (!_communication.IsOpen) { if (!_communication.Open()) { retryCount++; await Task.Delay(300); continue; } } _receiveBuffer.Clear(); LastResponse = ""; _responseTcs = new TaskCompletionSource(); _communication.ClearBuffer(); // 이전에 남아있던 패킷 조각 완벽히 제거 Debug.WriteLine($"[Board4251] Sending Command: {command.TrimEnd()}"); _communication.Write(command); using (var cts = new CancellationTokenSource(TimeoutMs)) { cts.Token.Register(() => _responseTcs.TrySetCanceled()); try { return await _responseTcs.Task; } catch (OperationCanceledException) { retryCount++; string currentBuffer = _receiveBuffer.ToString().Trim(); FileLogger.Log("WARNING", $"[Board4251] Timeout waiting for response (Retry {retryCount}/3). Command: {command.Trim()}, ReceivedSoFar: {currentBuffer}"); _receiveBuffer.Clear(); if (retryCount <= 3) await Task.Delay(300); } } } FileLogger.Log("ERROR", $"[Board4251] Failed to receive response after 3 retries: {command.Trim()}"); return null; } finally { _responseTcs = null; _lock.Release(); } } private void OnDataReceived(object sender, string data) { Debug.WriteLine($"[Board4251] Raw Data Chunk Received: {data.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t")}"); _receiveBuffer.Append(data); string currentContent = _receiveBuffer.ToString(); bool isComplete = false; if (currentContent.IndexOf("", StringComparison.OrdinalIgnoreCase) >= 0 || currentContent.IndexOf("Success", StringComparison.OrdinalIgnoreCase) >= 0 || currentContent.IndexOf("Fail", StringComparison.OrdinalIgnoreCase) >= 0 || currentContent.IndexOf("OFF", StringComparison.OrdinalIgnoreCase) >= 0) { isComplete = true; } else { // 가 오지 않았더라도, 명령어 에코가 아닌 줄에서 16자리 ID를 발견하면 즉시 완료 처리 string[] lines = currentContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { string trimmed = line.Trim(); if (trimmed.Contains("x00c_")) continue; var match = System.Text.RegularExpressions.Regex.Match(trimmed, @"[A-Za-z0-9]{16}"); if (match.Success) { isComplete = true; break; } } } if (isComplete) { if (_responseTcs != null && !_responseTcs.Task.IsCompleted) { LastResponse = currentContent.Trim(); _responseTcs.TrySetResult(currentContent); } _receiveBuffer.Clear(); } } public void Dispose() { Disconnect(); _communication.DataReceived -= OnDataReceived; _reconnectTimer?.Dispose(); _lock.Dispose(); } } /// /// 신규 4251 보드를 사용하여 제품 ID를 읽는 센서 서비스. /// ZmdiSensorService와 동일한 구조로 구현되어 교체가 용이함. /// public class Board4251SensorService : IIdSensorService { private readonly Board4251Service _service; private readonly int _sensorIndex; public event EventHandler ProgressMessage; public event EventHandler ErrorMessage; public event EventHandler ConnectionChanged; public Board4251SensorService(Board4251Service service, int sensorIndex) { _service = service; _sensorIndex = sensorIndex; // 좌우 오류 간섭을 막기 위해 공용 에러 이벤트 구독 해제 _service.ConnectionChanged += (s, isConnected) => ConnectionChanged?.Invoke(this, isConnected); } public bool Connect() => _service.Connect(); public void Disconnect() => _service.Disconnect(); public SensorIdData ReadSensor() { try { int channel = _sensorIndex + 1; // 0. 초기화 (x00o 전송) ProgressMessage?.Invoke(this, "4251 보드 초기화 중..."); // x00o 명령을 보내고 응답 내용에 상관없이 다음 단계 진행 (최대 5초 대기) Task.Run(() => _service.SendCommandAsync("x00o\r\n")).Wait(5000); Task.Delay(350).Wait(); // 보드 안정화 대기 // 1. 보드 상태 확인 (Fail인 경우 중단) ProgressMessage?.Invoke(this, "4251 보드 데이터 읽는 중..."); int extendedTimeout = 15000; string statusCmd = $"x00c_00{channel}101:owt28006727ea97c7801"; var statusTask = Task.Run(() => _service.CheckStatusAsync(channel)); if (!statusTask.Wait(extendedTimeout)) { string buf = _service.GetLastBuffer(); string displayBuf = string.IsNullOrEmpty(buf) ? "수신된 데이터 없음" : buf; ErrorMessage?.Invoke(this, $"통신 실패 (4251 보드 CH{channel} 상태 타임아웃)\r\n[송신값]: {statusCmd}\r\n[수신값]: {displayBuf}"); return null; } if (!statusTask.Result) { string buf = _service.GetLastBuffer(); string displayBuf = string.IsNullOrEmpty(buf) ? "수신된 데이터 없음" : buf; ErrorMessage?.Invoke(this, $"통신 실패 (4251 보드 CH{channel} 상태 이상 또는 Fail)\r\n[송신값]: {statusCmd}\r\n[수신값]: {displayBuf}"); return null; } // 2. ID 읽기 (끝자리가 F일 경우 최대 3회 시도) string idCmd = $"x00c_00{channel}101:ow2800326003e"; string rawId = null; int maxAttempts = 3; for (int attempt = 1; attempt <= maxAttempts; attempt++) { var idTask = Task.Run(() => _service.ReadIdAsync(channel)); if (!idTask.Wait(extendedTimeout)) { if (attempt == maxAttempts) { string buf = _service.GetLastBuffer(); string displayBuf = string.IsNullOrEmpty(buf) ? "수신된 데이터 없음" : buf; ErrorMessage?.Invoke(this, $"통신 실패 (4251 보드 CH{channel} ID 대기 타임아웃)\r\n[송신값]: {idCmd}\r\n[수신값]: {displayBuf}"); return null; } continue; } rawId = idTask.Result; if (string.IsNullOrEmpty(rawId)) { if (attempt == maxAttempts) { string buf = _service.GetLastBuffer(); string displayBuf = string.IsNullOrEmpty(buf) ? "수신된 데이터 없음" : buf; ErrorMessage?.Invoke(this, $"통신 실패 (4251 보드 CH{channel} ID 응답 없거나 파싱 오류)\r\n[송신값]: {idCmd}\r\n[수신값]: {displayBuf}"); return null; } continue; } // 정상적으로 파싱된 경우, 끝자리가 'F'인지 확인 if (rawId.EndsWith("F", StringComparison.OrdinalIgnoreCase)) { if (attempt < maxAttempts) { ProgressMessage?.Invoke(this, $"ID 읽기 재시도 중... ({attempt}/{maxAttempts})"); Task.Delay(350).Wait(); // 재시도 전 약간의 딜레이 continue; } else { ProgressMessage?.Invoke(this, $"최대 재시도(2회 추가) 초과. 끝자리가 F인 ID({rawId})를 사용합니다."); } } // 제대로 된 값을 얻었거나 최대 횟수에 도달하면 루프 탈출 break; } // 3. SensorIdData 객체 구성 (16자리 ID를 각 필드에 적절히 분배) // 신규 보드는 16자리 전체가 ID이므로, 파싱 로직 없이 통째로 넣거나 // 특정 규칙이 있다면 여기서 분할함. var data = new SensorIdData { LowID = rawId, ID = rawId, // 16자리 전체를 ID로 사용 Serial = "", // 시리얼 번호는 현재 존재하지 않으므로 강제로 파싱하지 않음 Item = "N/A", PrevResult = "F" // '불량제품 투입' 필터를 통과하기 위한 강제 초기화 }; ProgressMessage?.Invoke(this, "ID 읽기 성공"); return data; } catch (Exception ex) { ErrorMessage?.Invoke(this, $"4251 보드 읽기 중 예외 발생: {ex.Message}"); return null; } } public void Dispose() { // 공유 서비스(Board4251Service)는 외부(HomeViewModel)에서 관리하므로 여기서 해제하지 않음 } } /// /// 시리얼 통신을 기반으로 동작하는 신규 4251 DIO 보드 구현체. /// 기존 RealDioBoard(Legacy)와 교체 가능함. /// public class Board4251DioBoard : IDioBoard { private readonly Board4251Service _service; private readonly List _inputs = new List(); private readonly List _outputs = new List(); private bool _isDisposed = false; #pragma warning disable 0067 public event EventHandler InputChanged; #pragma warning restore 0067 public event EventHandler ErrorOccurred; public Board4251DioBoard(Board4251Service service) { _service = service; _service.ErrorOccurred += (s, msg) => ErrorOccurred?.Invoke(this, msg); InitializePoints(); } private void InitializePoints() { // 실제 보드 구성에 맞게 입출력 포인트 정의 (DioConfigParser 기반 혹은 하드코딩) // 일단 기존 프로젝트 구성과 호환되도록 빈 리스트 혹은 기본값 설정 var config = DioConfigParser.LoadDefault(); _inputs.AddRange(config.InputPoints); _outputs.AddRange(config.OutputPoints); } public bool Initialize() { // 시리얼 연결 시도 try { if (!_service.Connect()) { ErrorOccurred?.Invoke(this, $"4251 Board: Failed to open serial port."); return false; } // 보드 상태 확인 var statusTask = Task.Run(() => _service.CheckStatusAsync()); if (!statusTask.Wait(5000)) { ErrorOccurred?.Invoke(this, "4251 Board: Initialization Timeout (CheckStatus)."); return false; } return statusTask.Result; } catch (Exception ex) { ErrorOccurred?.Invoke(this, $"4251 Board: Initialization Error - {ex.Message}"); return false; } } public bool ReadInput(string pointName) { // 신규 보드의 입력 읽기 프로토콜이 필요한 부분 (현재 ReadId 등만 구현됨) // 구현 계획에는 ID 읽기와 상태 확인만 있었으므로, // 실제 DIO 기능을 위해선 추가적인 시리얼 명령이 필요할 수 있음. // 일단 true/false 로직 구현 return false; } public void WriteOutput(string pointName, bool value) { // 보드 출력 제어 명령 전송 (예시 프로토콜 필요) // _service.SendCommandAsync(...) 호출 형태가 될 것임. } public List GetInputPoints() => _inputs; public List GetOutputPoints() => _outputs; public void Dispose() { if (!_isDisposed) { _isDisposed = true; _service.Disconnect(); _service.Dispose(); } } } }