Подробные изменения: - Добавлены сцены Bootstrap, MainMenu и Game с текущей вертикальной 9:16 структурой проекта. - Добавлены runtime-скрипты gameplay-архитектуры: DI через Extenject, спавнер платформ, прогрессия этапов, one-way платформы, игрок, камера и оборудование JumpPad. - Добавлены ScriptableObject-этапы сложности, weighted-настройки платформ, оборудования, противников и collectibles. - Добавлены UI Toolkit ресурсы главного меню, preview-материалы, шрифты, спрайты, анимации и игровые prefab-ассеты. - Перенесена и оформлена новая COM-система для работы с аппаратными портами и StreamingAssets. - Обновлены проектные гайды, AGENTS.md, правила Git/Git LFS, .gitignore, Unity Packages и ProjectSettings. - Удалён локальный skill start-required-mcp-servers из репозитория.
1778 lines
60 KiB
C#
1778 lines
60 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using UnityEngine;
|
||
using UnityEngine.Events;
|
||
using Random = UnityEngine.Random;
|
||
|
||
namespace DanroJump.Hardware.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<string>();
|
||
[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<int> creditAdded = new();
|
||
[SerializeField] private UnityEvent<int> creditRemoved = new();
|
||
[SerializeField] private UnityEvent<int> creditsChanged = new();
|
||
[SerializeField] private UnityEvent<string> mainInputReceived = new();
|
||
[SerializeField] private UnityEvent<string> deviceAuthorized = new();
|
||
[SerializeField] private UnityEvent<string> deviceDisconnected = new();
|
||
[SerializeField] private UnityEvent<int> prizeBoxOpened = new();
|
||
[SerializeField] private UnityEvent hopperSensorTriggered = new();
|
||
|
||
private readonly Dictionary<string, ComDeviceConnection> devices = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly object devicesLock = new();
|
||
private readonly ConcurrentQueue<Action> mainThreadQueue = new();
|
||
private readonly ConcurrentQueue<string> coinQueue = new();
|
||
private readonly ConcurrentQueue<ComInputMessage> inputQueue = new();
|
||
private readonly ConcurrentQueue<PrizeBoxStatusEntry> prizeStatusQueue = new();
|
||
private readonly SemaphoreSlim prizeOperationLock = new(1, 1);
|
||
private IComPortProvider portProvider;
|
||
private IComTransportFactory transportFactory;
|
||
|
||
private CancellationTokenSource systemCts;
|
||
private Coroutine hatBlinkCoroutine;
|
||
private Coroutine lightSequenceCoroutine;
|
||
private LightSequenceState pausedLightSequence;
|
||
private bool hatIsOn;
|
||
private bool hatBlinkWasPaused;
|
||
private string mainDeviceId = "";
|
||
private string prizeStandDeviceId = "";
|
||
private int credits;
|
||
|
||
public static ComSystem Instance { get; private set; }
|
||
|
||
public UnityEvent<int> CreditAdded => creditAdded;
|
||
public UnityEvent<int> CreditRemoved => creditRemoved;
|
||
public UnityEvent<int> CreditsChanged => creditsChanged;
|
||
public UnityEvent<string> MainInputReceived => mainInputReceived;
|
||
public UnityEvent<string> DeviceAuthorized => deviceAuthorized;
|
||
public UnityEvent<string> DeviceDisconnected => deviceDisconnected;
|
||
public UnityEvent<int> PrizeBoxOpened => prizeBoxOpened;
|
||
public UnityEvent HopperSensorTriggered => hopperSensorTriggered;
|
||
|
||
public bool IsRunning => systemCts != null && !systemCts.IsCancellationRequested;
|
||
public int Credits => credits;
|
||
public string MainDeviceId => mainDeviceId;
|
||
public string PrizeStandDeviceId => prizeStandDeviceId;
|
||
public string LastMainInputBits => GetLastInputBits(mainDeviceId);
|
||
public IReadOnlyCollection<string> ConnectedDeviceIds
|
||
{
|
||
get
|
||
{
|
||
lock (devicesLock)
|
||
{
|
||
return devices.Keys.ToArray();
|
||
}
|
||
}
|
||
}
|
||
|
||
private void Awake()
|
||
{
|
||
if (Instance != null && Instance != this)
|
||
{
|
||
UnityEngine.Debug.LogWarning("[ComSystem] Another instance already exists. Keeping the newest scene instance active.", this);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
private void OnEnable()
|
||
{
|
||
if (startOnEnable)
|
||
{
|
||
StartSystem();
|
||
}
|
||
}
|
||
|
||
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 == mainDeviceId)
|
||
{
|
||
mainInputReceived.Invoke(message.InputBits);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
if (stopPortsOnDisable)
|
||
{
|
||
StopSystem(false);
|
||
}
|
||
}
|
||
|
||
private void OnApplicationQuit()
|
||
{
|
||
StopSystem(sendShutdownCommandsOnQuit);
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
if (Instance == this)
|
||
{
|
||
Instance = null;
|
||
}
|
||
|
||
StopSystem(false);
|
||
prizeOperationLock.Dispose();
|
||
}
|
||
|
||
public void StartSystem()
|
||
{
|
||
if (IsRunning)
|
||
{
|
||
return;
|
||
}
|
||
|
||
systemCts = new CancellationTokenSource();
|
||
mainDeviceId = "";
|
||
prizeStandDeviceId = "";
|
||
ClearQueues();
|
||
|
||
string[] ports = SelectPorts(portProvider.GetPortNames());
|
||
if (ports.Length == 0)
|
||
{
|
||
UnityEngine.Debug.LogWarning("[ComSystem] COM ports were not found.");
|
||
return;
|
||
}
|
||
|
||
foreach (string portName in ports)
|
||
{
|
||
TryStartDevice(portName);
|
||
}
|
||
}
|
||
|
||
public void StopSystem(bool sendShutdownCommands)
|
||
{
|
||
if (systemCts == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
systemCts.Cancel();
|
||
}
|
||
catch
|
||
{
|
||
// ignored
|
||
}
|
||
|
||
ComDeviceConnection[] deviceSnapshot;
|
||
lock (devicesLock)
|
||
{
|
||
deviceSnapshot = devices.Values.ToArray();
|
||
devices.Clear();
|
||
}
|
||
|
||
foreach (ComDeviceConnection device in deviceSnapshot)
|
||
{
|
||
Unsubscribe(device);
|
||
device.Dispose(sendShutdownCommands);
|
||
}
|
||
|
||
systemCts.Dispose();
|
||
systemCts = null;
|
||
mainDeviceId = "";
|
||
prizeStandDeviceId = "";
|
||
StopBoardLightSequence();
|
||
StopHatBlink(false);
|
||
ClearQueues();
|
||
}
|
||
|
||
public bool SendToMain(string command)
|
||
{
|
||
return SendToDevice(mainDeviceId, command);
|
||
}
|
||
|
||
public bool SendToPrizeStand(string command)
|
||
{
|
||
return SendToDevice(prizeStandDeviceId, 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 = devices.Values.ToArray();
|
||
}
|
||
|
||
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
|
||
: "";
|
||
}
|
||
}
|
||
|
||
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<IReadOnlyDictionary<int, PrizeBoxStatus>> GetPrizeBoxStatusesAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
if (prizeStandBoxesCount <= 0 || string.IsNullOrWhiteSpace(prizeStandDeviceId))
|
||
{
|
||
return new Dictionary<int, PrizeBoxStatus>();
|
||
}
|
||
|
||
ClearPrizeStatusQueue();
|
||
if (!SendToPrizeStand(RequestStandStatusCommand))
|
||
{
|
||
return new Dictionary<int, PrizeBoxStatus>();
|
||
}
|
||
|
||
Dictionary<int, PrizeBoxStatus> 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<int[]> GetReadyPrizeBoxesAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
IReadOnlyDictionary<int, PrizeBoxStatus> statuses = await GetPrizeBoxStatusesAsync(cancellationToken);
|
||
return statuses
|
||
.Where(pair => IsReadyToOpen(pair.Value))
|
||
.Select(pair => pair.Key)
|
||
.OrderBy(boxNumber => boxNumber)
|
||
.ToArray();
|
||
}
|
||
|
||
public async Task<PrizeOpenResult> OpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default)
|
||
{
|
||
return await OpenPrizeBoxInternalAsync(boxNumber, false, cancellationToken);
|
||
}
|
||
|
||
public async Task<PrizeOpenResult> 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;
|
||
lightSequenceCoroutine = StartCoroutine(BoardLightSequenceCoroutine(new[] { new LightStep(mode, resolvedCycles) }, false));
|
||
}
|
||
|
||
public void PlayBoardLightSequence(IReadOnlyList<LightStep> steps, bool loop)
|
||
{
|
||
if (steps == null || steps.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
StopBoardLightSequence();
|
||
lightSequenceCoroutine = StartCoroutine(BoardLightSequenceCoroutine(steps, loop));
|
||
}
|
||
|
||
public void PlayDefaultBoardLight()
|
||
{
|
||
PlayBoardLight(defaultLightMode, defaultLightCycles);
|
||
}
|
||
|
||
public void StopBoardLightSequence()
|
||
{
|
||
if (lightSequenceCoroutine != null)
|
||
{
|
||
StopCoroutine(lightSequenceCoroutine);
|
||
lightSequenceCoroutine = null;
|
||
}
|
||
|
||
pausedLightSequence = default;
|
||
}
|
||
|
||
public void StopAllBoardEffects()
|
||
{
|
||
StopBoardLightSequence();
|
||
SendToMain(BoardEffectsOffCommand);
|
||
}
|
||
|
||
public void BlinkHat()
|
||
{
|
||
BlinkHat(hatBlinkIntervalSeconds);
|
||
}
|
||
|
||
public void BlinkHat(float intervalSeconds)
|
||
{
|
||
StopHatBlink(false);
|
||
hatIsOn = true;
|
||
hatBlinkCoroutine = StartCoroutine(HatBlinkCoroutine(Mathf.Max(0.05f, intervalSeconds)));
|
||
}
|
||
|
||
public void StopHatBlink(bool leaveHatEnabled = true)
|
||
{
|
||
if (hatBlinkCoroutine != null)
|
||
{
|
||
StopCoroutine(hatBlinkCoroutine);
|
||
hatBlinkCoroutine = null;
|
||
}
|
||
|
||
hatIsOn = leaveHatEnabled;
|
||
SendToMain(leaveHatEnabled ? hatOnCommand : hatOffCommand);
|
||
}
|
||
|
||
public void PauseLighting()
|
||
{
|
||
if (hatBlinkCoroutine != null)
|
||
{
|
||
hatBlinkWasPaused = true;
|
||
StopCoroutine(hatBlinkCoroutine);
|
||
hatBlinkCoroutine = null;
|
||
}
|
||
else
|
||
{
|
||
hatBlinkWasPaused = false;
|
||
}
|
||
|
||
if (lightSequenceCoroutine != null)
|
||
{
|
||
StopCoroutine(lightSequenceCoroutine);
|
||
lightSequenceCoroutine = null;
|
||
pausedLightSequence = new LightSequenceState(true);
|
||
}
|
||
}
|
||
|
||
public void ResumeLighting()
|
||
{
|
||
if (hatBlinkWasPaused)
|
||
{
|
||
hatBlinkWasPaused = false;
|
||
BlinkHat(hatBlinkIntervalSeconds);
|
||
}
|
||
else if (hatIsOn)
|
||
{
|
||
SendToMain(hatOnCommand);
|
||
}
|
||
|
||
if (pausedLightSequence.HasPausedSequence)
|
||
{
|
||
PlayDefaultBoardLight();
|
||
}
|
||
}
|
||
|
||
private async Task<PrizeOpenResult> 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(prizeStandDeviceId))
|
||
{
|
||
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<PrizeBoxStatus> ReadPrizeBoxStatusAsync(
|
||
int boxNumber,
|
||
float timeoutSeconds,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ClearPrizeStatusQueue();
|
||
if (!SendToPrizeStand(RequestStandStatusCommand))
|
||
{
|
||
return PrizeBoxStatus.NotFound;
|
||
}
|
||
|
||
return await WaitPrizeBoxStatusAsync(boxNumber, null, timeoutSeconds, cancellationToken);
|
||
}
|
||
|
||
private async Task<PrizeBoxStatus> 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 void 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;
|
||
}
|
||
|
||
lock (devicesLock)
|
||
{
|
||
devices[device.DeviceId] = device;
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
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 = devices.Keys.FirstOrDefault() ?? "";
|
||
}
|
||
}
|
||
|
||
if (device.DeviceId == prizeStandDeviceId)
|
||
{
|
||
prizeStandDeviceId = "";
|
||
}
|
||
|
||
device.Dispose(false);
|
||
|
||
mainThreadQueue.Enqueue(() => deviceDisconnected.Invoke(device.DeviceId));
|
||
}
|
||
|
||
private void OnCoinAccepted(string deviceId)
|
||
{
|
||
if (deviceId == prizeStandDeviceId)
|
||
{
|
||
return;
|
||
}
|
||
|
||
coinQueue.Enqueue(deviceId);
|
||
}
|
||
|
||
private void OnInputReceived(ComInputMessage message)
|
||
{
|
||
if (message.DeviceId == prizeStandDeviceId)
|
||
{
|
||
return;
|
||
}
|
||
|
||
inputQueue.Enqueue(message);
|
||
}
|
||
|
||
private void OnRawLineReceived(string deviceId, string line)
|
||
{
|
||
if (deviceId == prizeStandDeviceId && TryParsePrizeBoxStatus(line, out PrizeBoxStatusEntry status))
|
||
{
|
||
prizeStatusQueue.Enqueue(status);
|
||
}
|
||
}
|
||
|
||
private void OnHopperSensorTriggered(string deviceId)
|
||
{
|
||
if (deviceId == mainDeviceId)
|
||
{
|
||
mainThreadQueue.Enqueue(() => hopperSensorTriggered.Invoke());
|
||
}
|
||
}
|
||
|
||
private string[] SelectPorts(string[] availablePorts)
|
||
{
|
||
if (availablePorts == null || availablePorts.Length == 0)
|
||
{
|
||
return Array.Empty<string>();
|
||
}
|
||
|
||
if (useAllDetectedPorts)
|
||
{
|
||
return availablePorts.OrderBy(port => port, StringComparer.OrdinalIgnoreCase).ToArray();
|
||
}
|
||
|
||
HashSet<string> explicitSet = new(
|
||
explicitPorts.Where(port => !string.IsNullOrWhiteSpace(port)),
|
||
StringComparer.OrdinalIgnoreCase);
|
||
|
||
return availablePorts.Where(explicitSet.Contains).ToArray();
|
||
}
|
||
|
||
private bool IsPrizeStandRole(string role)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(role))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
string[] roles = prizeStandRoles == null || prizeStandRoles.Length == 0 ? DefaultPrizeStandRoles : prizeStandRoles;
|
||
return roles.Any(candidate => IsRole(role, candidate));
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private void ClearQueues()
|
||
{
|
||
while (mainThreadQueue.TryDequeue(out _)) { }
|
||
while (coinQueue.TryDequeue(out _)) { }
|
||
while (inputQueue.TryDequeue(out _)) { }
|
||
ClearPrizeStatusQueue();
|
||
}
|
||
|
||
private void ClearPrizeStatusQueue()
|
||
{
|
||
while (prizeStatusQueue.TryDequeue(out _)) { }
|
||
}
|
||
|
||
private IEnumerator HatBlinkCoroutine(float intervalSeconds)
|
||
{
|
||
while (true)
|
||
{
|
||
SendToMain(Random.Range(0f, 1f) < 0.5f ? hatOnCommand : hatOffCommand);
|
||
|
||
float wait = hatBlinkVarianceSeconds > 0f
|
||
? Mathf.Max(0.05f, intervalSeconds + Random.Range(-hatBlinkVarianceSeconds, hatBlinkVarianceSeconds))
|
||
: intervalSeconds;
|
||
|
||
yield return new WaitForSecondsRealtime(wait);
|
||
}
|
||
}
|
||
|
||
private IEnumerator BoardLightSequenceCoroutine(IReadOnlyList<LightStep> steps, bool loop)
|
||
{
|
||
do
|
||
{
|
||
foreach (LightStep step in steps)
|
||
{
|
||
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);
|
||
yield return new WaitForSecondsRealtime(chunk);
|
||
elapsed += chunk;
|
||
|
||
if (elapsed < waitSeconds)
|
||
{
|
||
int remainSeconds = Mathf.CeilToInt(waitSeconds - elapsed);
|
||
SendToMain(BuildBoardLightCommand(step.Mode, remainSeconds));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
while (loop);
|
||
|
||
lightSequenceCoroutine = null;
|
||
}
|
||
|
||
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}";
|
||
}
|
||
|
||
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<bool> handshakeCompletion;
|
||
private TaskCompletionSource<bool> chalCompletion;
|
||
private TaskCompletionSource<string> roleCompletion;
|
||
private TaskCompletionSource<string> 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;
|
||
|
||
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; }
|
||
public string PortName { get; }
|
||
public string Role { get; private set; } = "";
|
||
public string MacAddress { get; private set; } = "";
|
||
public string LastInputBits { get; private set; } = DefaultInputBits;
|
||
|
||
public event Action<ComDeviceConnection> Authorized;
|
||
public event Action<ComDeviceConnection> Disconnected;
|
||
public event Action<string> CoinAccepted;
|
||
public event Action<ComInputMessage> InputReceived;
|
||
public event Action<string, string> RawLineReceived;
|
||
public event Action<string> HopperSensorTriggered;
|
||
|
||
public bool Start(CancellationToken token)
|
||
{
|
||
parentToken = token;
|
||
localCts = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||
|
||
if (!OpenPort())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
readTask = Task.Run(() => ReadLoopAsync(localCts.Token), localCts.Token);
|
||
|
||
if (useEncryptedHandshake)
|
||
{
|
||
_ = Task.Run(() => RunHandshakeAsync(localCts.Token), localCts.Token);
|
||
}
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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}");
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private async Task RunHandshakeAsync(CancellationToken token)
|
||
{
|
||
handshakeStage = HandshakeStage.WaitingChal;
|
||
handshakeCompletion = NewCompletion<bool>();
|
||
chalCompletion = NewCompletion<bool>();
|
||
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);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
private void MarkOperational()
|
||
{
|
||
isOperational = true;
|
||
handshakeStage = HandshakeStage.Completed;
|
||
missedInputResponses = 0;
|
||
|
||
Send("2171", true);
|
||
Send("890003", true);
|
||
Send("8600", true);
|
||
|
||
if (keepAlive)
|
||
{
|
||
keepAliveTask = Task.Run(() => KeepAliveLoopAsync(localCts.Token), localCts.Token);
|
||
}
|
||
|
||
_ = Task.Run(() => ResolveIdentityAsync(localCts.Token), localCts.Token);
|
||
}
|
||
|
||
private async Task ResolveIdentityAsync(CancellationToken token)
|
||
{
|
||
try
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(roleRequestCommand))
|
||
{
|
||
roleCompletion = NewCompletion<string>();
|
||
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<string>();
|
||
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}");
|
||
}
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
private void HandleCoin()
|
||
{
|
||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
if (now - lastCoinTime < CoinDebounceMs)
|
||
{
|
||
return;
|
||
}
|
||
|
||
lastCoinTime = now;
|
||
Send("10", true);
|
||
CoinAccepted?.Invoke(DeviceId);
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
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<T> NewCompletion<T>()
|
||
{
|
||
return new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
private static string GenerateRandomBlock16Hex()
|
||
{
|
||
byte[] buffer = new byte[16];
|
||
RandomNumberGenerator.Fill(buffer);
|
||
return BytesToHex(buffer);
|
||
}
|
||
|
||
private static byte[] HexToBytes(string hex)
|
||
{
|
||
if (hex == null)
|
||
{
|
||
throw new ArgumentNullException(nameof(hex));
|
||
}
|
||
|
||
hex = hex.Trim();
|
||
if (hex.Length % 2 != 0)
|
||
{
|
||
throw new InvalidOperationException("Hex string length must be even.");
|
||
}
|
||
|
||
byte[] result = new byte[hex.Length / 2];
|
||
for (int i = 0; i < result.Length; i++)
|
||
{
|
||
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static string BytesToHex(byte[] bytes)
|
||
{
|
||
StringBuilder builder = new(bytes.Length * 2);
|
||
for (int i = 0; i < bytes.Length; i++)
|
||
{
|
||
builder.AppendFormat("{0:X2}", bytes[i]);
|
||
}
|
||
|
||
return builder.ToString();
|
||
}
|
||
|
||
private static bool IsHexString(string value, int expectedLength)
|
||
{
|
||
return !string.IsNullOrWhiteSpace(value) && value.Length == expectedLength && IsHexString(value);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|