Danil Iarosh 2963e342c2
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
refactor: improve COM flow and light timing
Архитектура COM и роли
- ComMultiManager переименован в ComHub и переведен на фабричный паттерн обработчиков по ROLE.
- Добавлен единый контракт обработчиков COM-контроллеров:
  - IComControllerHandler
  - DefaultComControllerHandler
- Ответственность за оркестрацию перенесена в ComSystemManager, чтобы инициализация COM и жизненный цикл контроллеров управлялись централизованно.
- ComWrapper сохранен как слой совместимости на время миграции логики в новую схему.
- PrizeStandController отрефакторен под новый role-handler pipeline вместо старой прямой привязки.
- Добавлен удобный доступ к PrizeStandController из менеджера системы там, где это требуется игровому коду.

Handshake/авторизация платы (ComDeviceClient)
- Авторизация платы переработана в строгую поэтапную последовательность:
  1) ПК отправляет 07
  2) ожидает CHAL
  3) отправляет 08 в ответ на CHAL
  4) ожидает RAES
- Парсер стадий теперь принимает только ожидаемые токены на каждой стадии:
  - WaitingChal -> только CHAL
  - WaitingRaes -> только RAES
- Любой посторонний вывод из serial во время handshake игнорируется (без немедленного падения), что устраняет ложные ошибки на нерелевантных COM-устройствах.
- Если ожидаемый ответ после 07 не приходит, порт быстро пропускается как нерелевантный, без эскалации в критическую ошибку.
- Для случаев с неподходящими/сторонними устройствами уровень логирования снижен с ошибок до предупреждений там, где это уместно.
- Добавлена подробная диагностика авторизации (переходы стадий, принятые значимые кадры, контекст таймаутов) для упрощения отладки.

Тестовая утилита handshake
- Добавлен Assets/Temp/Handshake_Test.cs как изолированный диагностический скрипт.
- Включены очень подробные логи по каждому шагу handshake и по каждому решению парсера.
- Позволяет отдельно гонять тестовый сценарий и сравнивать поведение платы, не ломая production-поток.

Стабильность COM и поведение reconnect
- Подправлена логика keep-alive, чтобы исключить агрессивные реконнекты.
- Reconnect теперь срабатывает только после нескольких последовательных пропусков INPUT-ответа, что снижает случайные обрывы COM и прерывания подсветки.

Поведение подсветки во время выполнения
- Циклы подсветки сделаны независимыми от Time.timeScale:
  - LightController: WaitForSeconds -> WaitForSecondsRealtime
  - LightSequence: WaitForSeconds -> WaitForSecondsRealtime
- Это устраняет случайные паузы/замедления световых циклов при изменении игрового времени (пауза, slow-mo, временные изменения scale).

Итоговый эффект
- COM-стек стал расширяемым по ролям и проще в сопровождении.
- Handshake стал детерминированным, устойчивым к serial-шуму и безопасным при работе со сторонними устройствами.
- Циклы подсветки стабилизированы для длительных сессий и больше не зависят от масштабирования игрового времени.
2026-02-24 11:34:25 +03:00

790 lines
26 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;
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<bool> _handshakeCompletion;
private TaskCompletionSource<bool> _chalReceivedCompletion;
private TaskCompletionSource<string> _roleCompletion;
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 event Action<string> CoinAccepted;
public event Action<string, string> InputReceived;
public event Action<string, string> RawLineReceived;
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();
_chalReceivedCompletion?.TrySetCanceled();
_roleCompletion?.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 (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);
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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_chalReceivedCompletion = new TaskCompletionSource<bool>(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;
}
Debug.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);
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;
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 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;
}
}
}