using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.Events; using Random = UnityEngine.Random; namespace DanroJump.Hardware.Com { /// /// Управляет COM-устройствами автомата: авторизацией плат, кредитами, вводом, подсветкой и призовой витриной. /// [DefaultExecutionOrder(-9000)] [DisallowMultipleComponent] [AddComponentMenu("Hardware/COM System")] public sealed class ComSystem : MonoBehaviour, IComSystem { private const string DefaultInputBits = "11111111111111111111111111"; private const string RequestInputCommand = "91"; private const string RequestStandStatusCommand = "25"; private const string OpenBoxCommandPrefix = "2501"; private const string ForceOpenBoxCommandPrefix = "2511"; private const string PrizeStandIdleLightingCommand = "8020"; private const string PrizeStandHighlightBoxCommandPrefix = "8021"; private const string BoardEffectsCommandPrefix = "86"; private const string BoardEffectsOffCommand = "85"; private static readonly string[] DefaultPrizeStandRoles = { "prize_stand", "prize_stand_controller", "vitrina" }; [Header("Lifecycle")] [SerializeField] private bool startOnEnable = true; [SerializeField] private bool dontDestroyOnLoad = true; [SerializeField] private bool stopPortsOnDisable; [SerializeField] private bool sendShutdownCommandsOnQuit = true; [Header("Port scan")] [SerializeField] private bool useAllDetectedPorts = true; [SerializeField] private string[] explicitPorts = Array.Empty(); [SerializeField] private string preferredMainPort = ""; [Header("Serial settings")] [SerializeField] private int baudRate = 115200; [SerializeField] private int dataBits = 8; [SerializeField] private SerialParity parity = SerialParity.None; [SerializeField] private SerialStopBits stopBits = SerialStopBits.One; [SerializeField] private int readTimeoutMs = 50; [SerializeField] private int writeTimeoutMs = 100; [Header("Authorization")] [SerializeField] private bool useEncryptedHandshake = true; [SerializeField] private int handshakeTimeoutMs = 5000; [SerializeField] private string roleRequestCommand = "97"; [SerializeField] private string roleResponsePrefix = "ROLE"; [SerializeField] private int roleRequestTimeoutMs = 1500; [SerializeField] private string mainControllerRole = "main_controller"; [Header("Keep alive")] [SerializeField] private bool keepAlive = true; [SerializeField] private float keepAliveIntervalSeconds = 8f; [SerializeField] private int missedInputResponsesBeforeReconnect = 3; [Header("Credits")] [SerializeField] private bool saveCreditsBetweenSessions; [SerializeField] private string creditsPlayerPrefsKey = "Com.Credits"; [Header("Prize showcase")] [SerializeField] private string[] prizeStandRoles = DefaultPrizeStandRoles; [SerializeField] [Min(0)] private int prizeStandBoxesCount = 24; [SerializeField] private float prizeStatusTimeoutSeconds = 3f; [SerializeField] private float prizeOpenTimeoutSeconds = 5f; [Header("Lighting")] [SerializeField] private string hatOnCommand = "2131"; [SerializeField] private string hatOffCommand = "2130"; [SerializeField] [Range(0.05f, 5f)] private float hatBlinkIntervalSeconds = 0.5f; [SerializeField] [Range(0f, 2f)] private float hatBlinkVarianceSeconds; [SerializeField] private LightMode defaultLightMode = LightMode.Rainbow; [SerializeField] private float defaultLightCycles = 1f; [Header("Events")] [SerializeField] private UnityEvent creditAdded = new(); [SerializeField] private UnityEvent creditRemoved = new(); [SerializeField] private UnityEvent creditsChanged = new(); [SerializeField] private UnityEvent mainInputReceived = new(); [SerializeField] private UnityEvent deviceAuthorized = new(); [SerializeField] private UnityEvent deviceDisconnected = new(); [SerializeField] private UnityEvent prizeBoxOpened = new(); [SerializeField] private UnityEvent hopperSensorTriggered = new(); private readonly Dictionary devices = new(StringComparer.OrdinalIgnoreCase); private readonly object devicesLock = new(); private readonly ConcurrentQueue mainThreadQueue = new(); private readonly ConcurrentQueue coinQueue = new(); private readonly ConcurrentQueue inputQueue = new(); private readonly ConcurrentQueue prizeStatusQueue = new(); private readonly SemaphoreSlim prizeOperationLock = new(1, 1); private IComPortProvider portProvider; private IComTransportFactory transportFactory; private CancellationTokenSource systemCts; private CancellationTokenSource hatBlinkCancellation; private CancellationTokenSource lightSequenceCancellation; private LightSequenceState pausedLightSequence; private bool hatIsOn; private bool hatBlinkWasPaused; private string mainDeviceId = ""; private string prizeStandDeviceId = ""; private int credits; /// /// Последний активный экземпляр COM-системы для сцен, где DI еще не подключен. /// public static ComSystem Instance { get; private set; } /// /// Событие начисления кредитов. /// public UnityEvent CreditAdded => creditAdded; /// /// Событие списания кредитов. /// public UnityEvent CreditRemoved => creditRemoved; /// /// Событие любого изменения баланса кредитов. /// public UnityEvent CreditsChanged => creditsChanged; /// /// Событие получения входных битов от основной платы. /// public UnityEvent MainInputReceived => mainInputReceived; /// /// Событие успешной авторизации COM-устройства. /// public UnityEvent DeviceAuthorized => deviceAuthorized; /// /// Событие отключения COM-устройства. /// public UnityEvent DeviceDisconnected => deviceDisconnected; /// /// Событие подтвержденного открытия призовой ячейки. /// public UnityEvent PrizeBoxOpened => prizeBoxOpened; /// /// Событие срабатывания датчика хоппера на основной плате. /// public UnityEvent HopperSensorTriggered => hopperSensorTriggered; /// /// Показывает, запущена ли COM-подсистема. /// public bool IsRunning => systemCts != null && !systemCts.IsCancellationRequested; /// /// Текущий баланс аппаратных кредитов. /// public int Credits => credits; /// /// Идентификатор устройства, выбранного как основная плата. /// public string MainDeviceId => CurrentMainDeviceId; /// /// Идентификатор устройства, выбранного как контроллер призовой витрины. /// public string PrizeStandDeviceId => CurrentPrizeStandDeviceId; /// /// Последние входные биты от основной платы. /// public string LastMainInputBits => GetLastInputBits(CurrentMainDeviceId); public IReadOnlyCollection ConnectedDeviceIds { get { lock (devicesLock) { return new List(devices.Keys); } } } /// /// Инициализирует singleton-ссылку, инфраструктуру транспорта и сохраненный баланс кредитов. /// private void Awake() { if (Instance != null && Instance != this) { UnityEngine.Debug.LogWarning("[ComSystem] Another instance already exists. Destroying duplicate scene instance.", this); Destroy(gameObject); return; } Instance = this; portProvider ??= new ReflectionComPortProvider(); transportFactory ??= new ReflectionSerialComTransportFactory(); if (dontDestroyOnLoad) { DontDestroyOnLoad(gameObject); } credits = saveCreditsBetweenSessions ? PlayerPrefs.GetInt(creditsPlayerPrefsKey, 0) : 0; if (!saveCreditsBetweenSessions) { PlayerPrefs.SetInt(creditsPlayerPrefsKey, 0); } } /// /// Подменяет поставщика портов и фабрику транспорта до запуска системы. /// public void ConfigureInfrastructure(IComPortProvider customPortProvider, IComTransportFactory customTransportFactory) { if (IsRunning) { UnityEngine.Debug.LogWarning("[ComSystem] Infrastructure cannot be changed while the COM system is running.", this); return; } portProvider = customPortProvider ?? new ReflectionComPortProvider(); transportFactory = customTransportFactory ?? new ReflectionSerialComTransportFactory(); } /// /// Автоматически запускает COM-подсистему при включении объекта, если это разрешено в инспекторе. /// private void OnEnable() { if (startOnEnable) { StartSystem(); } } /// /// Переносит события из фоновых потоков COM-подключений в главный поток Unity. /// private void Update() { while (mainThreadQueue.TryDequeue(out Action action)) { action?.Invoke(); } while (coinQueue.TryDequeue(out _)) { AddCredit(1); } while (inputQueue.TryDequeue(out ComInputMessage message)) { if (message.DeviceId == CurrentMainDeviceId) { mainInputReceived.Invoke(message.InputBits); } } } /// /// Останавливает порты при выключении компонента, если включен соответствующий режим. /// private void OnDisable() { if (stopPortsOnDisable) { StopSystem(false); } } /// /// Закрывает устройства при выходе из приложения и отправляет shutdown-команды. /// private void OnApplicationQuit() { StopSystem(sendShutdownCommandsOnQuit); } /// /// Освобождает singleton-ссылку, фоновые подключения и semaphore призовых операций. /// private void OnDestroy() { if (Instance == this) { Instance = null; } StopSystem(false); prizeOperationLock.Dispose(); } /// /// Запускает сканирование портов и авторизацию найденных устройств. /// public void StartSystem() { if (IsRunning) { return; } systemCts = new CancellationTokenSource(); ResetDeviceSelections(); ClearQueues(); string[] ports = SelectPorts(portProvider.GetPortNames()); if (ports.Length == 0) { UnityEngine.Debug.LogWarning("[ComSystem] COM ports were not found."); ResetSystemToken(); return; } int startedDevices = 0; foreach (string portName in ports) { if (TryStartDevice(portName)) { startedDevices++; } } if (startedDevices == 0) { UnityEngine.Debug.LogWarning("[ComSystem] COM system did not start because all selected ports failed to open."); ResetSystemToken(); } } /// /// Останавливает все фоновые COM-операции и закрывает открытые порты. /// public void StopSystem(bool sendShutdownCommands) { if (systemCts == null) { return; } try { systemCts.Cancel(); } catch { // ignored } ComDeviceConnection[] deviceSnapshot; lock (devicesLock) { deviceSnapshot = CopyDeviceValues(); devices.Clear(); } foreach (ComDeviceConnection device in deviceSnapshot) { Unsubscribe(device); device.Dispose(sendShutdownCommands); } systemCts.Dispose(); systemCts = null; ResetDeviceSelections(); StopBoardLightSequence(); StopHatBlink(false); ClearQueues(); } /// /// Отправляет команду на основную плату управления. /// public bool SendToMain(string command) { return SendToDevice(CurrentMainDeviceId, command); } /// /// Отправляет команду на контроллер призовой витрины. /// public bool SendToPrizeStand(string command) { return SendToDevice(CurrentPrizeStandDeviceId, command); } /// /// Отправляет команду конкретному авторизованному устройству. /// public bool SendToDevice(string deviceId, string command) { if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(command)) { return false; } lock (devicesLock) { return devices.TryGetValue(deviceId, out ComDeviceConnection device) && device.Send(command); } } /// /// Рассылает команду всем подключенным устройствам. /// public void SendToAll(string command) { if (string.IsNullOrWhiteSpace(command)) { return; } ComDeviceConnection[] deviceSnapshot; lock (devicesLock) { deviceSnapshot = CopyDeviceValues(); } foreach (ComDeviceConnection device in deviceSnapshot) { device.Send(command); } } /// /// Возвращает последние входные биты устройства или безопасное значение по умолчанию. /// public string GetLastInputBits(string deviceId) { lock (devicesLock) { return !string.IsNullOrWhiteSpace(deviceId) && devices.TryGetValue(deviceId, out ComDeviceConnection device) ? device.LastInputBits : DefaultInputBits; } } /// /// Возвращает роль авторизованного устройства. /// public string GetDeviceRole(string deviceId) { lock (devicesLock) { return !string.IsNullOrWhiteSpace(deviceId) && devices.TryGetValue(deviceId, out ComDeviceConnection device) ? device.Role : ""; } } /// /// Возвращает MAC-адрес авторизованного устройства. /// public string GetDeviceMac(string deviceId) { lock (devicesLock) { return !string.IsNullOrWhiteSpace(deviceId) && devices.TryGetValue(deviceId, out ComDeviceConnection device) ? device.MacAddress : ""; } } /// /// Увеличивает баланс кредитов, пришедших от аппаратной периферии. /// public void AddCredit(int amount = 1) { if (amount <= 0) { return; } credits += amount; SaveCredits(); creditAdded.Invoke(credits); creditsChanged.Invoke(credits); } /// /// Пытается списать кредиты, не позволяя балансу уйти ниже нуля. /// public bool TrySpendCredits(int amount = 1) { if (amount <= 0) { return true; } if (credits < amount) { return false; } credits -= amount; SaveCredits(); creditRemoved.Invoke(credits); creditsChanged.Invoke(credits); return true; } /// /// Принудительно задает баланс кредитов и рассылает события изменения. /// public void SetCredits(int value) { int oldCredits = credits; credits = Mathf.Max(0, value); SaveCredits(); if (credits > oldCredits) { creditAdded.Invoke(credits); } else if (credits < oldCredits) { creditRemoved.Invoke(credits); } creditsChanged.Invoke(credits); } /// /// Асинхронно запрашивает статусы ячеек призовой витрины. /// public async Task> GetPrizeBoxStatusesAsync(CancellationToken cancellationToken = default) { if (prizeStandBoxesCount <= 0 || string.IsNullOrWhiteSpace(CurrentPrizeStandDeviceId)) { return new Dictionary(); } ClearPrizeStatusQueue(); if (!SendToPrizeStand(RequestStandStatusCommand)) { return new Dictionary(); } Dictionary statuses = new(); Stopwatch stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed.TotalSeconds < prizeStatusTimeoutSeconds && statuses.Count < prizeStandBoxesCount) { cancellationToken.ThrowIfCancellationRequested(); while (prizeStatusQueue.TryDequeue(out PrizeBoxStatusEntry entry)) { statuses[entry.BoxNumber] = entry.Status; if (statuses.Count >= prizeStandBoxesCount) { break; } } await Task.Delay(10, cancellationToken); } return statuses; } /// /// Возвращает номера ячеек, из которых можно безопасно выдать приз. /// public async Task GetReadyPrizeBoxesAsync(CancellationToken cancellationToken = default) { IReadOnlyDictionary statuses = await GetPrizeBoxStatusesAsync(cancellationToken); List readyBoxes = new(); foreach (var pair in statuses) { if (IsReadyToOpen(pair.Value)) { readyBoxes.Add(pair.Key); } } readyBoxes.Sort(); return readyBoxes.ToArray(); } /// /// Открывает призовую ячейку только если контроллер сообщает, что она готова. /// public async Task OpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default) { return await OpenPrizeBoxInternalAsync(boxNumber, false, cancellationToken); } /// /// Открывает призовую ячейку без предварительной проверки статуса. /// public async Task ForceOpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default) { return await OpenPrizeBoxInternalAsync(boxNumber, true, cancellationToken); } /// /// Переводит подсветку призовой витрины в режим ожидания. /// public bool SetPrizeStandIdleLighting() { return SendToPrizeStand(PrizeStandIdleLightingCommand); } /// /// Подсвечивает конкретную призовую ячейку на витрине. /// public bool HighlightPrizeBox(int boxNumber) { return boxNumber > 0 && SendToPrizeStand($"{PrizeStandHighlightBoxCommandPrefix}{boxNumber:D2}"); } /// /// Запускает один световой эффект на основной плате. /// public void PlayBoardLight(LightMode mode, float cycles = -1f) { StopBoardLightSequence(); float resolvedCycles = cycles > 0f ? cycles : defaultLightCycles; StartBoardLightSequenceTask(new[] { new LightStep(mode, resolvedCycles) }, false); } /// /// Запускает последовательность световых эффектов на основной плате. /// public void PlayBoardLightSequence(IReadOnlyList steps, bool loop) { if (steps == null || steps.Count == 0) { return; } StopBoardLightSequence(); StartBoardLightSequenceTask(steps, loop); } /// /// Запускает световой эффект платы, выбранный как эффект по умолчанию. /// public void PlayDefaultBoardLight() { PlayBoardLight(defaultLightMode, defaultLightCycles); } /// /// Останавливает текущую последовательность световых эффектов платы. /// public void StopBoardLightSequence() { CancelAndDispose(ref lightSequenceCancellation); pausedLightSequence = default; } /// /// Останавливает все эффекты платы и отправляет аппаратную команду выключения. /// public void StopAllBoardEffects() { StopBoardLightSequence(); SendToMain(BoardEffectsOffCommand); } /// /// Запускает мигание шляпы с интервалом из инспектора. /// public void BlinkHat() { BlinkHat(hatBlinkIntervalSeconds); } /// /// Запускает мигание шляпы с указанным интервалом. /// public void BlinkHat(float intervalSeconds) { StopHatBlink(false); hatIsOn = true; hatBlinkCancellation = new CancellationTokenSource(); HatBlinkAsync(Mathf.Max(0.05f, intervalSeconds), hatBlinkCancellation).Forget(); } /// /// Останавливает мигание шляпы и оставляет ее включенной или выключенной. /// public void StopHatBlink(bool leaveHatEnabled = true) { CancelAndDispose(ref hatBlinkCancellation); hatIsOn = leaveHatEnabled; SendToMain(leaveHatEnabled ? hatOnCommand : hatOffCommand); } /// /// Приостанавливает активные световые сценарии без сброса игрового состояния. /// public void PauseLighting() { if (hatBlinkCancellation != null) { hatBlinkWasPaused = true; CancelAndDispose(ref hatBlinkCancellation); } else { hatBlinkWasPaused = false; } if (lightSequenceCancellation != null) { CancelAndDispose(ref lightSequenceCancellation); pausedLightSequence = new LightSequenceState(true); } } /// /// Возобновляет световые сценарии после паузы. /// public void ResumeLighting() { if (hatBlinkWasPaused) { hatBlinkWasPaused = false; BlinkHat(hatBlinkIntervalSeconds); } else if (hatIsOn) { SendToMain(hatOnCommand); } if (pausedLightSequence.HasPausedSequence) { PlayDefaultBoardLight(); } } /// /// Общий сценарий выдачи приза с сериализацией операций через semaphore. /// private async Task OpenPrizeBoxInternalAsync( int boxNumber, bool force, CancellationToken cancellationToken) { if (!await prizeOperationLock.WaitAsync(0, cancellationToken)) { return PrizeOpenResult.Fail(boxNumber, "Выдача СѓР¶Рµ выполняется."); } try { if (boxNumber <= 0) { return PrizeOpenResult.Fail(boxNumber, "Номер ячейки должен быть больше 0."); } if (prizeStandBoxesCount > 0 && boxNumber > prizeStandBoxesCount) { return PrizeOpenResult.Fail(boxNumber, $"Номер ячейки должен быть РѕС‚ 1 РґРѕ {prizeStandBoxesCount}."); } if (string.IsNullOrWhiteSpace(CurrentPrizeStandDeviceId)) { return PrizeOpenResult.Fail(boxNumber, "Призовая витрина РЅРµ подключена."); } if (!force) { PrizeBoxStatus status = await ReadPrizeBoxStatusAsync(boxNumber, prizeStatusTimeoutSeconds, cancellationToken); if (!IsReadyToOpen(status)) { return PrizeOpenResult.Fail(boxNumber, $"Ячейка {boxNumber} РЅРµ готова Рє выдаче. Статус: {status}."); } } ClearPrizeStatusQueue(); string commandPrefix = force ? ForceOpenBoxCommandPrefix : OpenBoxCommandPrefix; if (!SendToPrizeStand($"{commandPrefix}{boxNumber:D2}")) { return PrizeOpenResult.Fail(boxNumber, "РќРµ удалось отправить команду открытия."); } PrizeBoxStatus openedStatus = await WaitPrizeBoxStatusAsync( boxNumber, PrizeBoxStatus.Opened, prizeOpenTimeoutSeconds, cancellationToken); if (openedStatus == PrizeBoxStatus.Opened) { mainThreadQueue.Enqueue(() => prizeBoxOpened.Invoke(boxNumber)); return PrizeOpenResult.Success(boxNumber); } return PrizeOpenResult.Fail(boxNumber, $"Плата РЅРµ подтвердила открытие. Последний статус: {openedStatus}."); } finally { prizeOperationLock.Release(); } } /// /// Запрашивает статусы витрины и возвращает статус одной ячейки. /// private async Task ReadPrizeBoxStatusAsync( int boxNumber, float timeoutSeconds, CancellationToken cancellationToken) { ClearPrizeStatusQueue(); if (!SendToPrizeStand(RequestStandStatusCommand)) { return PrizeBoxStatus.NotFound; } return await WaitPrizeBoxStatusAsync(boxNumber, null, timeoutSeconds, cancellationToken); } /// /// Ожидает статус нужной ячейки до таймаута, опционально до конкретного ожидаемого состояния. /// private async Task WaitPrizeBoxStatusAsync( int boxNumber, PrizeBoxStatus? expectedStatus, float timeoutSeconds, CancellationToken cancellationToken) { PrizeBoxStatus lastStatus = PrizeBoxStatus.NotFound; Stopwatch stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed.TotalSeconds < timeoutSeconds) { cancellationToken.ThrowIfCancellationRequested(); while (prizeStatusQueue.TryDequeue(out PrizeBoxStatusEntry entry)) { if (entry.BoxNumber != boxNumber) { continue; } lastStatus = entry.Status; if (!expectedStatus.HasValue || entry.Status == expectedStatus.Value) { return entry.Status; } } await Task.Delay(10, cancellationToken); } return lastStatus; } /// /// Создает подключение к одному порту и добавляет устройство после успешного старта. /// private bool TryStartDevice(string portName) { var device = new ComDeviceConnection( transportFactory, portName, baudRate, dataBits, parity, stopBits, readTimeoutMs, writeTimeoutMs, useEncryptedHandshake, handshakeTimeoutMs, keepAlive, keepAliveIntervalSeconds, missedInputResponsesBeforeReconnect, roleRequestCommand, roleResponsePrefix, roleRequestTimeoutMs); Subscribe(device); if (!device.Start(systemCts.Token)) { Unsubscribe(device); device.Dispose(false); return false; } lock (devicesLock) { devices[device.DeviceId] = device; } return true; } /// /// Подписывает COM-систему на события одного подключения. /// private void Subscribe(ComDeviceConnection device) { device.Authorized += OnDeviceAuthorized; device.Disconnected += OnDeviceDisconnected; device.CoinAccepted += OnCoinAccepted; device.InputReceived += OnInputReceived; device.RawLineReceived += OnRawLineReceived; device.HopperSensorTriggered += OnHopperSensorTriggered; } /// /// Снимает подписки с одного подключения. /// private void Unsubscribe(ComDeviceConnection device) { device.Authorized -= OnDeviceAuthorized; device.Disconnected -= OnDeviceDisconnected; device.CoinAccepted -= OnCoinAccepted; device.InputReceived -= OnInputReceived; device.RawLineReceived -= OnRawLineReceived; device.HopperSensorTriggered -= OnHopperSensorTriggered; } /// /// Назначает роли авторизованному устройству и отправляет событие в главный поток. /// private void OnDeviceAuthorized(ComDeviceConnection device) { lock (devicesLock) { if (string.IsNullOrWhiteSpace(mainDeviceId) || device.PortName == preferredMainPort || IsRole(device.Role, mainControllerRole)) { mainDeviceId = device.DeviceId; } if (IsPrizeStandRole(device.Role)) { prizeStandDeviceId = device.DeviceId; } } mainThreadQueue.Enqueue(() => deviceAuthorized.Invoke(device.DeviceId)); } /// /// Удаляет отключенное устройство из реестра и освобождает его ресурсы. /// private void OnDeviceDisconnected(ComDeviceConnection device) { Unsubscribe(device); lock (devicesLock) { devices.Remove(device.DeviceId); if (device.DeviceId == mainDeviceId) { mainDeviceId = GetFirstDeviceId(); } if (device.DeviceId == prizeStandDeviceId) { prizeStandDeviceId = ""; } } device.Dispose(false); mainThreadQueue.Enqueue(() => deviceDisconnected.Invoke(device.DeviceId)); } /// /// Переносит событие принятой монеты в очередь главного потока. /// private void OnCoinAccepted(string deviceId) { if (deviceId == CurrentPrizeStandDeviceId) { return; } coinQueue.Enqueue(deviceId); } /// /// Переносит входные биты основной платы в очередь главного потока. /// private void OnInputReceived(ComInputMessage message) { if (message.DeviceId == CurrentPrizeStandDeviceId) { return; } inputQueue.Enqueue(message); } /// /// Разбирает сырые строки призовой витрины в статусы ячеек. /// private void OnRawLineReceived(string deviceId, string line) { if (deviceId == CurrentPrizeStandDeviceId && TryParsePrizeBoxStatus(line, out PrizeBoxStatusEntry status)) { prizeStatusQueue.Enqueue(status); } } /// /// Публикует срабатывание датчика хоппера только от основной платы. /// private void OnHopperSensorTriggered(string deviceId) { if (deviceId == CurrentMainDeviceId) { mainThreadQueue.Enqueue(() => hopperSensorTriggered.Invoke()); } } /// /// Выбирает COM-порты для подключения с учетом режима auto-scan или явного списка. /// private string[] SelectPorts(string[] availablePorts) { if (availablePorts == null || availablePorts.Length == 0) { return Array.Empty(); } if (useAllDetectedPorts) { var sortedPorts = (string[])availablePorts.Clone(); Array.Sort(sortedPorts, StringComparer.OrdinalIgnoreCase); return sortedPorts; } HashSet explicitSet = new(StringComparer.OrdinalIgnoreCase); foreach (var explicitPort in explicitPorts ?? Array.Empty()) { if (!string.IsNullOrWhiteSpace(explicitPort)) { explicitSet.Add(explicitPort); } } List selectedPorts = new(); foreach (var availablePort in availablePorts) { if (explicitSet.Contains(availablePort)) { selectedPorts.Add(availablePort); } } return selectedPorts.ToArray(); } /// /// Проверяет, относится ли роль устройства к призовой витрине. /// private bool IsPrizeStandRole(string role) { if (string.IsNullOrWhiteSpace(role)) { return false; } string[] roles = prizeStandRoles == null || prizeStandRoles.Length == 0 ? DefaultPrizeStandRoles : prizeStandRoles; foreach (var candidate in roles) { if (IsRole(role, candidate)) { return true; } } return false; } private ComDeviceConnection[] CopyDeviceValues() { var snapshot = new ComDeviceConnection[devices.Count]; var index = 0; foreach (var device in devices.Values) { snapshot[index++] = device; } return snapshot; } private string CurrentMainDeviceId { get { lock (devicesLock) { return mainDeviceId; } } } private string CurrentPrizeStandDeviceId { get { lock (devicesLock) { return prizeStandDeviceId; } } } private void ResetDeviceSelections() { lock (devicesLock) { mainDeviceId = ""; prizeStandDeviceId = ""; } } private string GetFirstDeviceId() { foreach (var deviceId in devices.Keys) { return deviceId; } return string.Empty; } private static Task RunLoggedTask(Func taskFactory, CancellationToken token, string operationName) { var task = Task.Run(taskFactory, token); _ = task.ContinueWith( completedTask => { var exception = completedTask.Exception?.GetBaseException(); if (exception != null) { UnityEngine.Debug.LogWarning($"[ComSystem] Background task '{operationName}' failed: {exception.Message}"); } }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); return task; } /// /// Сравнивает роли устройств без учета регистра и лишних пробелов. /// private static bool IsRole(string role, string expected) { return !string.IsNullOrWhiteSpace(role) && !string.IsNullOrWhiteSpace(expected) && string.Equals(role.Trim(), expected.Trim(), StringComparison.OrdinalIgnoreCase); } /// /// Проверяет, можно ли выдавать приз из ячейки с таким статусом. /// private static bool IsReadyToOpen(PrizeBoxStatus status) { return status == PrizeBoxStatus.Ok || status == PrizeBoxStatus.OkWithCard; } /// /// Пытается разобрать строку вида BOX{номер}_{статус} от призовой витрины. /// private static bool TryParsePrizeBoxStatus(string line, out PrizeBoxStatusEntry status) { status = default; if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("BOX", StringComparison.OrdinalIgnoreCase)) { return false; } string[] parts = line.Substring(3).Split('_'); if (parts.Length != 2 || !int.TryParse(parts[0], out int boxNumber)) { return false; } status = new PrizeBoxStatusEntry(boxNumber, MapPrizeStatus(parts[1])); return true; } /// /// Преобразует строковый статус прошивки в enum проекта. /// private static PrizeBoxStatus MapPrizeStatus(string status) { return status switch { "OK" => PrizeBoxStatus.Ok, "OK+CARD" => PrizeBoxStatus.OkWithCard, "EMPTY" => PrizeBoxStatus.Empty, "OPEN" => PrizeBoxStatus.Open, "OPENED" => PrizeBoxStatus.Opened, "OPEN_ERROR" => PrizeBoxStatus.OpenError, "ERROR" => PrizeBoxStatus.Error, _ => PrizeBoxStatus.ParsingError }; } /// /// Сохраняет баланс кредитов, если включено сохранение между сессиями. /// private void SaveCredits() { if (!saveCreditsBetweenSessions) { return; } PlayerPrefs.SetInt(creditsPlayerPrefsKey, credits); PlayerPrefs.Save(); } /// /// Освобождает токен запуска, если COM-система не смогла перейти в рабочее состояние. /// private void ResetSystemToken() { systemCts?.Cancel(); systemCts?.Dispose(); systemCts = null; } /// /// Очищает очереди событий, накопленные между потоками. /// private void ClearQueues() { while (mainThreadQueue.TryDequeue(out _)) { } while (coinQueue.TryDequeue(out _)) { } while (inputQueue.TryDequeue(out _)) { } ClearPrizeStatusQueue(); } /// /// Очищает очередь статусов призовой витрины перед новым запросом. /// private void ClearPrizeStatusQueue() { while (prizeStatusQueue.TryDequeue(out _)) { } } /// /// Выполняет случайное мигание шляпы с заданным интервалом. /// private async UniTaskVoid HatBlinkAsync(float intervalSeconds, CancellationTokenSource cancellationSource) { var token = cancellationSource.Token; try { while (!token.IsCancellationRequested) { SendToMain(Random.Range(0f, 1f) < 0.5f ? hatOnCommand : hatOffCommand); float wait = hatBlinkVarianceSeconds > 0f ? Mathf.Max(0.05f, intervalSeconds + Random.Range(-hatBlinkVarianceSeconds, hatBlinkVarianceSeconds)) : intervalSeconds; await UniTask.Delay( TimeSpan.FromSeconds(wait), DelayType.UnscaledDeltaTime, PlayerLoopTiming.Update, token); } } catch (OperationCanceledException) when (token.IsCancellationRequested) { // ignored } finally { ClearCancellationIfCurrent(ref hatBlinkCancellation, cancellationSource); } } /// /// Поддерживает длинные световые эффекты, периодически продлевая команду на плате. /// private void StartBoardLightSequenceTask(IReadOnlyList steps, bool loop) { lightSequenceCancellation = new CancellationTokenSource(); BoardLightSequenceAsync(steps, loop, lightSequenceCancellation).Forget(); } private async UniTaskVoid BoardLightSequenceAsync( IReadOnlyList steps, bool loop, CancellationTokenSource cancellationSource) { var token = cancellationSource.Token; try { do { foreach (LightStep step in steps) { token.ThrowIfCancellationRequested(); SendToMain(BuildBoardLightCommand(step.Mode, step.Cycles)); float waitSeconds = LightTimings.GetCycleSeconds(step.Mode) * step.Cycles; if (step.Mode == LightMode.Off || waitSeconds <= 0f) { continue; } float elapsed = 0f; while (elapsed < waitSeconds) { float chunk = Mathf.Min(2f, waitSeconds - elapsed); await UniTask.Delay( TimeSpan.FromSeconds(chunk), DelayType.UnscaledDeltaTime, PlayerLoopTiming.Update, token); elapsed += chunk; if (elapsed < waitSeconds) { int remainSeconds = Mathf.CeilToInt(waitSeconds - elapsed); SendToMain(BuildBoardLightCommand(step.Mode, remainSeconds)); } } } } while (loop && !token.IsCancellationRequested); } catch (OperationCanceledException) when (token.IsCancellationRequested) { // ignored } finally { ClearCancellationIfCurrent(ref lightSequenceCancellation, cancellationSource); } } private static void CancelAndDispose(ref CancellationTokenSource cancellationSource) { if (cancellationSource == null) { return; } var source = cancellationSource; cancellationSource = null; try { source.Cancel(); } catch { // ignored } source.Dispose(); } private static void ClearCancellationIfCurrent( ref CancellationTokenSource currentSource, CancellationTokenSource completedSource) { if (currentSource != completedSource) { return; } currentSource = null; completedSource.Dispose(); } /// /// Собирает команду платы из светового режима и количества циклов. /// private static string BuildBoardLightCommand(LightMode mode, float cycles) { if (mode == LightMode.Off) { return BoardEffectsOffCommand; } int seconds = LightTimings.CyclesToCommandSeconds(mode, cycles); return BuildBoardLightCommand(mode, seconds); } /// /// Собирает команду платы из светового режима и длительности в секундах. /// private static string BuildBoardLightCommand(LightMode mode, int durationSeconds) { if (mode == LightMode.Off) { return BoardEffectsOffCommand; } return $"{BoardEffectsCommandPrefix}{(int)mode:D2}{Mathf.Clamp(durationSeconds, 0, 99):D2}"; } /// /// Инкапсулирует одно физическое COM-подключение, авторизацию и фоновый обмен с платой. /// private sealed class ComDeviceConnection { 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(); private readonly IComTransportFactory transportFactory; private readonly int baudRate; private readonly int dataBits; private readonly SerialParity parity; private readonly SerialStopBits stopBits; private readonly int readTimeoutMs; private readonly int writeTimeoutMs; private readonly bool useEncryptedHandshake; private readonly int handshakeTimeoutMs; private readonly bool keepAlive; private readonly float keepAliveIntervalSeconds; private readonly int missedInputResponsesBeforeReconnect; private readonly string roleRequestCommand; private readonly string roleResponsePrefix; private readonly int roleRequestTimeoutMs; private readonly byte[] aesKey; private IComTransport serialPort; private CancellationTokenSource localCts; private CancellationToken parentToken; private Task readTask; private Task keepAliveTask; private TaskCompletionSource handshakeCompletion; private TaskCompletionSource chalCompletion; private TaskCompletionSource roleCompletion; private TaskCompletionSource macCompletion; private HandshakeStage handshakeStage = HandshakeStage.None; private string requestHex = ""; private string lastHandshakeResponse = ""; private long lastCoinTime; private int inputReceivedInCycle; private int missedInputResponses; private bool isOperational; private bool disposed; /// /// Создает подключение к одному физическому порту с настройками авторизации и keep-alive. /// public ComDeviceConnection( IComTransportFactory transportFactory, string portName, int baudRate, int dataBits, SerialParity parity, SerialStopBits stopBits, int readTimeoutMs, int writeTimeoutMs, bool useEncryptedHandshake, int handshakeTimeoutMs, bool keepAlive, float keepAliveIntervalSeconds, int missedInputResponsesBeforeReconnect, string roleRequestCommand, string roleResponsePrefix, int roleRequestTimeoutMs) { this.transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory)); PortName = portName; DeviceId = portName; this.baudRate = baudRate; this.dataBits = dataBits; this.parity = parity; this.stopBits = stopBits; this.readTimeoutMs = Mathf.Max(1, readTimeoutMs); this.writeTimeoutMs = Mathf.Max(1, writeTimeoutMs); this.useEncryptedHandshake = useEncryptedHandshake; this.handshakeTimeoutMs = Mathf.Max(500, handshakeTimeoutMs); this.keepAlive = keepAlive; this.keepAliveIntervalSeconds = Mathf.Max(1f, keepAliveIntervalSeconds); this.missedInputResponsesBeforeReconnect = Mathf.Max(1, missedInputResponsesBeforeReconnect); this.roleRequestCommand = roleRequestCommand?.Trim() ?? ""; this.roleResponsePrefix = string.IsNullOrWhiteSpace(roleResponsePrefix) ? "ROLE" : roleResponsePrefix.Trim(); this.roleRequestTimeoutMs = Mathf.Max(200, roleRequestTimeoutMs); aesKey = BuildAesKey(); } /// /// Внутренний идентификатор устройства, сейчас совпадает с именем порта. /// public string DeviceId { get; } /// /// Имя COM-порта, через который подключено устройство. /// public string PortName { get; } /// /// Роль устройства, полученная после авторизации. /// public string Role { get; private set; } = ""; /// /// MAC-адрес устройства, если прошивка его возвращает. /// public string MacAddress { get; private set; } = ""; /// /// Последние входные биты, полученные от устройства. /// public string LastInputBits { get; private set; } = DefaultInputBits; /// /// Событие успешной авторизации подключения. /// public event Action Authorized; /// /// Событие потери подключения. /// public event Action Disconnected; /// /// Событие принятой монеты. /// public event Action CoinAccepted; /// /// Событие получения аппаратного ввода. /// public event Action InputReceived; /// /// Событие сырой строки, которую не обработали стандартные парсеры. /// public event Action RawLineReceived; /// /// Событие срабатывания датчика хоппера. /// public event Action HopperSensorTriggered; /// /// Открывает порт, запускает чтение и при необходимости проходит защищенное рукопожатие. /// public bool Start(CancellationToken token) { parentToken = token; localCts = CancellationTokenSource.CreateLinkedTokenSource(token); if (!OpenPort()) { return false; } readTask = RunLoggedTask(() => ReadLoopAsync(localCts.Token), localCts.Token, $"{PortName} read loop"); if (useEncryptedHandshake) { _ = RunLoggedTask(() => RunHandshakeAsync(localCts.Token), localCts.Token, $"{PortName} handshake"); } else { MarkOperational(); } return true; } /// /// Отправляет команду только после перехода устройства в рабочее состояние. /// public bool Send(string command) { return Send(command, false); } /// /// Закрывает подключение и опционально отправляет команды безопасного выключения. /// public void Dispose(bool sendShutdownCommands) { if (disposed) { return; } disposed = true; try { localCts?.Cancel(); } catch { // ignored } if (sendShutdownCommands) { Send("2170", true); Send("2130", true); } ClosePort(); localCts?.Dispose(); localCts = null; } /// /// Создает serial-транспорт, открывает порт и очищает его буферы. /// private bool OpenPort() { try { lock (ioLock) { serialPort = transportFactory.Create(new SerialSettings( PortName, baudRate, dataBits, parity, stopBits, readTimeoutMs, writeTimeoutMs)); serialPort.Open(); serialPort.DiscardInBuffer(); serialPort.DiscardOutBuffer(); } lastCoinTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); return true; } catch (Exception exception) { UnityEngine.Debug.LogWarning($"[ComSystem] Failed to open {PortName}: {exception.Message}"); return false; } } /// /// Фоновый цикл чтения строк из serial-порта. /// private async Task ReadLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { string line; lock (ioLock) { line = serialPort != null && serialPort.IsOpen ? serialPort.ReadLine() : ""; } ProcessLine(line); } catch (TimeoutException) { await Task.Delay(1, token); } catch (OperationCanceledException) { break; } catch (Exception exception) when (exception is IOException || exception is InvalidOperationException) { UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} disconnected: {exception.Message}"); NotifyDisconnected(); break; } catch (Exception exception) { UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} read error: {exception.Message}"); } } } /// /// Маршрутизирует входящую строку в handshake, идентификацию, ввод или сырые события. /// private void ProcessLine(string line) { line = (line ?? "").Trim(); if (string.IsNullOrEmpty(line)) { return; } if (handshakeStage == HandshakeStage.WaitingChal || handshakeStage == HandshakeStage.WaitingRaes) { ProcessHandshakeLine(line); return; } if (!isOperational) { return; } if (TryCompleteRole(line) || TryCompleteMac(line)) { return; } if (string.Equals(line, "COIN", StringComparison.OrdinalIgnoreCase)) { HandleCoin(); return; } if (line.StartsWith("INPUT", StringComparison.Ordinal)) { HandleInput(line); return; } RawLineReceived?.Invoke(DeviceId, line); } /// /// Выполняет CHAL/RAES-авторизацию, совместимую с текущей прошивкой устройства. /// private async Task RunHandshakeAsync(CancellationToken token) { handshakeStage = HandshakeStage.WaitingChal; handshakeCompletion = NewCompletion(); chalCompletion = NewCompletion(); requestHex = GenerateRandomBlock16Hex(); Send("07" + requestHex, true); Task chalTimeout = Task.Delay(Mathf.Min(1200, Mathf.Max(250, handshakeTimeoutMs / 4)), token); Task chalFinished = await Task.WhenAny(chalCompletion.Task, chalTimeout); if (chalFinished != chalCompletion.Task) { FailHandshake("No CHAL response."); return; } Task timeout = Task.Delay(handshakeTimeoutMs, token); Task finished = await Task.WhenAny(handshakeCompletion.Task, timeout); if (finished == handshakeCompletion.Task && handshakeCompletion.Task.Result) { MarkOperational(); return; } FailHandshake($"Handshake timeout. Last response: {lastHandshakeResponse}"); } /// /// Обрабатывает строку текущего этапа защищенного рукопожатия. /// private void ProcessHandshakeLine(string line) { lastHandshakeResponse = line; if (handshakeStage == HandshakeStage.WaitingChal && line.StartsWith("CHAL", StringComparison.Ordinal)) { HandleChal(line); return; } if (handshakeStage == HandshakeStage.WaitingRaes && line.StartsWith("RAES", StringComparison.Ordinal)) { HandleRaes(line); } } /// /// Обрабатывает CHAL-вызов устройства и отправляет зашифрованный ответ. /// private void HandleChal(string line) { if (line.Length < 36) { FailHandshake("Invalid CHAL length."); return; } string hex = line.Substring(4, 32); if (!IsHexString(hex, 32)) { FailHandshake("Invalid CHAL hex."); return; } chalCompletion?.TrySetResult(true); Send("08" + EncryptHexBlock16(hex), true); handshakeStage = HandshakeStage.WaitingRaes; } /// /// Проверяет RAES-ответ устройства на соответствие исходному запросу. /// private void HandleRaes(string line) { if (line.Length < 36) { FailHandshake("Invalid RAES length."); return; } string hex = line.Substring(4, 32); if (!IsHexString(hex, 32)) { FailHandshake("Invalid RAES hex."); return; } string decrypted = DecryptHexBlock16(hex); if (!string.Equals(decrypted, requestHex, StringComparison.OrdinalIgnoreCase)) { FailHandshake("RAES does not match request."); return; } handshakeStage = HandshakeStage.Completed; handshakeCompletion?.TrySetResult(true); } /// /// Завершает авторизацию ошибкой и помечает устройство отключенным. /// private void FailHandshake(string message) { UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} authorization failed: {message}"); handshakeStage = HandshakeStage.Failed; handshakeCompletion?.TrySetResult(false); NotifyDisconnected(); } /// /// Переводит устройство в рабочее состояние и запускает keep-alive/идентификацию. /// private void MarkOperational() { isOperational = true; handshakeStage = HandshakeStage.Completed; missedInputResponses = 0; Send("2171", true); Send("890003", true); Send("8600", true); if (keepAlive) { keepAliveTask = RunLoggedTask(() => KeepAliveLoopAsync(localCts.Token), localCts.Token, $"{PortName} keep alive"); } _ = RunLoggedTask(() => ResolveIdentityAsync(localCts.Token), localCts.Token, $"{PortName} identity resolution"); } /// /// Запрашивает роль и MAC-адрес устройства после успешной авторизации. /// private async Task ResolveIdentityAsync(CancellationToken token) { try { if (!string.IsNullOrWhiteSpace(roleRequestCommand)) { roleCompletion = NewCompletion(); Send(roleRequestCommand); Task timeout = Task.Delay(roleRequestTimeoutMs, token); Task finished = await Task.WhenAny(roleCompletion.Task, timeout); if (finished == roleCompletion.Task && roleCompletion.Task.IsCompletedSuccessfully) { Role = roleCompletion.Task.Result; } } macCompletion = NewCompletion(); Send("06"); Task macTimeout = Task.Delay(roleRequestTimeoutMs, token); Task macFinished = await Task.WhenAny(macCompletion.Task, macTimeout); if (macFinished == macCompletion.Task && macCompletion.Task.IsCompletedSuccessfully) { MacAddress = macCompletion.Task.Result; } } catch (OperationCanceledException) { return; } finally { Authorized?.Invoke(this); } } /// /// Периодически проверяет, что плата отвечает на запросы ввода, и инициирует переподключение. /// private async Task KeepAliveLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromSeconds(keepAliveIntervalSeconds), token); inputReceivedInCycle = 0; Send(RequestInputCommand); await Task.Delay(1000, token); if (inputReceivedInCycle == 0) { missedInputResponses++; if (missedInputResponses >= missedInputResponsesBeforeReconnect) { await ReconnectAsync(token); } } else { missedInputResponses = 0; } } catch (OperationCanceledException) { break; } catch (Exception exception) { UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} keep alive error: {exception.Message}"); } } } /// /// Переоткрывает порт и повторно проходит авторизацию после потери keep-alive. /// private async Task ReconnectAsync(CancellationToken token) { isOperational = false; Role = ""; MacAddress = ""; LastInputBits = DefaultInputBits; ClosePort(); await Task.Delay(500, token); if (!OpenPort()) { NotifyDisconnected(); return; } if (useEncryptedHandshake) { await RunHandshakeAsync(token); } else { MarkOperational(); } } /// /// Обрабатывает событие монеты с debounce и подтверждением плате. /// private void HandleCoin() { long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (now - lastCoinTime < CoinDebounceMs) { return; } lastCoinTime = now; Send("10", true); CoinAccepted?.Invoke(DeviceId); } /// /// Разбирает строку INPUT, обновляет биты ввода и отдельно отслеживает датчик хоппера. /// private void HandleInput(string line) { inputReceivedInCycle = 1; if (line.Length <= 5) { return; } string rawData = line.Substring(5); if (string.IsNullOrWhiteSpace(rawData)) { return; } if (long.TryParse(rawData, out long rawValue)) { string rawBinary = Convert.ToString(rawValue, 2); if (rawBinary.Length >= 2 && rawBinary[1] == '0') { HopperSensorTriggered?.Invoke(DeviceId); } } LastInputBits = DecimalToReversedBinary(rawData); InputReceived?.Invoke(new ComInputMessage(DeviceId, LastInputBits)); } /// /// Отправляет команду в порт, опционально обходя проверку рабочего состояния для handshake. /// private bool Send(string command, bool bypassOperational) { if (string.IsNullOrWhiteSpace(command)) { return false; } if (!bypassOperational && !isOperational) { return false; } try { lock (ioLock) { if (serialPort == null || !serialPort.IsOpen) { return false; } serialPort.WriteLine(command); return true; } } catch (Exception exception) { UnityEngine.Debug.LogWarning($"[ComSystem] Failed to write '{command}' to {PortName}: {exception.Message}"); return false; } } /// /// Пытается завершить ожидающий запрос роли устройства. /// private bool TryCompleteRole(string line) { if (roleCompletion == null || roleCompletion.Task.IsCompleted) { return false; } string trimmed = line.Trim(); if (!trimmed.StartsWith(roleResponsePrefix, StringComparison.OrdinalIgnoreCase)) { return false; } string role = trimmed.Substring(roleResponsePrefix.Length).Trim(); if (role.StartsWith(":", StringComparison.Ordinal) || role.StartsWith("=", StringComparison.Ordinal)) { role = role.Substring(1).Trim(); } if (string.IsNullOrWhiteSpace(role)) { return false; } Role = role; roleCompletion.TrySetResult(role); return true; } /// /// Пытается завершить ожидающий запрос MAC-адреса устройства. /// private bool TryCompleteMac(string line) { if (macCompletion == null || macCompletion.Task.IsCompleted) { return false; } string trimmed = line.Trim(); if (trimmed.Length != 12 || !IsHexString(trimmed)) { return false; } MacAddress = trimmed.ToUpperInvariant(); macCompletion.TrySetResult(MacAddress); return true; } /// /// Единообразно переводит подключение в отключенное состояние и публикует событие. /// private void NotifyDisconnected() { if (disposed) { return; } isOperational = false; localCts?.Cancel(); ClosePort(); Disconnected?.Invoke(this); } /// /// Закрывает и освобождает serial-транспорт под lock. /// private void ClosePort() { lock (ioLock) { if (serialPort == null) { return; } try { if (serialPort.IsOpen) { serialPort.Close(); } } catch { // ignored } serialPort.Dispose(); serialPort = null; } } private string EncryptHexBlock16(string plainHex) { byte[] plain = HexToBytes(plainHex); using Aes aes = Aes.Create(); aes.Key = aesKey; aes.Mode = CipherMode.ECB; aes.Padding = PaddingMode.None; using ICryptoTransform encryptor = aes.CreateEncryptor(); return BytesToHex(encryptor.TransformFinalBlock(plain, 0, plain.Length)); } /// /// Расшифровывает 16-байтовый AES-блок в hex-формате. /// private string DecryptHexBlock16(string cipherHex) { byte[] cipher = HexToBytes(cipherHex); using Aes aes = Aes.Create(); aes.Key = aesKey; aes.Mode = CipherMode.ECB; aes.Padding = PaddingMode.None; using ICryptoTransform decryptor = aes.CreateDecryptor(); return BytesToHex(decryptor.TransformFinalBlock(cipher, 0, cipher.Length)); } private static TaskCompletionSource NewCompletion() { return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } /// /// Восстанавливает AES-ключ из обфусцированного массива. /// 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 static string DecimalToReversedBinary(string decimalString) { if (!long.TryParse(decimalString, out long value)) { return DefaultInputBits; } string binary = Convert.ToString(value, 2); char[] chars = binary.ToCharArray(); Array.Reverse(chars); string reversed = new string(chars); return reversed.Length >= DefaultInputBits.Length ? reversed : reversed.PadRight(DefaultInputBits.Length, '0'); } /// /// Генерирует случайный 16-байтовый блок для handshake. /// private static string GenerateRandomBlock16Hex() { byte[] buffer = new byte[16]; RandomNumberGenerator.Fill(buffer); return BytesToHex(buffer); } /// /// Преобразует hex-строку в массив байтов. /// 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; } /// /// Преобразует байты в uppercase hex-строку. /// private static string BytesToHex(byte[] bytes) { StringBuilder builder = new(bytes.Length * 2); for (int i = 0; i < bytes.Length; i++) { builder.AppendFormat("{0:X2}", bytes[i]); } return builder.ToString(); } /// /// Проверяет hex-строку с требуемой длиной. /// private static bool IsHexString(string value, int expectedLength) { return !string.IsNullOrWhiteSpace(value) && value.Length == expectedLength && IsHexString(value); } /// /// Проверяет, состоит ли строка только из hex-символов. /// private static bool IsHexString(string value) { for (int i = 0; i < value.Length; i++) { if (!Uri.IsHexDigit(value[i])) { return false; } } return true; } /// /// Состояния защищенного рукопожатия с контроллером. /// private enum HandshakeStage { None, WaitingChal, WaitingRaes, Completed, Failed } } /// /// Сырые данные о статусе одной призовой ячейки из очереди ответов витрины. /// private readonly struct PrizeBoxStatusEntry { /// /// Номер ячейки витрины. /// public readonly int BoxNumber; /// /// Последний полученный статус ячейки. /// public readonly PrizeBoxStatus Status; /// /// Создает запись статуса одной ячейки. /// public PrizeBoxStatusEntry(int boxNumber, PrizeBoxStatus status) { BoxNumber = boxNumber; Status = status; } } /// /// Минимальное состояние паузы световой последовательности. /// private readonly struct LightSequenceState { /// /// Была ли активная последовательность света на момент паузы. /// public readonly bool HasPausedSequence; /// /// Создает состояние паузы световой последовательности. /// public LightSequenceState(bool hasPausedSequence) { HasPausedSequence = hasPausedSequence; } } } }