using System; using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Events; using Inputs.ComSystem; /// /// Основной класс-обертка для работы с COM-портом и периферийной платой. /// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов. /// public class ComWrapper : MonoBehaviour { //Настраиваем из инспектора [SerializeField] bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true [SerializeField] bool keepAlive = true; // Периодическая проверка соединения с платами [SerializeField] bool requireHandshake = true; // Двойное рукопожатие AES // Монеты и кредиты — см. CreditSystem.cs (работает в любом режиме ввода) public static UnityEvent onInputRecieved; public static System.Action OnInitialize; // Событие, вызываемое непосредственно перед закрытием портов — слушатели могут послать команды выключения public static System.Action OnBeforeClose; // Очередь исходящих сообщений — все отправки пойдут через неё и будут обработаны в Update() или при завершении private struct OutgoingMessage { public int playerId; public string message; } private static readonly System.Collections.Concurrent.ConcurrentQueue outgoingQueue = new System.Collections.Concurrent.ConcurrentQueue(); #region Приватные поля private const string REQUEST_INPUT = "91"; private const string LIGHTSIGNAL_PREFIX = "80"; private static int handle1; //идентификатор хэндла платы 1 private static int handle2; //идентификатор хэндла платы 2 private static int handle3; //идентификатор хэндла платы 3 (опциональная витрина) // Роль-ориентированные хэндлы (заполняются в AssignRoles после инициализации портов) private static int handleLeft = 0; // левая игровая плата (player 1) private static int handleRight = 0; // правая игровая плата (player 2) private static int handleVitrine = 0; // витрина / showcase (опционально) // Константы ролей — должны совпадать с тем, что плата возвращает на команду 97 // Ответ платы: "ROLE : <название>" (до 20 символов, регистр игнорируется) public const string ROLE_LEFT = "left"; public const string ROLE_RIGHT = "right"; public const string ROLE_VITRINE = "vitrina"; private const string CMD_GET_ROLE = "97"; // команда запроса роли платы // Асинхронная работа private CancellationTokenSource pollCancellationTokenSource; private int inputToggle = 0; // Флаг для проверки получения INPUT от плат // Флаг для безопасного запроса переподключения, выставляется из фонового потока и обрабатывается в Update() на главном потоке private volatile bool needReinit = false; // Очередь для обработки COIN в главном потоке private static readonly System.Collections.Concurrent.ConcurrentQueue coinQueue = new System.Collections.Concurrent.ConcurrentQueue(); // Защита от спама COIN: отслеживание времени последней монеты для каждой платы private static long lastCoinTimePlate1 = 0; // в миллисекундах private static long lastCoinTimePlate2 = 0; // в миллисекундах private const long COIN_DEBOUNCE_MS = 250; // Минимальный интервал между монетами (250ms, больше чем 200ms спам от платы) private static byte[] buf = new byte[1024]; //Байт-буфер куда будут приходить данные с порта // StringBuilder для эффективного формирования строки INPUT без лишних аллокаций private static System.Text.StringBuilder inputBuilder = new System.Text.StringBuilder(32); // Кэш последних состояний INPUT для каждой платы (26 бит от каждой платы) // ВАЖНО: 0 = кнопка нажата, 1 = кнопка отпущена, поэтому по умолчанию все единицы // Каждая плата присылает 26 бит, из них используются только 10: // - первые 8 бит = кнопки (биты 0-7) // - последние 2 бита = хопер (биты 24-25) // Итоговая строка: 8 кнопок платы1 + 8 кнопок платы2 + 2 хопера платы1 + 2 хопера платы2 = 20 бит private static string lastInputBits1 = "11111111111111111111111111"; // последние 26 бит от платы 1 (все отпущены) private static string lastInputBits2 = "11111111111111111111111111"; // последние 26 бит от платы 2 (все отпущены) #endregion //===========================start: PUBLIC API===========================// /// /// Инициализирует систему работы с COM-портом. /// Открывает соединение, настраивает события и запускает асинхронные запросы состояния. /// public void Initialize() { //Создаем ивенты onInputRecieved = new(); initPort(); //открываем порты // Запускаем асинхронный опрос портов pollCancellationTokenSource = new CancellationTokenSource(); StartAsyncPolling(); // Запускаем keep-alive механизм если включен if (keepAlive || enablePrizeChecking) { StartKeepAliveProcess(); } OnInitialize?.Invoke(); } /// /// Добавляет один кредит в систему и сохраняет обновленное количество. /// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта. /// /// /// Вся логика кредитов перенесена в CreditSystem.cs. /// Оставлено только для совместимости — удалено. /// /// /// Отправляет произвольную строковую команду на периферийную плату через COM-порт. /// public static void Send(string message, int playerId) { // Публикуем в очередь — реальные записи будут выполнены в Update() (на главном потоке) if (playerId != 1 && playerId != 2) { Debug.LogWarning("ComWrapper.Send: неверный playerId"); return; } outgoingQueue.Enqueue(new OutgoingMessage { playerId = playerId, message = message }); } /// /// Отправляет строковую команду на все подключенные платы (broadcast). /// public static void Send(string message) { outgoingQueue.Enqueue(new OutgoingMessage { playerId = 1, message = message }); outgoingQueue.Enqueue(new OutgoingMessage { playerId = 2, message = message }); } /// /// Возвращает true, если витрина (showcase-плата) была обнаружена и подключена. /// public static bool HasVitrine => handleVitrine > 0; /// /// Отправляет команду на витрину (showcase-плату). /// Если витрина не обнаружена — выводит предупреждение и ничего не делает. /// public static void SendToVitrine(string message) { if (handleVitrine > 0) outgoingQueue.Enqueue(new OutgoingMessage { playerId = 3, message = message }); else Debug.LogWarning("ComWrapper.SendToVitrine: витрина не подключена или роль не определена."); } /// /// Возвращает хэндл платы по её строковой роли (используй ROLE_LEFT / ROLE_RIGHT / ROLE_VITRINE). /// Возвращает 0, если плата с такой ролью не найдена. /// public static int GetHandleByRole(string role) { return role switch { ROLE_LEFT => handleLeft, ROLE_RIGHT => handleRight, ROLE_VITRINE => handleVitrine, _ => 0 }; } //===========================end: PUBLIC API===========================// private void Update() { // Выполняем переподключение на главном потоке, если было запрошено из фонового if (needReinit) { needReinit = false; Debug.LogWarning("ComWrapper: выполняю переподключение портов на главном потоке..."); ClosePort(); initPort(); } // Обрабатываем COIN из очереди — делегируем в CreditSystem int coinsPlayer1 = 0; int coinsPlayer2 = 0; while (coinQueue.TryDequeue(out int playerId)) { CreditSystem.AddImpulse(playerId); if (playerId == 1) coinsPlayer1++; else coinsPlayer2++; } if (coinsPlayer1 > 0 || coinsPlayer2 > 0) { Debug.Log($"✓ Обработано монет: Игрок1={coinsPlayer1}, Игрок2={coinsPlayer2}, Итого={coinsPlayer1 + coinsPlayer2}"); } // Отправляем накопленные исходящие сообщения (выходящие команды на платы) ProcessOutgoingQueueOnce(); } // Отправляет все сообщения, которые есть в очереди в данный момент (синхронно, на главном потоке) private void ProcessOutgoingQueueOnce() { while (outgoingQueue.TryDequeue(out OutgoingMessage msg)) { try { // Используем роль-алиасы; если ещё не назначены — fallback на raw handle1/handle2 int targetHandle = msg.playerId switch { 1 => handleLeft > 0 ? handleLeft : handle1, // левая / player1 2 => handleRight > 0 ? handleRight : handle2, // правая / player2 3 => handleVitrine, // витрина (только через SendToVitrine) _ => 0 }; if (targetHandle > 0) ComNativeWrapper.Serial_WriteStringUTF8(targetHandle, msg.message, 1); } catch (Exception ex) { Debug.LogWarning($"ComWrapper: ошибка отправки исходящего сообщения: {ex.Message}"); } } } private void OnDisable() { try { OnBeforeClose?.Invoke(); } catch { } // Даем шанс отправить накопленные команды (например, TurnAllOff). Ждем короткий таймаут. try { var sw = System.Diagnostics.Stopwatch.StartNew(); const int maxWaitMs = 200; // ждем до 200 ms while (!outgoingQueue.IsEmpty && sw.ElapsedMilliseconds < maxWaitMs) { // Обрабатываем очередь синхронно ProcessOutgoingQueueOnce(); System.Threading.Thread.Sleep(10); } } catch (Exception) { } StopAsyncPolling(); ClosePort(); } private void OnApplicationQuit() { try { OnBeforeClose?.Invoke(); } catch { } try { var sw = System.Diagnostics.Stopwatch.StartNew(); const int maxWaitMs = 200; while (!outgoingQueue.IsEmpty && sw.ElapsedMilliseconds < maxWaitMs) { ProcessOutgoingQueueOnce(); System.Threading.Thread.Sleep(10); } } catch (Exception) { } StopAsyncPolling(); ClosePort(); } private void StopAsyncPolling() { // Только отменяем токен; не вызываем Dispose здесь — StartAsyncPolling() выполнит очистку. try { pollCancellationTokenSource?.Cancel(); } catch (Exception ex) { Debug.LogWarning($"StopAsyncPolling: ошибка при Cancel: {ex.Message}"); } } private async void StartAsyncPolling() { Debug.Log("Запуск асинхронного опроса портов"); // Локальная ссылка на CTS var cts = pollCancellationTokenSource; try { while (true) { // Всегда берём свежую ссылку в начале итерации cts = pollCancellationTokenSource; if (cts == null) break; // Безопасно получаем токен: доступ к cts.Token может выбросить ObjectDisposedException CancellationToken token; try { token = cts.Token; } catch (ObjectDisposedException) { Debug.LogWarning("StartAsyncPolling: CTS disposed при попытке получить Token — выходим из цикла."); break; } if (token.IsCancellationRequested) break; try { // Используем локальный токен для задержек и чтения await Task.Delay(10, token); // Опрос каждые 10ms string comInput = await ReadBufferLineAsync(token); if (!string.IsNullOrEmpty(comInput)) { try { ProcessComInputImmediate(comInput); } catch (Exception ex) { Debug.LogError($"Ошибка обработки COM-входа: {ex}\nInput=[{comInput}]"); } } } catch (OperationCanceledException) { // токен отменён — корректный выход из цикла break; } catch (ObjectDisposedException odex) { Debug.LogWarning($"StartAsyncPolling: CancellationTokenSource был disposed во время ожидания: {odex.Message}"); break; } catch (Exception ex) { // Если источник был disposed — не считать это критической ошибкой if (ex is ObjectDisposedException) { Debug.LogWarning($"StartAsyncPolling: ObjectDisposedException (обработано): {ex.Message}"); break; } Debug.LogError($"Внутренняя ошибка опроса порта: {ex}"); } } } finally { // Убедимся, что мы корректно освободим CTS и обнулим поле только если оно всё ещё ссылается на тот объект try { var tmp = pollCancellationTokenSource; if (tmp != null) { try { tmp.Cancel(); } catch { } try { tmp.Dispose(); } catch { } } } finally { pollCancellationTokenSource = null; Debug.Log("StartAsyncPolling: завершён и pollCancellationTokenSource очищен"); } } } private void ProcessComInputImmediate(string comInput) { if (string.IsNullOrEmpty(comInput)) return; Debug.Log(comInput); // INPUT обработка if (comInput.StartsWith("INPUT")) { System.Threading.Interlocked.Exchange(ref inputToggle, 1); // потокобезопасно выставляем флаг для keep-alive if (comInput.Length > 5) onInputRecieved?.Invoke(comInput.Substring(5)); else onInputRecieved?.Invoke(string.Empty); } } private void ToggleInputReceived(int value) => inputToggle = value; private int GetInputToggle() => inputToggle; #region Приватные методы //Ну тут по дебаг логам все должно быть понятно //Если интересно, то h - это хэндл. Что то на плюсовом, типа ID соединения или чтото. Если 0 то нету соед. если меньше то ошибка, больше 0 - норм. private void initPort() { Debug.Log("Старт программы"); ComNativeWrapper.Serial_CloseAll(); var ports = ComNativeWrapper.ListPorts(); Debug.Log("Ports: " + string.Join(", ", ports)); if (ports.Length == 0) { Debug.Log("Нет портов, завершаю"); return; } // НОВАЯ ПРОВЕРКА: нужно минимум 2 порта if (ports.Length < 2) { Debug.Log($"ОШИБКА: Найден только {ports.Length} порт, а нужно минимум 2 для работы с двумя платами!"); return; } string port1 = ports[^1]; string port2 = ports[^2]; Debug.Log("Открываю первый с конца порт: " + port1); Debug.Log("Открываю второй с конца порт: " + port2); //вот эти настройки лучше не ковырять, но если очень хочется можно поменять readTimeoutMs и writeTimeoutMs //Если read поставить слишком маленьким оно проебует кредиты когда они идут большой пачкой например при оплате через вендисту. //Там в порт спамит "COIN" в больших количествах и процентов 10 из этих строк не успевают прочитаться при таймауте 1мс, соотв. монеты проебуются. //Слишком большой таймаут будет тормозить игру потому что оно все выполняется синхронно и каждый кадр забирает столько мс сколько в переменной указано. handle1 = ComNativeWrapper.Serial_Open(port1, 115200, 8, 0, 1, 5, 10); handle2 = ComNativeWrapper.Serial_Open(port2, 115200, 8, 0, 1, 5, 10); Debug.Log($"handle: {handle1}"); if (handle1 <= 0) { Debug.Log("Open failed: " + ComNativeWrapper.GetLastError()); return; } Debug.Log($"handle: {handle2}"); if (handle2 <= 0) { Debug.Log("Open failed: " + ComNativeWrapper.GetLastError()); return; } // Опционально открываем 3-й порт (возможная витрина) handle3 = 0; if (ports.Length >= 3) { string port3 = ports[^3]; Debug.Log($"Найден 3-й порт, открываю (возможная витрина): {port3}"); handle3 = ComNativeWrapper.Serial_Open(port3, 115200, 8, 0, 1, 5, 10); if (handle3 <= 0) { Debug.LogWarning($"Не удалось открыть порт {port3} (handle3={handle3}), витрина недоступна."); handle3 = 0; } else { Debug.Log($"handle3 (возможная витрина): {handle3}"); } } // ВАЖНО: Очищаем буферы портов от старых данных Debug.Log("Очистка буферов портов от старых данных..."); FlushPortBuffers(); // Инициализируем lastCoinTime текущим временем чтобы не обработать старые COIN long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); lastCoinTimePlate1 = currentTime; lastCoinTimePlate2 = currentTime; Debug.Log($"Порты инициализированы, debounce активирован с текущего времени: {currentTime}"); // Выполняем handshake если требуется if (requireHandshake) { PerformHandshakeForBothBoards(port1, port2); } // Определяем роли плат через команду 97 (левая / правая / витрина) AssignRoles(); } /// /// Очищает буферы обоих портов от накопившихся старых данных /// private static void FlushPortBuffers() { int flushedCount1 = 0; int flushedCount2 = 0; int flushedCount3 = 0; // Читаем все накопившиеся данные из порта 1 while (ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle1, buf, buf.Length) > 0) { flushedCount1++; if (flushedCount1 > 100) break; // Защита от бесконечного цикла } // Читаем все накопившиеся данные из порта 2 while (ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle2, buf, buf.Length) > 0) { flushedCount2++; if (flushedCount2 > 100) break; // Защита от бесконечного цикла } // Читаем накопившиеся данные из порта 3 (витрина), если открыт if (handle3 > 0) { while (ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle3, buf, buf.Length) > 0) { flushedCount3++; if (flushedCount3 > 100) break; } } Debug.Log($"Очищено: Плата1={flushedCount1}, Плата2={flushedCount2}, Плата3={flushedCount3} сообщений"); } private static void ClosePort() { if (handle1 > 0) { ComNativeWrapper.Serial_Close(handle1); handle1 = 0; } if (handle2 > 0) { ComNativeWrapper.Serial_Close(handle2); handle2 = 0; } if (handle3 > 0) { ComNativeWrapper.Serial_Close(handle3); handle3 = 0; } // Сбрасываем роль-ориентированные алиасы handleLeft = 0; handleRight = 0; handleVitrine = 0; } /// /// Конвертирует десятичное число в двоичную строку фиксированной длины (26 бит) /// private static string DecimalToBinary(string decimalString) { if (int.TryParse(decimalString, out int decimalValue)) { // Конвертируем в двоичную строку и дополняем нулями слева до 26 символов string binary = Convert.ToString(decimalValue, 2).PadLeft(26, '0'); // Переворачиваем строку (младшие биты слева, старшие справа) char[] chars = binary.ToCharArray(); Array.Reverse(chars); string reversed = new string(chars); return reversed; } // Если не удалось распарсить - возвращаем строку как есть (для обратной совместимости) Debug.LogWarning($"Не удалось преобразовать '{decimalString}' в число. Используем как есть."); return decimalString.PadLeft(26, '1'); // По умолчанию все кнопки отпущены } //Чтение с ДВУХ плат и объединение битов INPUT с использованием кэша //COIN обрабатывается сразу, чтобы не потерять монеты с обеих плат private static string ReadBufferLine() { try { string result1 = ""; string result2 = ""; bool hasNewInput1 = false; bool hasNewInput2 = false; // Используем роль-алиасы, если уже назначены (иначе — raw handle1/handle2) int hLeft = handleLeft > 0 ? handleLeft : handle1; int hRight = handleRight > 0 ? handleRight : handle2; // Читаем с левой платы (8 кнопок + 2 хопера) int read1 = ComNativeWrapper.Serial_ReadLineUTF8_bytes(hLeft, buf, buf.Length); if (read1 > 0) { result1 = Encoding.UTF8.GetString(buf, 0, read1); // Обрабатываем COIN сразу, но добавляем в очередь для обработки в главном потоке if (result1 == "COIN") { long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long timeSinceLastCoin = currentTime - lastCoinTimePlate1; // Проверяем debounce: если прошло меньше 250ms - это спам от той же монеты if (timeSinceLastCoin < COIN_DEBOUNCE_MS) { Debug.Log($"COM-ЛЕВАЯ: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено"); // НЕ отправляем подтверждение для спама! // НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти } else { // Это РЕАЛЬНАЯ новая монета - обрабатываем lastCoinTimePlate1 = currentTime; // ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет! int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(hLeft, "10", 1); Debug.Log($"COM-ЛЕВАЯ: COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})"); // Добавляем в очередь для обработки в главном потоке coinQueue.Enqueue(1); } } // Если пришёл INPUT - обновляем кэш else if (result1.StartsWith("INPUT")) { string rawData = result1.Substring(5); // Конвертируем десятичное число в двоичную строку lastInputBits1 = DecimalToBinary(rawData); hasNewInput1 = true; } } // Читаем с правой платы (8 кнопок + 2 хопера) int read2 = ComNativeWrapper.Serial_ReadLineUTF8_bytes(hRight, buf, buf.Length); if (read2 > 0) { result2 = Encoding.UTF8.GetString(buf, 0, read2); // Обрабатываем COIN сразу, но добавляем в очередь для обработки в главном потоке if (result2 == "COIN") { long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long timeSinceLastCoin = currentTime - lastCoinTimePlate2; // Проверяем debounce: если прошло меньше 250ms - это спам от той же монеты if (timeSinceLastCoin < COIN_DEBOUNCE_MS) { Debug.Log($"COM-ПРАВАЯ: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено"); // НЕ отправляем подтверждение для спама! // НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти } else { // Это РЕАЛЬНАЯ новая монета - обрабатываем lastCoinTimePlate2 = currentTime; // ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет! int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(hRight, "10", 1); Debug.Log($"COM-ПРАВАЯ: COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})"); // Добавляем в очередь для обработки в главном потоке coinQueue.Enqueue(2); } } // Если пришёл INPUT - обновляем кэш else if (result2.StartsWith("INPUT")) { string rawData = result2.Substring(5); // Конвертируем десятичное число в двоичную строку lastInputBits2 = DecimalToBinary(rawData); hasNewInput2 = true; } } // Если хотя бы с одной платы пришёл INPUT - объединяем с кэшем if (hasNewInput1 || hasNewInput2) { // Берём нужные биты из каждой платы (из 26 бит): // - от платы 1: первые 8 бит (кнопки 0-7) // - от платы 2: первые 8 бит (кнопки 8-15) // - от платы 1: последние 2 бита (хопер 16-17, биты 24-25) // - от платы 2: последние 2 бита (хопер 18-19, биты 24-25) // Итого: 20 бит (8+8+2+2) string buttons1 = lastInputBits1.Substring(0, 8); // биты 0-7: кнопки платы 1 string buttons2 = lastInputBits2.Substring(0, 8); // биты 8-15: кнопки платы 2 string hopper1 = lastInputBits1.Substring(24, 2); // биты 16-17: хопер платы 1 (последние 2 из 26) string hopper2 = lastInputBits2.Substring(24, 2); // биты 18-19: хопер платы 2 (последние 2 из 26) // Формируем строку эффективно через StringBuilder inputBuilder.Clear(); inputBuilder.Append("INPUT"); inputBuilder.Append(buttons1); inputBuilder.Append(buttons2); inputBuilder.Append(hopper1); inputBuilder.Append(hopper2); string combined = inputBuilder.ToString(); return combined; } } catch (Exception ex) { Debug.LogError($"Ошибка в ReadBufferLine: {ex}"); } return ""; } /// /// Асинхронное чтение с двух портов. Выполняется в фоновом потоке. /// private static async Task ReadBufferLineAsync(CancellationToken cancellationToken = default) { try { return await Task.Run(() => { if (cancellationToken.IsCancellationRequested) return string.Empty; return ReadBufferLine(); }, cancellationToken); } catch (OperationCanceledException) { return string.Empty; } catch (Exception e) { Debug.LogError($"Ошибка асинхронного чтения портов: {e.Message}"); return string.Empty; } } //просим прислать состояние цифровых входов. Приходит что то вроде "INPUT111111101" private void requestInputs() => Send(REQUEST_INPUT); /// /// Асинхронный keep-alive процесс для проверки соединения с платами /// private async void StartKeepAliveProcess() { Debug.Log("Запуск асинхронной проверки соединения"); var cts = pollCancellationTokenSource; try { while (true) { // Всегда берем свежую ссылку cts = pollCancellationTokenSource; if (cts == null) break; CancellationToken token; try { token = cts.Token; } catch (ObjectDisposedException) { Debug.LogWarning("StartKeepAliveProcess: CTS disposed — выхожу."); break; } if (token.IsCancellationRequested) break; try { await Task.Delay(2000, token); // Ждем 2 секунды // Запрашиваем INPUT и сбрасываем флаг ожидания requestInputs(); System.Threading.Interlocked.Exchange(ref inputToggle, 0); // Ждём ответа (1 сек) await Task.Delay(1000, token); // Проверяем, пришёл ли ответ (потокобезопасно) if (System.Threading.Volatile.Read(ref inputToggle) == 0) { Debug.LogWarning("ComWrapper: платы не отвечают — устанавливаю запрос на переподключение (будет выполнено в Update)."); needReinit = true; } await Task.Delay(7000, token); // Итого ~10 секунд между проверками } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { Debug.LogWarning("StartKeepAliveProcess: CancellationTokenSource disposed — выхожу."); break; } catch (Exception e) { Debug.LogError($"Ошибка проверки соединения: {e.Message}"); } } } finally { // ничего не диспозим здесь — StopAsyncPolling/StartAsyncPolling управляют жизненным циклом CTS } } /// /// Выполняет двойное рукопожатие для обеих плат через ComNativeWrapper /// private void PerformHandshakeForBothBoards(string port1Name, string port2Name) { Debug.Log("=== НАЧАЛО ДВОЙНОГО РУКОПОЖАТИЯ ==="); bool success1 = PerformHandshakeSingle(handle1, 1); bool success2 = PerformHandshakeSingle(handle2, 2); bool success3 = handle3 > 0 && PerformHandshakeSingle(handle3, 3); if (success1 && success2) { Debug.Log("=== ОБЕ ИГРОВЫЕ ПЛАТЫ АВТОРИЗОВАНЫ ==="); } else { Debug.LogError($"=== HANDSHAKE ПРОВАЛЕН: Плата1={success1}, Плата2={success2} ==="); } if (handle3 > 0) { if (success3) Debug.Log("=== ВИТРИНА АВТОРИЗОВАНА ==="); else Debug.LogWarning("=== HANDSHAKE ВИТРИНЫ ПРОВАЛЕН (продолжаем без витрины) ==="); } } /// /// Выполняет handshake для одной платы (07→CHAL→08→RAES) /// private bool PerformHandshakeSingle(int handle, int plateId) { if (handle <= 0) { Debug.LogError($"Handshake [{plateId}]: невалидный handle={handle}"); return false; } try { // 1. Генерируем случайный блок 16 байт string randomHex = AESCrypto.GenerateRandomBlock16Hex(); string cmd1 = "07" + randomHex + "\r\n"; Debug.Log($"Handshake [{plateId}]: → 07+random..."); ComNativeWrapper.Serial_WriteStringUTF8(handle, cmd1, 1); // 2. Ждем ответ CHAL string chalResponse = WaitForResponse(handle, "CHAL", 3000); if (string.IsNullOrEmpty(chalResponse) || chalResponse.Length < 36) { Debug.LogWarning($"Handshake [{plateId}]: нет ответа CHAL"); return false; } string serverChalHex = chalResponse.Substring(4, 32); Debug.Log($"Handshake [{plateId}]: ← CHAL={serverChalHex.Substring(0, 8)}..."); // 3. Шифруем и отправляем 08+encrypted string encryptedChal = AESCrypto.EncryptHexBlock16(serverChalHex); string cmd2 = "08" + encryptedChal + "\r\n"; Debug.Log($"Handshake [{plateId}]: → 08+encrypted..."); ComNativeWrapper.Serial_WriteStringUTF8(handle, cmd2, 1); // 4. Ждем ответ RAES string raesResponse = WaitForResponse(handle, "RAES", 3000); if (string.IsNullOrEmpty(raesResponse) || raesResponse.Length < 36) { Debug.LogWarning($"Handshake [{plateId}]: нет ответа RAES"); return false; } string raesHex = raesResponse.Substring(4, 32); Debug.Log($"Handshake [{plateId}]: ← RAES={raesHex.Substring(0, 8)}..."); // 5. Расшифровываем и проверяем string decryptedRaes = AESCrypto.DecryptHexBlock16(raesHex); if (string.Equals(decryptedRaes, randomHex, StringComparison.OrdinalIgnoreCase)) { Debug.Log($"Handshake [{plateId}]: ✓ УСПЕХ"); return true; } else { Debug.LogError($"Handshake [{plateId}]: ✗ Проверка провалена! Expected={randomHex.Substring(0, 8)}..., Got={decryptedRaes.Substring(0, 8)}..."); return false; } } catch (Exception ex) { Debug.LogError($"Handshake [{plateId}]: Exception: {ex.Message}"); return false; } } /// /// Ожидает ответ с определенным префиксом от платы /// private string WaitForResponse(int handle, string prefix, int timeoutMs) { float elapsed = 0; byte[] localBuf = new byte[1024]; while (elapsed < timeoutMs) { int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle, localBuf, localBuf.Length); if (read > 0) { string line = Encoding.UTF8.GetString(localBuf, 0, read).Trim(); if (line.StartsWith(prefix)) { return line; } } System.Threading.Thread.Sleep(10); elapsed += 10; } return null; } /// /// Запрашивает роль платы командой 97 и возвращает её (в верхнем регистре, обрезанную). /// Ответ платы ожидается в формате: "ROLE : ЛЕВАЯ" / "ROLE : ПРАВАЯ" / "ROLE : ВИТРИНА" /// private string RequestBoardRole(int handle) { if (handle <= 0) return string.Empty; try { // Отправляем команду запроса роли ComNativeWrapper.Serial_WriteStringUTF8(handle, CMD_GET_ROLE, 1); // Ждём ответ с префиксом "ROLE" до 2 секунд string response = WaitForResponse(handle, "ROLE", 2000); if (string.IsNullOrEmpty(response)) { Debug.LogWarning($"RequestBoardRole (handle={handle}): нет ответа на команду {CMD_GET_ROLE}"); return string.Empty; } // Парсим "ROLE : <название>" → ищем двоеточие int colonIdx = response.IndexOf(':'); if (colonIdx >= 0 && colonIdx < response.Length - 1) { string role = response.Substring(colonIdx + 1).Trim().ToUpper(); return role; } Debug.LogWarning($"RequestBoardRole (handle={handle}): неожиданный формат ответа: '{response}'"); return string.Empty; } catch (Exception ex) { Debug.LogError($"RequestBoardRole (handle={handle}): Exception: {ex.Message}"); return string.Empty; } } /// /// Определяет роли плат (ЛЕВАЯ / ПРАВАЯ / ВИТРИНА) через команду 97. /// Заполняет handleLeft, handleRight, handleVitrine. /// Если роль платы не определена — выводит предупреждение, fallback на порядок портов. /// private void AssignRoles() { Debug.Log("=== ОПРЕДЕЛЕНИЕ РОЛЕЙ ПЛАТ (команда 97) ==="); handleLeft = 0; handleRight = 0; handleVitrine = 0; // Перебираем все открытые хэндлы int[] allHandles = { handle1, handle2, handle3 }; foreach (int h in allHandles) { if (h <= 0) continue; string role = RequestBoardRole(h); Debug.Log($"Плата handle={h}: роль = '{role}'"); switch (role) { case ROLE_LEFT: handleLeft = h; Debug.Log($"→ handle={h} назначена как ЛЕВАЯ (player 1)"); break; case ROLE_RIGHT: handleRight = h; Debug.Log($"→ handle={h} назначена как ПРАВАЯ (player 2)"); break; case ROLE_VITRINE: handleVitrine = h; Debug.Log($"→ handle={h} назначена как ВИТРИНА (showcase)"); break; default: Debug.LogWarning($"Плата handle={h}: неизвестная или пустая роль '{role}' — будет использован fallback"); break; } } // Fallback: если игровые платы не ответили на запрос роли — используем порядок портов if (handleLeft == 0 && handle1 > 0) { handleLeft = handle1; Debug.LogWarning($"ЛЕВАЯ роль не определена — используем handle1={handle1} как fallback"); } if (handleRight == 0 && handle2 > 0) { handleRight = handle2; Debug.LogWarning($"ПРАВАЯ роль не определена — используем handle2={handle2} как fallback"); } Debug.Log($"=== РОЛИ: Левая={handleLeft}, Правая={handleRight}, Витрина={handleVitrine} ==="); } #endregion }