Danil Iarosh 32cfa79578 refactor: Refactor work with COM to ComSystemManager
- Добавлен ComSystemManager как новый публичный компонент COM-системы для поэтапной замены ComWrapper
- Реализовано получение роли контроллера только после успешного handshake в ComDeviceClient
- Добавлено публичное поле ControllerRole в ComDeviceClient
- Добавлен запрос роли по команде 97 и разбор ответа формата ROLE : <value> (с пробелами)
- В ComMultiManager добавлена логика выбора DefaultDeviceId по роли main_controller
- Добавлен доступ к роли устройства через GetDeviceRole(deviceId) в менеджере и фасаде
- В ComWrapper добавлены настройки роли: roleRequestCommand, roleResponsePrefix, roleRequestTimeoutMs, mainControllerRole
- Дефолтная команда запроса роли изменена на 97
- Выполнена кодовая миграция зависимостей с ComWrapper на ComSystemManager в GameManager, GlobalsManager, PaymentManager, BalanceManager, InactivityManager, COMInput, LightController, PrizeSystem
- В GlobalsManager исправлена инициализация ссылки после AddComponent<ComSystemManager>(), чтобы исключить NullReference
2026-02-20 14:59:16 +03:00

724 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using RR.Game2D.Core.Managers;
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 bool _isAuthorized;
private bool _isOperational;
private bool _handshakeInProgress;
private string _requestHex = string.Empty;
private string _lastInputBits = DefaultInputBits;
private byte[] _aesKey32;
private HandshakeStage _handshakeStage = HandshakeStage.None;
private TaskCompletionSource<bool> _handshakeCompletion;
private TaskCompletionSource<string> _roleCompletion;
private enum HandshakeStage
{
None,
WaitingRaes,
WaitingChal,
WaitingReady,
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 event Action<string> CoinAccepted;
public event Action<string, string> InputReceived;
public event Action<string> Authorized;
public event Action<string> Disconnected;
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();
_roleCompletion?.TrySetCanceled();
ControllerRole = string.Empty;
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 (line.StartsWith("INPUT", StringComparison.Ordinal))
{
_inputToggle = 1;
if (line.Length <= 5) return;
string rawData = line.Substring(5);
if (string.IsNullOrWhiteSpace(rawData)) return;
string bits = DecimalToBinary(rawData);
_lastInputBits = bits;
InputReceived?.Invoke(DeviceId, bits);
}
}
private async Task RunHandshakeAsync(CancellationToken token)
{
_handshakeInProgress = true;
_isAuthorized = false;
_isOperational = false;
_handshakeStage = HandshakeStage.WaitingRaes;
_handshakeCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
SendRaw("00", true);
await Task.Delay(1000, token);
_requestHex = GenerateRandomBlock16Hex();
SendRaw("07" + _requestHex, true);
Task timeoutTask = Task.Delay(_handshakeTimeoutMs, token);
Task finished = await Task.WhenAny(_handshakeCompletion.Task, timeoutTask);
if (finished == _handshakeCompletion.Task && _handshakeCompletion.Task.Result)
{
MarkOperational();
return;
}
FailHandshake("Таймаут рукопожатия");
}
private void TryProcessHandshakeLine(string line)
{
if (line.Equals("READY", StringComparison.OrdinalIgnoreCase))
{
if (_handshakeStage != HandshakeStage.WaitingReady)
{
FailHandshake($"READY в неверной фазе: {_handshakeStage}");
return;
}
_handshakeStage = HandshakeStage.Completed;
_isAuthorized = true;
_handshakeCompletion?.TrySetResult(true);
return;
}
if (line.StartsWith("RAES", StringComparison.Ordinal))
{
HandleRaes(line);
return;
}
if (line.StartsWith("CHAL", StringComparison.Ordinal))
{
HandleChal(line);
}
}
private void HandleRaes(string line)
{
try
{
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.WaitingChal;
}
catch (Exception e)
{
FailHandshake($"Ошибка RAES: {e.Message}");
}
}
private void HandleChal(string line)
{
try
{
if (_handshakeStage != HandshakeStage.WaitingChal)
{
FailHandshake($"CHAL в неверной фазе: {_handshakeStage}");
return;
}
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.WaitingReady;
}
catch (Exception e)
{
FailHandshake($"Ошибка CHAL: {e.Message}");
}
}
private void MarkOperational()
{
_handshakeInProgress = false;
_isAuthorized = true;
_isOperational = true;
_handshakeStage = HandshakeStage.Completed;
ControllerRole = string.Empty;
SendRaw("2171", true);
if (_keepAlive)
{
_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);
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] {message}");
}
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)
{
await ReconnectAsync(token);
}
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;
lock (_ioLock)
{
if (_handle > 0)
{
ComNativeWrapper.Serial_Close(_handle);
_handle = 0;
}
}
if (!OpenPort())
{
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 async Task ResolveRoleAfterAuthorizationAsync(CancellationToken token)
{
// Роль считываем только после завершенной авторизации рукопожатия.
if (!_enableEncryptedHandshake || !_isAuthorized || string.IsNullOrWhiteSpace(_roleRequestCommand))
{
Authorized?.Invoke(DeviceId);
return;
}
try
{
_roleCompletion = new TaskCompletionSource<string>(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");
}
}
catch (OperationCanceledException)
{
// ignore
}
catch (Exception e)
{
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] Role 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;
}
}
}