diff --git a/Assets/Scripts/Common/GameManager.cs b/Assets/Scripts/Common/GameManager.cs
index e33d317..15d7993 100644
--- a/Assets/Scripts/Common/GameManager.cs
+++ b/Assets/Scripts/Common/GameManager.cs
@@ -145,7 +145,11 @@ public class GameManager : MonoBehaviour
}
}
- private void OnApplicationQuit() => ComNativeWrapper.Serial_CloseAll();
+ private void OnApplicationQuit()
+ {
+ ComWrapper.Shutdown();
+ ComNativeWrapper.Serial_CloseAll();
+ }
private void Update()
{
@@ -172,6 +176,7 @@ public class GameManager : MonoBehaviour
RefundEntryIfAvailable();
_isLeavingGameplay = true;
CancelPrizeFlow();
+ VitrinaView.Instance?.Deactivate();
ControlsPerformer.Instance.SetControlable(null);
timerManager.HideBars();
VideoUtility.CrossFadeToMenu();
diff --git a/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs
new file mode 100644
index 0000000..b8d5d30
--- /dev/null
+++ b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+
+public static class BoardAuthentication
+{
+ private const int _blockSizeBytes = 16;
+ private const int _hexBlockLength = _blockSizeBytes * 2;
+
+ private static readonly byte[] _aesObfuscatedKey =
+ {
+ 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
+ };
+
+ public static string CreateChallengeHex()
+ {
+ byte[] challenge = new byte[_blockSizeBytes];
+ using RandomNumberGenerator generator = RandomNumberGenerator.Create();
+ generator.GetBytes(challenge);
+ return ToHex(challenge);
+ }
+
+ public static string EncryptHexBlock(string plainHex)
+ {
+ byte[] plain = ParseHexBlock(plainHex);
+
+ using Aes aes = Aes.Create();
+ aes.KeySize = 256;
+ aes.BlockSize = 128;
+ aes.Mode = CipherMode.ECB;
+ aes.Padding = PaddingMode.None;
+ aes.Key = (byte[])_aesObfuscatedKey.Clone();
+
+ using ICryptoTransform encryptor = aes.CreateEncryptor();
+ byte[] encrypted = encryptor.TransformFinalBlock(plain, 0, plain.Length);
+ return ToHex(encrypted);
+ }
+
+ public static bool TryExtractHexPayload(string line, string prefix, out string payload)
+ {
+ payload = null;
+ if (string.IsNullOrEmpty(line)
+ || string.IsNullOrEmpty(prefix)
+ || line.Length != prefix.Length + _hexBlockLength
+ || !line.StartsWith(prefix, StringComparison.Ordinal))
+ return false;
+
+ string candidate = line.Substring(prefix.Length);
+ for (int i = 0; i < candidate.Length; i++)
+ {
+ if (!IsHexCharacter(candidate[i]))
+ return false;
+ }
+
+ payload = candidate.ToUpperInvariant();
+ return true;
+ }
+
+ public static bool ValidateEncryptedResponse(string challengeHex, string responseHex)
+ {
+ if (!TryNormalizeHexBlock(responseHex, out string normalizedResponse))
+ return false;
+
+ string expectedResponse = EncryptHexBlock(challengeHex);
+ int difference = 0;
+ for (int i = 0; i < expectedResponse.Length; i++)
+ difference |= expectedResponse[i] ^ normalizedResponse[i];
+
+ return difference == 0;
+ }
+
+ private static byte[] ParseHexBlock(string value)
+ {
+ if (!TryNormalizeHexBlock(value, out string normalized))
+ throw new ArgumentException($"Ожидался HEX-блок длиной {_hexBlockLength} символа.", nameof(value));
+
+ byte[] bytes = new byte[_blockSizeBytes];
+ for (int i = 0; i < bytes.Length; i++)
+ bytes[i] = Convert.ToByte(normalized.Substring(i * 2, 2), 16);
+
+ return bytes;
+ }
+
+ private static bool TryNormalizeHexBlock(string value, out string normalized)
+ {
+ normalized = null;
+ if (string.IsNullOrEmpty(value) || value.Length != _hexBlockLength)
+ return false;
+
+ for (int i = 0; i < value.Length; i++)
+ {
+ if (!IsHexCharacter(value[i]))
+ return false;
+ }
+
+ normalized = value.ToUpperInvariant();
+ return true;
+ }
+
+ private static bool IsHexCharacter(char value)
+ => value >= '0' && value <= '9'
+ || value >= 'A' && value <= 'F'
+ || value >= 'a' && value <= 'f';
+
+ private static string ToHex(byte[] bytes)
+ {
+ StringBuilder builder = new(bytes.Length * 2);
+ for (int i = 0; i < bytes.Length; i++)
+ builder.Append(bytes[i].ToString("X2"));
+
+ return builder.ToString();
+ }
+}
diff --git a/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs.meta b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs.meta
new file mode 100644
index 0000000..293e8a2
--- /dev/null
+++ b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b353d677af907bc47a6706e829da2a34
\ No newline at end of file
diff --git a/Assets/Scripts/Input/ComSystem/ComPortScanner.cs b/Assets/Scripts/Input/ComSystem/ComPortScanner.cs
index bb83ef4..f688326 100644
--- a/Assets/Scripts/Input/ComSystem/ComPortScanner.cs
+++ b/Assets/Scripts/Input/ComSystem/ComPortScanner.cs
@@ -15,7 +15,8 @@ public static class ComPortScanner
// Команды устройств
private const string CmdPrize = "97"; // Для витрины и периферийника2
- private const string CmdControllerV1 = "666"; // Только для периферийника1
+ private const string CmdControllerAuth = "07";
+ private const int DeviceResponseTimeoutMs = 1000;
// Типы устройств
public enum DeviceType
@@ -27,8 +28,6 @@ public static class ComPortScanner
}
// Префиксы ответов
- private const string ControllerV1ResponsePrefix = "ENC:"; // Периферийник1 на 666
- private const string ControllerV1NotActivatedPrefix = "NOT_ACTIVATED:"; // Периферийник1 на всё остальное
private const string RolePrefix = "ROLE :"; // Периферийник2 и витрина на 97
private const string PrizeRoleValue = "vitrina"; // Витрина: ROLE : vitrina
@@ -61,19 +60,10 @@ public static class ComPortScanner
private static DeviceType CheckPort(string port)
{
Debug.Log($"Проверка порта {port}");
- // ШАГ 1: Проверяем периферийник1 по команде 666
- string response666 = CheckDevice(port, CmdControllerV1);
- if (response666 != null)
- {
- // Если ответ начинается с "ENC:" - это точно периферийник1
- if (response666.StartsWith(ControllerV1ResponsePrefix) || response666.StartsWith("ALREADY_ACTIVATED"))
- {
- return DeviceType.ControllerV1;
- }
- }
-
- // ШАГ 2: Проверяем по команде 97 (витрина и периферийник2)
- string response97 = CheckDevice(port, CmdPrize);
+ string response97 = CheckDevice(
+ port,
+ CmdPrize,
+ line => line.StartsWith(RolePrefix, StringComparison.Ordinal));
if (response97 != null && response97.StartsWith(RolePrefix))
{
Debug.Log($"Role: {response97}");
@@ -89,15 +79,15 @@ public static class ComPortScanner
{
return DeviceType.ControllerV2;
}
-
}
- // ШАГ 3: Дополнительная проверка для неактивированного периферийника1
- // Если устройство ответило на 97 с "NOT_ACTIVATED:" - это периферийник1
- if (response97 != null && response97.StartsWith(ControllerV1NotActivatedPrefix))
- {
+ string computerChallenge = BoardAuthentication.CreateChallengeHex();
+ string authResponse = CheckDevice(
+ port,
+ CmdControllerAuth + computerChallenge,
+ IsAuthenticationResponse);
+ if (authResponse != null)
return DeviceType.ControllerV1;
- }
return DeviceType.None;
}
@@ -142,7 +132,7 @@ public static class ComPortScanner
///
/// Проверяет устройство на порту и возвращает ответ
///
- private static string CheckDevice(string port, string command)
+ private static string CheckDevice(string port, string command, Func isExpectedResponse)
{
int handle = ComNativeWrapper.Serial_Open(port, BaudRate, DataBits, Parity, StopBits, 10, WriteTimeoutMs);
if (handle <= 0)
@@ -161,19 +151,28 @@ public static class ComPortScanner
}
byte[] buffer = new byte[1024];
- int bytesRead = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle, buffer, buffer.Length);
-
- if (bytesRead <= 0)
+ long deadline = DateTime.UtcNow.Ticks
+ + DeviceResponseTimeoutMs * TimeSpan.TicksPerMillisecond;
+ while (DateTime.UtcNow.Ticks < deadline)
{
- return null;
+ int bytesRead = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle, buffer, buffer.Length);
+ if (bytesRead <= 0)
+ continue;
+
+ string line = Encoding.UTF8.GetString(buffer, 0, bytesRead).Trim();
+ if (!string.IsNullOrEmpty(line) && isExpectedResponse(line))
+ return line;
}
- string line = Encoding.UTF8.GetString(buffer, 0, bytesRead).Trim();
- return string.IsNullOrEmpty(line) ? null : line;
+ return null;
}
finally
{
- ComNativeWrapper.Serial_CloseAll();
+ ComNativeWrapper.Serial_Close(handle);
}
}
-}
\ No newline at end of file
+
+ private static bool IsAuthenticationResponse(string line)
+ => BoardAuthentication.TryExtractHexPayload(line, "CHAL", out _)
+ || BoardAuthentication.TryExtractHexPayload(line, "RAES", out _);
+}
diff --git a/Assets/Scripts/Input/ComSystem/ComWrapper.cs b/Assets/Scripts/Input/ComSystem/ComWrapper.cs
index f62efb7..810fc34 100644
--- a/Assets/Scripts/Input/ComSystem/ComWrapper.cs
+++ b/Assets/Scripts/Input/ComSystem/ComWrapper.cs
@@ -16,25 +16,37 @@ public static class ComWrapper
public static event Action PaymentReceived;
public static event Action InputRecieved;
- private const string REQUEST_INPUT_COMMAND = "91";
- private const string REQUEST_ENCRYPTED_COMMAND = "666";
- private const string REQUEST_CONFIRMATION_COMMAND = "777";
+ private const string _requestInputCommand = "91";
+ private const string _requestAuthCommand = "07";
+ private const string _responseAuthCommand = "08";
+ private const string _challengePrefix = "CHAL";
+ private const string _encryptedResponsePrefix = "RAES";
+ private const string _readyResponse = "READY!";
+ private const string _cabinetLightingOnCommand = "856001";
+ private const string _cabinetLightingOffCommand = "856000";
+ private const int _authStepTimeoutMs = 3000;
+ private const int _authReadyTimeoutMs = 5000;
+ private const int _inputIdleBeforePingMs = 60_000;
+ private const int _keepAlivePollMs = 5000;
+ private const int _reconnectRetryMs = 10_000;
private static CancellationTokenSource _lifetimeCts;
private static Thread _readerThread;
private static CancellationTokenSource _readerCts;
private static int h;
- public static int BoardVersion { get; private set; }
- private static ILogger logger;
- private static string[] AvailablePorts => ComNativeWrapper.ListPorts();
+ private static int _lastInputTick;
+ private static string _controllerPort;
+ private static volatile bool _isAuthorized;
- private static int _lastInputTick = 0;
- private const int INPUT_IDLE_BEFORE_PING_MS = 60_000;//1 минута
+ public static int BoardVersion { get; private set; }
+
+ private static ILogger logger;
public static void Initialize(string port, int boardVersion)//Добавить логгер в конструктор
{
BoardVersion = boardVersion;
+ _controllerPort = port;
if (_lifetimeCts != null) return;
OpenPort(port);
@@ -45,7 +57,7 @@ public static class ComWrapper
}
UnityMainThreadDispatcher.Initialize();
_lifetimeCts = new CancellationTokenSource();
-
+ MarkInputActivity();
StartReaderThread();
_ = SetupBoardAsync(_lifetimeCts.Token);
@@ -130,13 +142,19 @@ public static class ComWrapper
if (line == "COIN")
{
+ if (!_isAuthorized)
+ return;
+
PaymentReceived?.Invoke();
Send("10");
}
else if (line.StartsWith("INPUT"))
{
- Volatile.Write(ref _lastInputTick, Environment.TickCount);
+ if (!_isAuthorized)
+ return;
+
+ MarkInputActivity();
string rawInputString = line.Substring(5);
string inputString = BoardVersion == 2 ? DecimalToBinary(rawInputString) : rawInputString;
@@ -215,8 +233,8 @@ public static class ComWrapper
static Task WaitForPrefixAsync(string prefix, int timeoutMs, CancellationToken ct)
=> WaitForAsync(s => s.StartsWith(prefix, StringComparison.Ordinal), timeoutMs, ct);
- static Task WaitForContainsAsync(string prefix, int timeoutMs, CancellationToken ct)
- => WaitForAsync(s => s.IndexOf(prefix, StringComparison.Ordinal) >= 0, timeoutMs, ct);
+ static Task WaitForExactAsync(string expected, int timeoutMs, CancellationToken ct)
+ => WaitForAsync(s => string.Equals(s, expected, StringComparison.Ordinal), timeoutMs, ct);
static void OnLine(string line)
{
@@ -249,18 +267,12 @@ public static class ComWrapper
private static async Task SetupBoardAsync(CancellationToken ct)
{
- bool auth;
- if (BoardVersion == 1)
- auth = await BoardAuthAsync(ct);
- else
- auth = true;
+ _isAuthorized = await BoardAuthAsync(ct);
- if (!auth)
- {
+ if (!_isAuthorized)
Log("Авторизация на плате не пройдена.");
- UnityMainThreadDispatcher.Instance.Enqueue(() => Application.Quit());
- return;
- }
+ else
+ HandleAuthorizationSucceeded();
_ = StartKeepAliveProcessAsync(ct);
}
@@ -270,7 +282,6 @@ public static class ComWrapper
{
Log("Инициализация порта");
- ComNativeWrapper.Serial_CloseAll();
Log("Открываю порт: " + port);
h = ComNativeWrapper.Serial_Open(port, 115200, 8, 0, 1, 4, 10);
@@ -351,52 +362,70 @@ public static class ComWrapper
private static async Task BoardAuthAsync(CancellationToken ct)
{
- //Проверяем зашифрована или нет
+ using CancellationTokenSource authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ CancellationToken authToken = authCts.Token;
+ string computerChallenge = BoardAuthentication.CreateChallengeHex();
+
try
{
- Send(REQUEST_INPUT_COMMAND);
- var inputLine = await WaitForPrefixAsync("INPUT", 1000, ct);
+ Task controllerChallengeTask = WaitForAsync(
+ line => BoardAuthentication.TryExtractHexPayload(line, _challengePrefix, out _),
+ _authStepTimeoutMs,
+ authToken);
+ Send(_requestAuthCommand + computerChallenge);
+
+ string controllerChallengeLine = await controllerChallengeTask;
+ if (!BoardAuthentication.TryExtractHexPayload(
+ controllerChallengeLine,
+ _challengePrefix,
+ out string controllerChallenge))
+ return false;
+
+ Task encryptedResponseTask = WaitForAsync(
+ line => BoardAuthentication.TryExtractHexPayload(line, _encryptedResponsePrefix, out _),
+ _authStepTimeoutMs,
+ authToken);
+ Task readyTask = WaitForExactAsync(
+ _readyResponse,
+ _authReadyTimeoutMs,
+ authToken);
+ Send(_responseAuthCommand + BoardAuthentication.EncryptHexBlock(controllerChallenge));
+
+ string encryptedResponseLine = await encryptedResponseTask;
+ if (!BoardAuthentication.TryExtractHexPayload(
+ encryptedResponseLine,
+ _encryptedResponsePrefix,
+ out string encryptedResponse)
+ || !BoardAuthentication.ValidateEncryptedResponse(computerChallenge, encryptedResponse))
+ {
+ Log("Контроллер не прошёл взаимную AES-проверку.");
+ return false;
+ }
+
+ await readyTask;
+ Log("Авторизация платы полностью завершена по READY!.");
return true;
}
- catch (TimeoutException)
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
- //Не пришел инпут
- Log("Шифрованная плата");
- }
-
- //Запрашиваем шифрованную строку
- string encLine;
- try
- {
- Send(REQUEST_ENCRYPTED_COMMAND);
- encLine = await WaitForPrefixAsync("ENC:", 1000, ct);
- }
- catch
- {
- Log($"Зашифрованная строка не пришла");
return false;
}
-
- //Расшифровка
- var encrypted = encLine.Substring(4);
- var decrypted = BoardUtility.DecryptSerial(encrypted);
-
- //Отправляем расшифрованное на подтверждение
- try
+ catch (Exception exception)
{
- Send(REQUEST_CONFIRMATION_COMMAND + decrypted);
- await WaitForContainsAsync("OK", 1000, ct);
- return true;
- }
- catch
- {
- Log("Подтверждение не пришло");
+ Log($"Ошибка авторизации платы: {exception.Message}");
return false;
}
+ finally
+ {
+ authCts.Cancel();
+ }
}
public static void Shutdown()
{
+ if (h > 0)
+ Send(_cabinetLightingOffCommand);
+
_lifetimeCts?.Cancel();
_lifetimeCts?.Dispose();
_lifetimeCts = null;
@@ -404,6 +433,8 @@ public static class ComWrapper
StopReaderThread();
CancelAllWaiters();
ClosePort();
+ _isAuthorized = false;
+ _controllerPort = null;
}
private static void CancelAllWaiters()
@@ -431,7 +462,6 @@ public static class ComWrapper
}
- //Сделать ожидание простоя около минуты на проверку и переподелючение.
private static async Task StartKeepAliveProcessAsync(CancellationToken ct)
{
try
@@ -439,20 +469,32 @@ public static class ComWrapper
Log("Запуск асинхронной проверки порта");
while (!ct.IsCancellationRequested)
{
- if (!IsInputIdleTooLong())
+ if (!_isAuthorized)
{
- await Task.Delay(5000, ct);
+ Log("Нет авторизованного соединения. Запускаю переподключение.");
+ await ReinitializePortAsync(ct);
+ await Task.Delay(_reconnectRetryMs, ct);
+ continue;
+ }
+
+ if (!IsInputIdleTooLong())
+ {
+ await Task.Delay(_keepAlivePollMs, ct);
continue;
}
- //Пингуем плату
if (!await CheckPortConnectionAsync(ct))
{
Log("Плата отвалилась. Запускаю переподключение.");
+ _isAuthorized = false;
await ReinitializePortAsync(ct);
}
+ else
+ {
+ MarkInputActivity();
+ }
- await Task.Delay(10000, ct);
+ await Task.Delay(_keepAlivePollMs, ct);
}
}
catch (OperationCanceledException)
@@ -470,8 +512,9 @@ public static class ComWrapper
{
try
{
- Send(REQUEST_INPUT_COMMAND);
- await WaitForPrefixAsync("INPUT", 1000, ct);
+ Task inputResponseTask = WaitForPrefixAsync("INPUT", 1000, ct);
+ Send(_requestInputCommand);
+ await inputResponseTask;
return true;
}
catch
@@ -480,40 +523,57 @@ public static class ComWrapper
}
}
- //реализовать
- private static async Task ReinitializePortAsync(CancellationToken ct)
+ private static async Task ReinitializePortAsync(CancellationToken ct)
{
StopReaderThread();
CancelAllWaiters();
ClosePort();
- OpenPort(AvailablePorts[^1]);
+ if (string.IsNullOrWhiteSpace(_controllerPort))
+ {
+ Log("Не сохранён COM-порт контроллера.");
+ return false;
+ }
+
+ OpenPort(_controllerPort);
if (h <= 0)
{
- Log($"Ошибка при переподключении.");
- return;
+ Log("Ошибка при переподключении.");
+ return false;
}
-
StartReaderThread();
- var auth = await BoardAuthAsync(ct);
- if (!auth)
+ _isAuthorized = await BoardAuthAsync(ct);
+ if (!_isAuthorized)
{
- Application.Quit();
- Debug.Log("Не пройдена авторизация на плате. Выход.");
+ Log("Повторная авторизация платы не пройдена.");
+ return false;
}
+
+ HandleAuthorizationSucceeded();
+ Log($"Соединение с {_controllerPort} восстановлено и авторизовано.");
+ return true;
}
static bool IsInputIdleTooLong()
{
int last = Volatile.Read(ref _lastInputTick);
- if (last == 0) return true;
+ if (last == 0) return false;
int now = Environment.TickCount;
// переполнение TickCount тут ок: сравнение интервала < 24 дней работает корректно
int elapsed = unchecked(now - last);
- return elapsed >= INPUT_IDLE_BEFORE_PING_MS;
+ return elapsed >= _inputIdleBeforePingMs;
+ }
+
+ private static void MarkInputActivity()
+ => Volatile.Write(ref _lastInputTick, Environment.TickCount);
+
+ private static void HandleAuthorizationSucceeded()
+ {
+ MarkInputActivity();
+ Send(_cabinetLightingOnCommand);
}
private static void Log(string message) => logger?.Log($"CW{message}");