using System; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using RR.Game2D.Core.Managers; using UnityEngine; namespace RR.Common.Controls { internal sealed class ComDeviceClient { private const string RequestInput = "91"; private const string DefaultInputBits = "11111111111111111111111111"; private const long CoinDebounceMs = 250; private static readonly byte[] AesEncryptedKey = { 0x69, 0x40, 0xC7, 0x0E, 0xF2, 0xC7, 0x45, 0x70, 0x72, 0xCD, 0x28, 0x58, 0x99, 0x8F, 0x73, 0xA8, 0x5F, 0x5F, 0xED, 0xB4, 0xC1, 0x62, 0xB4, 0x24, 0xAC, 0xD7, 0xA3, 0xD6, 0xD8, 0x0B, 0x50, 0x37 }; private readonly object _ioLock = new object(); private readonly bool _enableEncryptedHandshake; private readonly int _handshakeTimeoutMs; private readonly bool _keepAlive; private readonly string _roleRequestCommand; private readonly string _roleResponsePrefix; private readonly int _roleRequestTimeoutMs; private CancellationTokenSource _lifecycleCts; private Task _pollTask; private Task _keepAliveTask; private int _handle; private long _lastCoinTime; private int _inputToggle; private int _missedInputResponses; private bool _isAuthorized; private bool _isOperational; private bool _handshakeInProgress; private string _requestHex = string.Empty; private string _lastHandshakeResponse = string.Empty; private string _lastInputBits = DefaultInputBits; private byte[] _aesKey32; private HandshakeStage _handshakeStage = HandshakeStage.None; private TaskCompletionSource _handshakeCompletion; private TaskCompletionSource _chalReceivedCompletion; private TaskCompletionSource _roleCompletion; private TaskCompletionSource _macCompletion; private enum HandshakeStage { None, WaitingChal, WaitingRaes, Completed, Failed } public string DeviceId { get; } public string PortName { get; } public bool IsOperational => _isOperational; public string LastInputBits => _lastInputBits; public string ControllerRole { get; private set; } = string.Empty; public string MacAddress { get; private set; } = string.Empty; public event Action CoinAccepted; public event Action InputReceived; public event Action RawLineReceived; public event Action Authorized; public event Action Disconnected; public event Action ConnectionLost; public ComDeviceClient( string portName, bool enableEncryptedHandshake, int handshakeTimeoutMs, bool keepAlive, string roleRequestCommand, string roleResponsePrefix, int roleRequestTimeoutMs) { PortName = portName; DeviceId = portName; _enableEncryptedHandshake = enableEncryptedHandshake; _handshakeTimeoutMs = Math.Max(500, handshakeTimeoutMs); _keepAlive = keepAlive; _roleRequestCommand = roleRequestCommand?.Trim() ?? string.Empty; _roleResponsePrefix = string.IsNullOrWhiteSpace(roleResponsePrefix) ? "ROLE" : roleResponsePrefix.Trim(); _roleRequestTimeoutMs = Math.Max(200, roleRequestTimeoutMs); if (_enableEncryptedHandshake) { _aesKey32 = BuildAesKey(); } } public bool Start() { if (!OpenPort()) return false; _lifecycleCts = new CancellationTokenSource(); _pollTask = Task.Run(() => PollLoopAsync(_lifecycleCts.Token), _lifecycleCts.Token); if (_enableEncryptedHandshake) { _ = RunHandshakeAsync(_lifecycleCts.Token); } else { MarkOperational(); } return true; } public void Stop(bool sendShutdownCommands) { try { _lifecycleCts?.Cancel(); } catch { // ignored } if (sendShutdownCommands) { SendRaw("2170", true); SendRaw("2130", true); } lock (_ioLock) { if (_handle > 0) { ComNativeWrapper.Serial_Close(_handle); _handle = 0; } } _isOperational = false; _isAuthorized = false; _handshakeInProgress = false; _handshakeStage = HandshakeStage.None; _handshakeCompletion?.TrySetCanceled(); _chalReceivedCompletion?.TrySetCanceled(); _roleCompletion?.TrySetCanceled(); _macCompletion?.TrySetCanceled(); ControllerRole = string.Empty; _keepAliveTask = null; Disconnected?.Invoke(DeviceId); _lifecycleCts?.Dispose(); _lifecycleCts = null; } public void Send(string message) { SendRaw(message, false); } private bool OpenPort() { int handle = ComNativeWrapper.Serial_Open(PortName, 115200, 8, 0, 1, 5, 10); if (handle <= 0) { GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] Open failed: {ComNativeWrapper.GetLastError()}"); return false; } lock (_ioLock) { _handle = handle; } _lastCoinTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); FlushPortBuffer(); return true; } private async Task PollLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await Task.Delay(1, token); string line = ReadBufferLine(); if (string.IsNullOrEmpty(line)) continue; ProcessLine(line); } catch (OperationCanceledException) { break; } catch (Exception e) { GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] Poll error: {e.Message}"); } } } private void ProcessLine(string line) { line = line.Trim(); if (string.IsNullOrEmpty(line)) return; if (_handshakeInProgress) { TryProcessHandshakeLine(line); return; } if (!_isOperational) return; if (TryHandleRoleLine(line)) { return; } if (TryHandleMacLine(line)) { return; } if (line.StartsWith("INPUT", StringComparison.Ordinal)) { _inputToggle = 1; if (line.Length <= 5) return; string rawData = line.Substring(5); if (string.IsNullOrWhiteSpace(rawData)) return; // Проверяем датчик хоппера: 2-й бит с начала в нормальном двоичном представлении if (long.TryParse(rawData, out long rawValue)) { string rawBinary = Convert.ToString(rawValue, 2); if (rawBinary.Length >= 2 && rawBinary[1] == '0') PrizeSystem.SignalPrizeGiven(); } string bits = DecimalToBinary(rawData); _lastInputBits = bits; InputReceived?.Invoke(DeviceId, bits); return; } RawLineReceived?.Invoke(DeviceId, line); } private async Task RunHandshakeAsync(CancellationToken token) { _handshakeInProgress = true; _isAuthorized = false; _isOperational = false; _handshakeStage = HandshakeStage.WaitingChal; _lastHandshakeResponse = string.Empty; _handshakeCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _chalReceivedCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Debug.Log($"[COM AUTH][{DeviceId}] Handshake started. Stage={_handshakeStage}"); _requestHex = GenerateRandomBlock16Hex(); // Порядок handshake по протоколу платы: // PC -> 07[random], board -> CHAL, PC -> 08[enc(CHAL)], board -> RAES, board -> AUTH_CNT_OK. SendRaw("07" + _requestHex, true); Debug.Log($"[COM AUTH][{DeviceId}] -> 07{_requestHex}"); // Если на 07 не получили ожидаемый CHAL быстро, сразу пропускаем устройство. int chalFastTimeoutMs = Math.Min(1200, Math.Max(250, _handshakeTimeoutMs / 4)); Task chalFastTimeoutTask = Task.Delay(chalFastTimeoutMs, token); Task chalFastFinished = await Task.WhenAny(_chalReceivedCompletion.Task, chalFastTimeoutTask); if (chalFastFinished != _chalReceivedCompletion.Task) { Debug.LogWarning($"[COM AUTH][{DeviceId}] No CHAL after 07 in {chalFastTimeoutMs}ms. Skipping device."); FailHandshake($"Нет ожидаемого CHAL на 07 за {chalFastTimeoutMs}ms"); return; } Task timeoutTask = Task.Delay(_handshakeTimeoutMs, token); Task finished = await Task.WhenAny(_handshakeCompletion.Task, timeoutTask); if (finished == _handshakeCompletion.Task && _handshakeCompletion.Task.Result) { Debug.Log($"[COM AUTH][{DeviceId}] Handshake completed successfully"); MarkOperational(); return; } GameManager.Logger.LogWarning($"COM AUTH [{DeviceId}]", $"Handshake timeout. Last board response: {_lastHandshakeResponse}"); FailHandshake($"Таймаут рукопожатия. Последний ответ платы: {_lastHandshakeResponse}"); } private void TryProcessHandshakeLine(string line) { Debug.Log($"[COM AUTH][{DeviceId}] <- {line}; stage={_handshakeStage}"); _lastHandshakeResponse = line; // Во время handshake обрабатываем только ожидаемые сообщения: // WaitingChal -> CHAL // WaitingRaes -> RAES // Все остальные строки игнорируем. if (_handshakeStage == HandshakeStage.WaitingChal) { if (line.StartsWith("CHAL", StringComparison.Ordinal)) { HandleChal(line); } else { Debug.Log($"[COM AUTH][{DeviceId}] Ignored while WaitingChal: {line}"); } return; } if (_handshakeStage == HandshakeStage.WaitingRaes) { if (line.StartsWith("RAES", StringComparison.Ordinal)) { HandleRaes(line); } else { Debug.Log($"[COM AUTH][{DeviceId}] Ignored while WaitingRaes: {line}"); } return; } Debug.Log($"[COM AUTH][{DeviceId}] Ignored in stage {_handshakeStage}: {line}"); } private void HandleRaes(string line) { try { Debug.Log($"[COM AUTH][{DeviceId}] Handling RAES..."); if (_handshakeStage != HandshakeStage.WaitingRaes) { FailHandshake($"RAES в неверной фазе: {_handshakeStage}"); return; } if (line.Length < 36) { FailHandshake("Некорректная длина RAES"); return; } string hex = line.Substring(4, 32); if (!IsHexString(hex, 32)) { FailHandshake("RAES содержит некорректный HEX"); return; } string decrypted = DecryptHexBlock16(hex); if (!string.Equals(decrypted, _requestHex, StringComparison.OrdinalIgnoreCase)) { FailHandshake("RAES не совпадает с запросом 07"); return; } _handshakeStage = HandshakeStage.Completed; _isAuthorized = true; Debug.Log($"[COM AUTH][{DeviceId}] RAES verified. Raw RAES={line}; decrypted={decrypted}; handshake complete"); _handshakeCompletion?.TrySetResult(true); } catch (Exception e) { FailHandshake($"Ошибка RAES: {e.Message}"); } } private void HandleChal(string line) { try { Debug.Log($"[COM AUTH][{DeviceId}] Handling CHAL..."); if (_handshakeStage != HandshakeStage.WaitingChal) { FailHandshake($"CHAL в неверной фазе: {_handshakeStage}"); return; } _chalReceivedCompletion?.TrySetResult(true); if (line.Length < 36) { FailHandshake("Некорректная длина CHAL"); return; } string hex = line.Substring(4, 32); if (!IsHexString(hex, 32)) { FailHandshake("CHAL содержит некорректный HEX"); return; } string encrypted = EncryptHexBlock16(hex); SendRaw("08" + encrypted, true); _handshakeStage = HandshakeStage.WaitingRaes; Debug.Log($"[COM AUTH][{DeviceId}] -> 08{encrypted}; stage={_handshakeStage}"); } catch (Exception e) { FailHandshake($"Ошибка CHAL: {e.Message}"); } } private void MarkOperational() { _handshakeInProgress = false; _isAuthorized = true; _isOperational = true; _handshakeStage = HandshakeStage.Completed; ControllerRole = string.Empty; _missedInputResponses = 0; Debug.Log($"[COM AUTH][{DeviceId}] Device marked operational"); SendRaw("2171", true); SendRaw("890003", true); SendRaw("8600", true); if (_keepAlive && (_keepAliveTask == null || _keepAliveTask.IsCompleted)) { _keepAliveTask = Task.Run(() => KeepAliveLoopAsync(_lifecycleCts.Token), _lifecycleCts.Token); } _ = Task.Run(() => ResolveRoleAfterAuthorizationAsync(_lifecycleCts.Token), _lifecycleCts.Token); } private void FailHandshake(string message) { _isAuthorized = false; _isOperational = false; _handshakeInProgress = false; _handshakeStage = HandshakeStage.Failed; _handshakeCompletion?.TrySetResult(false); Debug.LogWarning($"[COM AUTH][{DeviceId}] Handshake failed: {message}"); GameManager.Logger?.LogWarning("ComDeviceClient", $"[{DeviceId}] {message}"); // Если порт отвечает не по ожидаемому протоколу платы, // отключаем устройство, чтобы менеджер выбрал другой порт. Stop(false); } private async Task KeepAliveLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await Task.Delay(2000, token); if (!_isOperational) { await Task.Delay(8000, token); continue; } SendRaw(RequestInput, false); _inputToggle = 0; await Task.Delay(1000, token); if (_inputToggle == 0) { _missedInputResponses++; if (_missedInputResponses >= 3) { GameManager.Logger?.LogWarning("ComDeviceClient", $"[{DeviceId}] Нет INPUT-ответа {_missedInputResponses} цикла подряд, выполняю reconnect"); _missedInputResponses = 0; await ReconnectAsync(token); } } else { _missedInputResponses = 0; } await Task.Delay(7000, token); } catch (OperationCanceledException) { break; } catch (Exception e) { GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] KeepAlive error: {e.Message}"); } } } private async Task ReconnectAsync(CancellationToken token) { _isOperational = false; _isAuthorized = false; _handshakeInProgress = false; _handshakeStage = HandshakeStage.None; _lastInputBits = DefaultInputBits; _inputToggle = 0; ControllerRole = string.Empty; MacAddress = string.Empty; ConnectionLost?.Invoke(DeviceId); lock (_ioLock) { if (_handle > 0) { ComNativeWrapper.Serial_Close(_handle); _handle = 0; } } if (!OpenPort()) { // Порт недоступен при переподключении: отключаем устройство и даем менеджеру // переключиться на другие доступные порты. Stop(false); return; } if (_enableEncryptedHandshake) { await RunHandshakeAsync(token); } else { MarkOperational(); } } private void FlushPortBuffer() { lock (_ioLock) { if (_handle <= 0) return; byte[] localBuf = new byte[1024]; int flushedCount = 0; int read; while ((read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(_handle, localBuf, localBuf.Length)) > 0) { flushedCount++; if (flushedCount > 100) break; } } } private string ReadBufferLine() { lock (_ioLock) { if (_handle <= 0) return string.Empty; byte[] localBuf = new byte[1024]; int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(_handle, localBuf, localBuf.Length); if (read <= 0) return string.Empty; if (read > localBuf.Length) read = localBuf.Length; string line = Encoding.UTF8.GetString(localBuf, 0, read).Trim(); if (line != "COIN") return line; long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (now - _lastCoinTime < CoinDebounceMs) return string.Empty; _lastCoinTime = now; if (_isOperational) { ComNativeWrapper.Serial_WriteStringUTF8(_handle, "10", 1); CoinAccepted?.Invoke(DeviceId); } return string.Empty; } } private void SendRaw(string message, bool bypassAuthorization) { if (string.IsNullOrWhiteSpace(message)) return; if (!bypassAuthorization && !_isOperational) return; lock (_ioLock) { if (_handle <= 0) return; ComNativeWrapper.Serial_WriteStringUTF8(_handle, message, 1); } } private bool TryHandleRoleLine(string line) { if (_roleCompletion == null) return false; if (!_roleCompletion.Task.IsCompleted && TryExtractRole(line, out string role)) { ControllerRole = role; _roleCompletion.TrySetResult(role); return true; } return false; } private bool TryHandleMacLine(string line) { if (_macCompletion == null) return false; if (!_macCompletion.Task.IsCompleted && TryExtractMac(line, out string mac)) { MacAddress = mac; _macCompletion.TrySetResult(mac); return true; } return false; } private static bool TryExtractMac(string line, out string mac) { mac = string.Empty; if (string.IsNullOrWhiteSpace(line)) return false; string trimmed = line.Trim(); // Ответ на "06" — голый MAC без двоеточий, 12 hex-символов if (trimmed.Length == 12 && IsHexString(trimmed)) { mac = trimmed.ToUpperInvariant(); return true; } return false; } private static bool IsHexString(string s) { for (int i = 0; i < s.Length; i++) { char c = s[i]; if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) return false; } return true; } private async Task ResolveRoleAfterAuthorizationAsync(CancellationToken token) { // Роль и MAC считываем только после завершенной авторизации рукопожатия. if (!_enableEncryptedHandshake || !_isAuthorized || string.IsNullOrWhiteSpace(_roleRequestCommand)) { Authorized?.Invoke(DeviceId); return; } try { // --- Запрос роли --- _roleCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); SendRaw(_roleRequestCommand, false); Task timeoutTask = Task.Delay(_roleRequestTimeoutMs, token); Task finished = await Task.WhenAny(_roleCompletion.Task, timeoutTask); if (finished == _roleCompletion.Task && _roleCompletion.Task.IsCompletedSuccessfully && !string.IsNullOrWhiteSpace(_roleCompletion.Task.Result)) { ControllerRole = _roleCompletion.Task.Result; } else { GameManager.Logger?.LogWarning("ComDeviceClient", $"[{DeviceId}] Не удалось получить роль контроллера за {_roleRequestTimeoutMs}ms"); } // --- Запрос MAC-адреса --- _macCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); SendRaw("06", false); Task macTimeoutTask = Task.Delay(_roleRequestTimeoutMs, token); Task macFinished = await Task.WhenAny(_macCompletion.Task, macTimeoutTask); if (macFinished == _macCompletion.Task && _macCompletion.Task.IsCompletedSuccessfully && !string.IsNullOrWhiteSpace(_macCompletion.Task.Result)) { MacAddress = _macCompletion.Task.Result; GameManager.Logger?.Log("ComDeviceClient", $"[{DeviceId}] MAC-адрес: {MacAddress}"); } else { GameManager.Logger?.LogWarning("ComDeviceClient", $"[{DeviceId}] Не удалось получить MAC-адрес за {_roleRequestTimeoutMs}ms"); } } catch (OperationCanceledException) { // ignore } catch (Exception e) { GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] Role/MAC resolve error: {e.Message}"); } finally { Authorized?.Invoke(DeviceId); } } private bool TryExtractRole(string line, out string role) { role = string.Empty; if (string.IsNullOrWhiteSpace(line)) return false; string trimmed = line.Trim(); if (!trimmed.StartsWith(_roleResponsePrefix, StringComparison.OrdinalIgnoreCase)) { return false; } string tail = trimmed.Substring(_roleResponsePrefix.Length).Trim(); if (tail.StartsWith(":") || tail.StartsWith("=")) { tail = tail.Substring(1).Trim(); } if (string.IsNullOrWhiteSpace(tail)) return false; role = tail; return true; } private static string DecimalToBinary(string decimalString) { try { if (!long.TryParse(decimalString, out long value)) return DefaultInputBits; string binary = Convert.ToString(value, 2); char[] chars = binary.ToCharArray(); Array.Reverse(chars); return new string(chars); } catch { return DefaultInputBits; } } private static byte[] BuildAesKey() { byte[] key = new byte[32]; for (int i = 0; i < key.Length; i++) { key[i] = (byte)(AesEncryptedKey[i] ^ 0x37); } return key; } private string EncryptHexBlock16(string plainHex) { byte[] plain = HexToBytes(plainHex); if (plain.Length != 16) throw new InvalidOperationException("Plain must be 16 bytes."); return BytesToHex(Aes256EcbEncrypt(plain)); } private string DecryptHexBlock16(string cipherHex) { byte[] cipher = HexToBytes(cipherHex); if (cipher.Length != 16) throw new InvalidOperationException("Cipher must be 16 bytes."); return BytesToHex(Aes256EcbDecrypt(cipher)); } private byte[] Aes256EcbEncrypt(byte[] data) { using (Aes aes = Aes.Create()) { aes.Key = _aesKey32; aes.Mode = CipherMode.ECB; aes.Padding = PaddingMode.None; using (ICryptoTransform encryptor = aes.CreateEncryptor()) { return encryptor.TransformFinalBlock(data, 0, data.Length); } } } private byte[] Aes256EcbDecrypt(byte[] data) { using (Aes aes = Aes.Create()) { aes.Key = _aesKey32; aes.Mode = CipherMode.ECB; aes.Padding = PaddingMode.None; using (ICryptoTransform decryptor = aes.CreateDecryptor()) { return decryptor.TransformFinalBlock(data, 0, data.Length); } } } private static string GenerateRandomBlock16Hex() { byte[] buffer = new byte[16]; RandomNumberGenerator.Fill(buffer); return BytesToHex(buffer); } private static byte[] HexToBytes(string hex) { if (hex == null) throw new ArgumentNullException(nameof(hex)); hex = hex.Trim(); if (hex.Length % 2 != 0) { throw new InvalidOperationException("Hex string length must be even."); } byte[] result = new byte[hex.Length / 2]; for (int i = 0; i < result.Length; i++) { result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); } return result; } private static string BytesToHex(byte[] bytes) { StringBuilder builder = new StringBuilder(bytes.Length * 2); for (int i = 0; i < bytes.Length; i++) { builder.AppendFormat("{0:X2}", bytes[i]); } return builder.ToString(); } private static bool IsHexString(string value, int expectedLength) { if (string.IsNullOrWhiteSpace(value) || value.Length != expectedLength) { return false; } for (int i = 0; i < value.Length; i++) { if (!Uri.IsHexDigit(value[i])) { return false; } } return true; } } }