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 Cysharp.Threading.Tasks;
using DanroJump.Debugging;
using DanroJump.Settings;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Serialization;
using Zenject;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace DanroJump.Hardware.Com
{
///
/// Управляет COM-устройствами автомата: авторизацией плат, кредитами, вводом, подсветкой и призовой витриной.
///
[DefaultExecutionOrder(-9000)]
[DisallowMultipleComponent]
[AddComponentMenu("Hardware/COM System")]
public sealed class ComSystem : MonoBehaviour, IComSystem, IDebugLogConsumer
{
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 PaymentImpulseCostPlayerPrefsSuffix = ".PaymentImpulseCost";
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";
[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";
[SerializeField] [Min(1)] private int paymentImpulseCostFallback = 1;
[SerializeField] [FormerlySerializedAs("simulatedCreditAmount")] [Min(1)] private int simulatedPaymentImpulseCount = 1;
#if ENABLE_INPUT_SYSTEM
[SerializeField] private InputActionReference addCreditActionReference;
[SerializeField] private InputActionAsset inputActionsAsset;
[SerializeField] private string addCreditActionPath = "UI/AddCredit";
#endif
[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("Debug")]
[SerializeField, FormerlySerializedAs("logDebugToConsole")] private bool logLifecycle = true;
[SerializeField] private bool logInput = true;
[SerializeField] private bool logAdc = true;
[SerializeField, FormerlySerializedAs("logRawLinesToConsole")] private bool logRawLines;
[SerializeField] private bool logPrize = true;
[SerializeField] private bool logCredits = true;
[SerializeField] private bool logHopper = true;
[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 mainAdcReceived = 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 adcQueue = new();
private readonly ConcurrentQueue prizeStatusQueue = new();
private readonly SemaphoreSlim prizeOperationLock = new(1, 1);
private IComPortProvider portProvider;
private IComTransportFactory transportFactory;
private IDebugLogService debugLogService;
private IReadOnlyServiceSettings serviceSettings;
private ComLightingSystem lightingSystem;
private CancellationTokenSource systemCts;
private string mainDeviceId = "";
private string prizeStandDeviceId = "";
private int credits;
private int activePaymentImpulseCost = 1;
private bool serviceSettingsCallbacksRegistered;
#if ENABLE_INPUT_SYSTEM
private InputAction addCreditAction;
private bool addCreditActionWasEnabled;
#endif
///
/// Последний активный экземпляр 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;
///
/// Событие получения ADC-значений от основной платы.
///
public UnityEvent MainAdcReceived => mainAdcReceived;
///
/// Событие успешной авторизации 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);
///
/// Последние ADC-значения от основной платы.
///
public string LastMainAdcValues => GetLastAdcValues(CurrentMainDeviceId);
public IReadOnlyCollection ConnectedDeviceIds
{
get
{
lock (devicesLock)
{
return new List(devices.Keys);
}
}
}
///
/// Получает сервис отладочного вывода из DI, если объект создан внутри Zenject-контекста.
///
[Inject]
public void Construct(
[InjectOptional] IDebugLogService injectedDebugLogService,
[InjectOptional] IReadOnlyServiceSettings injectedServiceSettings)
{
debugLogService = injectedDebugLogService;
if (serviceSettingsCallbacksRegistered && !ReferenceEquals(serviceSettings, injectedServiceSettings))
{
UnregisterServiceSettingsCallbacks();
}
serviceSettings = injectedServiceSettings;
if (isActiveAndEnabled)
{
RegisterServiceSettingsCallbacks();
}
}
///
/// Инициализирует 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;
EnsureInfrastructure();
EnsureLightingSystem();
if (dontDestroyOnLoad)
{
if (Application.isPlaying)
{
DontDestroyOnLoad(gameObject);
}
}
credits = saveCreditsBetweenSessions ? PlayerPrefs.GetInt(creditsPlayerPrefsKey, 0) : 0;
activePaymentImpulseCost = LoadStoredPaymentImpulseCost();
if (!saveCreditsBetweenSessions)
{
PlayerPrefs.SetInt(creditsPlayerPrefsKey, 0);
PlayerPrefs.SetInt(GetPaymentImpulseCostPlayerPrefsKey(), activePaymentImpulseCost);
}
}
///
/// Подменяет поставщика портов и фабрику транспорта до запуска системы.
///
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()
{
RegisterServiceSettingsCallbacks();
#if ENABLE_INPUT_SYSTEM
RegisterAddCreditAction();
#endif
if (startOnEnable)
{
StartSystem();
}
}
///
/// Переносит события из фоновых потоков COM-подключений в главный поток Unity.
///
private void Update()
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (Keyboard.current != null && Keyboard.current.f6Key.wasPressedThisFrame)
{
AddPaymentImpulse(simulatedPaymentImpulseCount);
LogDebug(
DebugLogChannel.ComCredits,
logCredits,
$"Simulated F6 keyboard payment impulse input accepted. impulses={simulatedPaymentImpulseCount} balance={credits}");
}
#endif
while (mainThreadQueue.TryDequeue(out Action action))
{
action?.Invoke();
}
while (coinQueue.TryDequeue(out _))
{
AddPaymentImpulse(1);
}
while (inputQueue.TryDequeue(out ComInputMessage message))
{
if (message.DeviceId == CurrentMainDeviceId)
{
mainInputReceived.Invoke(message.InputBits);
}
}
while (adcQueue.TryDequeue(out ComAdcMessage message))
{
if (message.DeviceId == CurrentMainDeviceId)
{
mainAdcReceived.Invoke(message.AdcValues);
}
}
}
///
/// Останавливает порты при выключении компонента, если включен соответствующий режим.
///
private void OnDisable()
{
UnregisterServiceSettingsCallbacks();
#if ENABLE_INPUT_SYSTEM
UnregisterAddCreditAction();
#endif
if (stopPortsOnDisable)
{
StopSystem(false);
}
}
///
/// Закрывает устройства при выходе из приложения и отправляет shutdown-команды.
///
private void OnApplicationQuit()
{
StopSystem(sendShutdownCommandsOnQuit);
}
///
/// Освобождает singleton-ссылку, фоновые подключения и semaphore призовых операций.
///
private void OnDestroy()
{
UnregisterServiceSettingsCallbacks();
if (Instance == this)
{
Instance = null;
}
StopSystem(sendShutdownCommandsOnQuit);
lightingSystem?.Dispose();
lightingSystem = null;
prizeOperationLock.Dispose();
}
///
/// Запускает сканирование портов и авторизацию найденных устройств.
///
public void StartSystem()
{
if (IsRunning)
{
return;
}
systemCts = new CancellationTokenSource();
EnsureInfrastructure();
ResetDeviceSelections();
ClearQueues();
string[] ports = SelectPorts(portProvider.GetPortNames());
if (ShouldLog(DebugLogChannel.ComLifecycle, logLifecycle))
{
LogDebug(DebugLogChannel.ComLifecycle, $"Starting. Selected ports: {(ports.Length > 0 ? string.Join(", ", ports) : "")}");
}
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();
return;
}
LogDebug(DebugLogChannel.ComLifecycle, logLifecycle, $"Started {startedDevices}/{ports.Length} device connection(s).");
}
private void EnsureInfrastructure()
{
portProvider ??= new ReflectionComPortProvider();
transportFactory ??= new ReflectionSerialComTransportFactory();
}
private void EnsureLightingSystem()
{
lightingSystem ??= new ComLightingSystem(
CreateLightingOptions(),
SendToMain,
SendToPrizeStand);
}
private ComLightingOptions CreateLightingOptions()
{
return new ComLightingOptions(
hatOnCommand,
hatOffCommand,
hatBlinkIntervalSeconds,
hatBlinkVarianceSeconds,
defaultLightMode,
defaultLightCycles);
}
///
/// Останавливает все фоновые COM-операции и закрывает открытые порты.
///
public void StopSystem(bool sendShutdownCommands)
{
if (systemCts == null)
{
return;
}
LogDebug(DebugLogChannel.ComLifecycle, logLifecycle, $"Stopping. sendShutdownCommands={sendShutdownCommands}");
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();
lightingSystem?.StopBoardLightSequence();
lightingSystem?.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;
}
}
///
/// Возвращает последние ADC-значения устройства или пустую строку.
///
public string GetLastAdcValues(string deviceId)
{
lock (devicesLock)
{
return !string.IsNullOrWhiteSpace(deviceId) && devices.TryGetValue(deviceId, out ComDeviceConnection device)
? device.LastAdcValues
: "";
}
}
///
/// Возвращает роль авторизованного устройства.
///
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);
}
private void AddPaymentImpulse(int impulseCount = 1)
{
if (impulseCount <= 0)
{
return;
}
var impulseCost = ResolvePaymentImpulseCost();
ApplyPaymentImpulseCostChange(impulseCost);
AddCredit(impulseCount * impulseCost);
LogDebug(
DebugLogChannel.ComCredits,
logCredits,
$"Payment impulse accepted. impulses={impulseCount} impulseCost={impulseCost} balance={credits}");
}
private int ResolvePaymentImpulseCost()
{
ResolveServiceSettingsIfNeeded();
return serviceSettings != null
? Mathf.Max(1, serviceSettings.GetInt(ServiceSettingsKeys.PaymentImpulseCost))
: Mathf.Max(1, paymentImpulseCostFallback);
}
private void ResolveServiceSettingsIfNeeded()
{
if (serviceSettings != null)
{
return;
}
try
{
var projectContext = ProjectContext.Instance;
if (projectContext != null &&
projectContext.Container != null &&
projectContext.Container.HasBinding())
{
serviceSettings = projectContext.Container.Resolve();
}
}
catch (ZenjectException exception)
{
LogWarning(
DebugLogChannel.ComLifecycle,
logLifecycle,
$"Service settings are not available for payment impulse cost: {exception.Message}");
}
}
private void RegisterServiceSettingsCallbacks()
{
ResolveServiceSettingsIfNeeded();
if (serviceSettings == null || serviceSettingsCallbacksRegistered)
{
return;
}
serviceSettings.SettingChanged += HandleServiceSettingChanged;
serviceSettings.SettingsLoaded += HandleServiceSettingsLoaded;
serviceSettingsCallbacksRegistered = true;
ApplyPaymentImpulseCostChange(ResolvePaymentImpulseCost());
}
private void UnregisterServiceSettingsCallbacks()
{
if (!serviceSettingsCallbacksRegistered || serviceSettings == null)
{
return;
}
serviceSettings.SettingChanged -= HandleServiceSettingChanged;
serviceSettings.SettingsLoaded -= HandleServiceSettingsLoaded;
serviceSettingsCallbacksRegistered = false;
}
private void HandleServiceSettingsLoaded()
{
ApplyPaymentImpulseCostChange(ResolvePaymentImpulseCost());
}
private void HandleServiceSettingChanged(string key, ServiceSettingValue value)
{
if (!string.Equals(key, ServiceSettingsKeys.PaymentImpulseCost, StringComparison.Ordinal))
{
return;
}
var newImpulseCost = value.type == ServiceSettingValueType.Int
? value.intValue
: ResolvePaymentImpulseCost();
ApplyPaymentImpulseCostChange(newImpulseCost);
}
private void ApplyPaymentImpulseCostChange(int newImpulseCost)
{
newImpulseCost = Mathf.Max(1, newImpulseCost);
var oldImpulseCost = Mathf.Max(1, activePaymentImpulseCost);
if (oldImpulseCost == newImpulseCost)
{
activePaymentImpulseCost = newImpulseCost;
SaveActivePaymentImpulseCost();
return;
}
var oldCredits = credits;
credits = RecalculateCreditsForPaymentImpulseCost(credits, oldImpulseCost, newImpulseCost);
activePaymentImpulseCost = newImpulseCost;
SaveCredits();
SaveActivePaymentImpulseCost();
if (credits > oldCredits)
{
creditAdded.Invoke(credits);
}
else if (credits < oldCredits)
{
creditRemoved.Invoke(credits);
}
creditsChanged.Invoke(credits);
LogDebug(
DebugLogChannel.ComCredits,
logCredits,
$"Payment impulse cost changed. oldCost={oldImpulseCost} newCost={newImpulseCost} oldBalance={oldCredits} balance={credits}");
}
private static int RecalculateCreditsForPaymentImpulseCost(int currentCredits, int oldImpulseCost, int newImpulseCost)
{
if (currentCredits <= 0)
{
return 0;
}
oldImpulseCost = Mathf.Max(1, oldImpulseCost);
newImpulseCost = Mathf.Max(1, newImpulseCost);
long numerator = (long)currentCredits * newImpulseCost;
long rounded = (numerator + oldImpulseCost / 2L) / oldImpulseCost;
return (int)Math.Min(int.MaxValue, rounded);
}
private int LoadStoredPaymentImpulseCost()
{
var currentImpulseCost = ResolvePaymentImpulseCost();
return saveCreditsBetweenSessions
? Mathf.Max(1, PlayerPrefs.GetInt(GetPaymentImpulseCostPlayerPrefsKey(), currentImpulseCost))
: currentImpulseCost;
}
private void SaveActivePaymentImpulseCost()
{
if (!saveCreditsBetweenSessions)
{
return;
}
PlayerPrefs.SetInt(GetPaymentImpulseCostPlayerPrefsKey(), Mathf.Max(1, activePaymentImpulseCost));
}
private string GetPaymentImpulseCostPlayerPrefsKey()
{
return creditsPlayerPrefsKey + PaymentImpulseCostPlayerPrefsSuffix;
}
#if ENABLE_INPUT_SYSTEM
private void RegisterAddCreditAction()
{
if (addCreditAction != null)
{
return;
}
addCreditAction = ResolveAddCreditAction();
if (addCreditAction == null)
{
return;
}
addCreditActionWasEnabled = addCreditAction.enabled;
addCreditAction.performed += HandleAddCreditActionPerformed;
if (!addCreditAction.enabled)
{
addCreditAction.Enable();
}
}
private void UnregisterAddCreditAction()
{
if (addCreditAction == null)
{
return;
}
addCreditAction.performed -= HandleAddCreditActionPerformed;
if (!addCreditActionWasEnabled)
{
addCreditAction.Disable();
}
addCreditAction = null;
}
private InputAction ResolveAddCreditAction()
{
if (addCreditActionReference != null && addCreditActionReference.action != null)
{
return addCreditActionReference.action;
}
return inputActionsAsset != null && !string.IsNullOrWhiteSpace(addCreditActionPath)
? inputActionsAsset.FindAction(addCreditActionPath, false)
: null;
}
private void HandleAddCreditActionPerformed(InputAction.CallbackContext context)
{
AddPaymentImpulse(simulatedPaymentImpulseCount);
LogDebug(
DebugLogChannel.ComCredits,
logCredits,
$"Simulated board payment impulse input accepted. impulses={simulatedPaymentImpulseCount} balance={credits}");
}
#endif
///
/// Пытается списать кредиты, не позволяя балансу уйти ниже нуля.
///
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 UniTask> 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 UniTask.Delay(10, DelayType.DeltaTime, PlayerLoopTiming.Update, cancellationToken);
}
return statuses;
}
///
/// Возвращает номера ячеек, из которых можно безопасно выдать приз.
///
public async UniTask 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 UniTask OpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default)
{
return await OpenPrizeBoxInternalAsync(boxNumber, false, cancellationToken);
}
///
/// Открывает призовую ячейку без предварительной проверки статуса.
///
public async UniTask ForceOpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default)
{
return await OpenPrizeBoxInternalAsync(boxNumber, true, cancellationToken);
}
///
/// Переводит подсветку призовой витрины в режим ожидания.
///
public bool SetPrizeStandIdleLighting()
{
EnsureLightingSystem();
return lightingSystem.SetPrizeStandIdleLighting();
}
///
/// Подсвечивает конкретную призовую ячейку на витрине.
///
public bool HighlightPrizeBox(int boxNumber)
{
EnsureLightingSystem();
return lightingSystem.HighlightPrizeBox(boxNumber);
}
///
/// Запускает один световой эффект на основной плате.
///
public void PlayBoardLight(LightMode mode, float cycles = -1f)
{
EnsureLightingSystem();
lightingSystem.PlayBoardLight(mode, cycles);
}
///
/// Запускает последовательность световых эффектов на основной плате.
///
public void PlayBoardLightSequence(IReadOnlyList steps, bool loop)
{
EnsureLightingSystem();
lightingSystem.PlayBoardLightSequence(steps, loop);
}
///
/// Запускает световой эффект платы, выбранный как эффект по умолчанию.
///
public void PlayDefaultBoardLight()
{
EnsureLightingSystem();
lightingSystem.PlayDefaultBoardLight();
}
///
/// Останавливает текущую последовательность световых эффектов платы.
///
public void StopBoardLightSequence()
{
EnsureLightingSystem();
lightingSystem.StopBoardLightSequence();
}
///
/// Останавливает все эффекты платы и отправляет аппаратную команду выключения.
///
public void StopAllBoardEffects()
{
EnsureLightingSystem();
lightingSystem.StopAllBoardEffects();
}
///
/// Запускает мигание шляпы с интервалом из инспектора.
///
public void BlinkHat()
{
EnsureLightingSystem();
lightingSystem.BlinkHat();
}
///
/// Запускает мигание шляпы с указанным интервалом.
///
public void BlinkHat(float intervalSeconds)
{
EnsureLightingSystem();
lightingSystem.BlinkHat(intervalSeconds);
}
///
/// Останавливает мигание шляпы и оставляет ее включенной или выключенной.
///
public void StopHatBlink(bool leaveHatEnabled = true)
{
EnsureLightingSystem();
lightingSystem.StopHatBlink(leaveHatEnabled);
}
///
/// Приостанавливает активные световые сценарии без сброса игрового состояния.
///
public void PauseLighting()
{
EnsureLightingSystem();
lightingSystem.PauseLighting();
}
///
/// Возобновляет световые сценарии после паузы.
///
public void ResumeLighting()
{
EnsureLightingSystem();
lightingSystem.ResumeLighting();
}
///
/// Общий сценарий выдачи приза с сериализацией операций через semaphore.
///
private async UniTask 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 UniTask ReadPrizeBoxStatusAsync(
int boxNumber,
float timeoutSeconds,
CancellationToken cancellationToken)
{
ClearPrizeStatusQueue();
if (!SendToPrizeStand(RequestStandStatusCommand))
{
return PrizeBoxStatus.NotFound;
}
return await WaitPrizeBoxStatusAsync(boxNumber, null, timeoutSeconds, cancellationToken);
}
///
/// Ожидает статус нужной ячейки до таймаута, опционально до конкретного ожидаемого состояния.
///
private async UniTask 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 UniTask.Delay(10, DelayType.DeltaTime, PlayerLoopTiming.Update, 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,
message => LogWarning(DebugLogChannel.ComLifecycle, logLifecycle, message));
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.AdcReceived += OnAdcReceived;
device.RawLineReceived += OnRawLineReceived;
device.HopperSensorTriggered += OnHopperSensorTriggered;
}
///
/// Снимает подписки с одного подключения.
///
private void Unsubscribe(ComDeviceConnection device)
{
device.Authorized -= OnDeviceAuthorized;
device.Disconnected -= OnDeviceDisconnected;
device.CoinAccepted -= OnCoinAccepted;
device.InputReceived -= OnInputReceived;
device.AdcReceived -= OnAdcReceived;
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;
}
}
if (ShouldLog(DebugLogChannel.ComLifecycle, logLifecycle))
{
LogDebug(DebugLogChannel.ComLifecycle, $"Authorized {FormatDeviceDebug(device)}. main={CurrentMainDeviceId} prize={CurrentPrizeStandDeviceId}");
}
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);
if (ShouldLog(DebugLogChannel.ComLifecycle, logLifecycle))
{
LogDebug(DebugLogChannel.ComLifecycle, $"Disconnected {FormatDeviceDebug(device)}. main={CurrentMainDeviceId} prize={CurrentPrizeStandDeviceId}");
}
mainThreadQueue.Enqueue(() => deviceDisconnected.Invoke(device.DeviceId));
}
///
/// Переносит событие принятой монеты в очередь главного потока.
///
private void OnCoinAccepted(string deviceId)
{
if (deviceId == CurrentPrizeStandDeviceId)
{
return;
}
coinQueue.Enqueue(deviceId);
LogDebug(DebugLogChannel.ComCredits, logCredits, $"Coin accepted from {deviceId}.");
}
///
/// Переносит входные биты основной платы в очередь главного потока.
///
private void OnInputReceived(ComInputMessage message)
{
if (message.DeviceId == CurrentPrizeStandDeviceId)
{
return;
}
inputQueue.Enqueue(message);
LogDebug(DebugLogChannel.ComInput, logInput, $"INPUT {message.DeviceId}: {message.InputBits}");
}
///
/// Переносит ADC-значения основной платы в очередь главного потока.
///
private void OnAdcReceived(ComAdcMessage message)
{
if (message.DeviceId == CurrentPrizeStandDeviceId)
{
return;
}
adcQueue.Enqueue(message);
LogDebug(DebugLogChannel.ComAdc, logAdc, $"ADC {message.DeviceId}: {message.AdcValues}");
}
///
/// Разбирает сырые строки призовой витрины в статусы ячеек.
///
private void OnRawLineReceived(string deviceId, string line)
{
LogDebug(DebugLogChannel.ComRaw, logRawLines, $"RAW {deviceId}: {line}");
if (deviceId == CurrentPrizeStandDeviceId && TryParsePrizeBoxStatus(line, out PrizeBoxStatusEntry status))
{
prizeStatusQueue.Enqueue(status);
LogDebug(DebugLogChannel.ComPrize, logPrize, $"Prize status {deviceId}: box={status.BoxNumber} status={status.Status}");
}
}
///
/// Публикует срабатывание датчика хоппера только от основной платы.
///
private void OnHopperSensorTriggered(string deviceId)
{
if (deviceId == CurrentMainDeviceId)
{
LogDebug(DebugLogChannel.ComHopper, logHopper, $"Hopper sensor triggered by {deviceId}.");
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 void RunLoggedTask(Func taskFactory, CancellationToken token, string operationName)
{
RunLoggedTaskAsync(taskFactory, token, operationName).Forget(UnityEngine.Debug.LogException);
}
private bool ShouldLog(DebugLogChannel channel, bool localSwitchEnabled)
{
if (!localSwitchEnabled)
{
return false;
}
var service = debugLogService ?? DebugLogService.Instance;
return service == null || service.IsChannelEnabled(channel);
}
private void LogDebug(DebugLogChannel channel, bool localSwitchEnabled, string message)
{
if (localSwitchEnabled)
{
LogDebug(channel, message);
}
}
private void LogDebug(DebugLogChannel channel, string message)
{
var service = debugLogService ?? DebugLogService.Instance;
if (service != null)
{
service.Log(channel, $"[ComSystem] {message}", this);
return;
}
UnityEngine.Debug.Log($"[{channel}] [ComSystem] {message}", this);
}
private void LogWarning(DebugLogChannel channel, bool localSwitchEnabled, string message)
{
if (!localSwitchEnabled)
{
return;
}
var service = debugLogService ?? DebugLogService.Instance;
if (service != null)
{
service.LogWarning(channel, $"[ComSystem] {message}", this);
return;
}
UnityEngine.Debug.LogWarning($"[{channel}] [ComSystem] {message}", this);
}
private static string FormatDeviceDebug(ComDeviceConnection device)
{
if (device == null)
{
return "";
}
return $"{device.DeviceId} port={device.PortName} role={FormatDebugValue(device.Role)} mac={FormatDebugValue(device.MacAddress)}";
}
private static string FormatDebugValue(string value)
{
return string.IsNullOrWhiteSpace(value) ? "" : value;
}
private static async UniTask RunLoggedTaskAsync(Func taskFactory, CancellationToken token, string operationName)
{
try
{
await UniTask.RunOnThreadPool(taskFactory, configureAwait: false, cancellationToken: token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
}
catch (Exception exception)
{
UnityEngine.Debug.LogWarning($"[ComSystem] Background task '{operationName}' failed: {exception.Message}");
}
}
///
/// Сравнивает роли устройств без учета регистра и лишних пробелов.
///
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 _)) { }
while (adcQueue.TryDequeue(out _)) { }
ClearPrizeStatusQueue();
}
///
/// Очищает очередь статусов призовой витрины перед новым запросом.
///
private void ClearPrizeStatusQueue()
{
while (prizeStatusQueue.TryDequeue(out _)) { }
}
///
/// Инкапсулирует одно физическое 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 Action logWarning;
private readonly byte[] aesKey;
private IComTransport serialPort;
private CancellationTokenSource localCts;
private CancellationToken parentToken;
private UniTaskCompletionSource handshakeCompletion;
private UniTaskCompletionSource chalCompletion;
private UniTaskCompletionSource roleCompletion;
private UniTaskCompletionSource 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,
Action logWarning)
{
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);
this.logWarning = logWarning;
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;
///
/// Последние ADC-значения, полученные от устройства.
///
public string LastAdcValues { get; private set; } = "";
///
/// Событие успешной авторизации подключения.
///
public event Action Authorized;
///
/// Событие потери подключения.
///
public event Action Disconnected;
///
/// Событие принятой монеты.
///
public event Action CoinAccepted;
///
/// Событие получения аппаратного ввода.
///
public event Action InputReceived;
///
/// Событие получения аналоговых ADC-входов.
///
public event Action AdcReceived;
///
/// Событие сырой строки, которую не обработали стандартные парсеры.
///
public event Action RawLineReceived;
///
/// Событие срабатывания датчика хоппера.
///
public event Action HopperSensorTriggered;
///
/// Открывает порт, запускает чтение и при необходимости проходит защищенное рукопожатие.
///
public bool Start(CancellationToken token)
{
parentToken = token;
localCts = CancellationTokenSource.CreateLinkedTokenSource(token);
if (!OpenPort())
{
return false;
}
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)
{
LogWarning($"Failed to open {PortName}: {exception.Message}");
return false;
}
}
///
/// Фоновый цикл чтения строк из serial-порта.
///
private async UniTask ReadLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
string line;
IComTransport port;
lock (ioLock)
{
port = serialPort;
}
if (port == null || !port.IsOpen)
{
await UniTask.Delay(10, DelayType.Realtime, PlayerLoopTiming.Update, token);
continue;
}
line = port.ReadLine();
ProcessLine(line);
}
catch (TimeoutException)
{
await UniTask.Delay(1, DelayType.Realtime, PlayerLoopTiming.Update, token);
await UniTask.SwitchToThreadPool();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception) when (exception is IOException || exception is InvalidOperationException)
{
LogWarning($"{PortName} disconnected: {exception.Message}");
NotifyDisconnected();
break;
}
catch (Exception exception)
{
LogWarning($"{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;
}
if (line.StartsWith("ADC", StringComparison.OrdinalIgnoreCase))
{
HandleAdc(line);
return;
}
RawLineReceived?.Invoke(DeviceId, line);
}
///
/// Выполняет CHAL/RAES-авторизацию, совместимую с текущей прошивкой устройства.
///
private async UniTask RunHandshakeAsync(CancellationToken token)
{
handshakeStage = HandshakeStage.WaitingChal;
handshakeCompletion = NewCompletion();
chalCompletion = NewCompletion();
requestHex = GenerateRandomBlock16Hex();
Send("07" + requestHex, true);
var (chalReceived, _) = await UniTask.WhenAny(
chalCompletion.Task,
UniTask.Delay(Mathf.Min(1200, Mathf.Max(250, handshakeTimeoutMs / 4)), DelayType.Realtime, PlayerLoopTiming.Update, token));
await UniTask.SwitchToThreadPool();
if (!chalReceived)
{
FailHandshake("No CHAL response.");
return;
}
var (handshakeFinished, handshakeSucceeded) = await UniTask.WhenAny(
handshakeCompletion.Task,
UniTask.Delay(handshakeTimeoutMs, DelayType.Realtime, PlayerLoopTiming.Update, token));
await UniTask.SwitchToThreadPool();
if (handshakeFinished && handshakeSucceeded)
{
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)
{
LogWarning($"{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)
{
RunLoggedTask(() => KeepAliveLoopAsync(localCts.Token), localCts.Token, $"{PortName} keep alive");
}
RunLoggedTask(() => ResolveIdentityAsync(localCts.Token), localCts.Token, $"{PortName} identity resolution");
}
///
/// Запрашивает роль и MAC-адрес устройства после успешной авторизации.
///
private async UniTask ResolveIdentityAsync(CancellationToken token)
{
try
{
if (!string.IsNullOrWhiteSpace(roleRequestCommand))
{
roleCompletion = NewCompletion();
Send(roleRequestCommand);
var (roleReceived, role) = await UniTask.WhenAny(
roleCompletion.Task,
UniTask.Delay(roleRequestTimeoutMs, DelayType.Realtime, PlayerLoopTiming.Update, token));
await UniTask.SwitchToThreadPool();
if (roleReceived)
{
Role = role;
}
}
macCompletion = NewCompletion();
Send("06");
var (macReceived, macAddress) = await UniTask.WhenAny(
macCompletion.Task,
UniTask.Delay(roleRequestTimeoutMs, DelayType.Realtime, PlayerLoopTiming.Update, token));
await UniTask.SwitchToThreadPool();
if (macReceived)
{
MacAddress = macAddress;
}
}
catch (OperationCanceledException)
{
return;
}
finally
{
Authorized?.Invoke(this);
}
}
///
/// Периодически проверяет, что плата отвечает на запросы ввода, и инициирует переподключение.
///
private async UniTask KeepAliveLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await UniTask.Delay(TimeSpan.FromSeconds(keepAliveIntervalSeconds), DelayType.Realtime, PlayerLoopTiming.Update, token);
await UniTask.SwitchToThreadPool();
inputReceivedInCycle = 0;
Send(RequestInputCommand);
await UniTask.Delay(1000, DelayType.Realtime, PlayerLoopTiming.Update, token);
await UniTask.SwitchToThreadPool();
if (inputReceivedInCycle == 0)
{
missedInputResponses++;
if (missedInputResponses >= missedInputResponsesBeforeReconnect)
{
await ReconnectAsync(token);
}
}
else
{
missedInputResponses = 0;
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception)
{
LogWarning($"{PortName} keep alive error: {exception.Message}");
}
}
}
///
/// Переоткрывает порт и повторно проходит авторизацию после потери keep-alive.
///
private async UniTask ReconnectAsync(CancellationToken token)
{
isOperational = false;
Role = "";
MacAddress = "";
LastInputBits = DefaultInputBits;
ClosePort();
await UniTask.Delay(500, DelayType.Realtime, PlayerLoopTiming.Update, token);
await UniTask.SwitchToThreadPool();
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));
}
///
/// Разбирает строку ADC и публикует сырые аналоговые значения.
///
private void HandleAdc(string line)
{
string rawData = line.Length > 3 ? line.Substring(3).Trim() : "";
if (rawData.StartsWith(":", StringComparison.Ordinal) || rawData.StartsWith("=", StringComparison.Ordinal))
{
rawData = rawData.Substring(1).Trim();
}
LastAdcValues = rawData;
AdcReceived?.Invoke(new ComAdcMessage(DeviceId, LastAdcValues));
}
///
/// Отправляет команду в порт, опционально обходя проверку рабочего состояния для 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)
{
LogWarning($"Failed to write '{command}' to {PortName}: {exception.Message}");
return false;
}
}
private void LogWarning(string message)
{
if (logWarning != null)
{
logWarning(message);
return;
}
UnityEngine.Debug.LogWarning($"[ComSystem] {message}");
}
///
/// Пытается завершить ожидающий запрос роли устройства.
///
private bool TryCompleteRole(string line)
{
if (roleCompletion == null || roleCompletion.Task.Status.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.Status.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 UniTaskCompletionSource NewCompletion()
{
return new UniTaskCompletionSource();
}
///
/// Восстанавливает 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;
}
}
}
}