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(); [SerializeField] private string preferredMainPort = ""; [Header("Serial settings")] [SerializeField] private int baudRate = 115200; [SerializeField] private int dataBits = 8; [SerializeField] private SerialParity parity = SerialParity.None; [SerializeField] private SerialStopBits stopBits = SerialStopBits.One; [SerializeField] private int readTimeoutMs = 50; [SerializeField] private int writeTimeoutMs = 100; [Header("Authorization")] [SerializeField] private bool useEncryptedHandshake = true; [SerializeField] private int handshakeTimeoutMs = 5000; [SerializeField] private string roleRequestCommand = "97"; [SerializeField] private string roleResponsePrefix = "ROLE"; [SerializeField] private int roleRequestTimeoutMs = 1500; [SerializeField] private string mainControllerRole = "main_controller"; [Header("Keep alive")] [SerializeField] private bool keepAlive = true; [SerializeField] private float keepAliveIntervalSeconds = 8f; [SerializeField] private int missedInputResponsesBeforeReconnect = 3; [Header("Credits")] [SerializeField] private bool saveCreditsBetweenSessions; [SerializeField] private string creditsPlayerPrefsKey = "Com.Credits"; [Header("Prize showcase")] [SerializeField] private string[] prizeStandRoles = DefaultPrizeStandRoles; [SerializeField] [Min(0)] private int prizeStandBoxesCount = 24; [SerializeField] private float prizeStatusTimeoutSeconds = 3f; [SerializeField] private float prizeOpenTimeoutSeconds = 5f; [Header("Lighting")] [SerializeField] private string hatOnCommand = "2131"; [SerializeField] private string hatOffCommand = "2130"; [SerializeField] [Range(0.05f, 5f)] private float hatBlinkIntervalSeconds = 0.5f; [SerializeField] [Range(0f, 2f)] private float hatBlinkVarianceSeconds; [SerializeField] private LightMode defaultLightMode = LightMode.Rainbow; [SerializeField] private float defaultLightCycles = 1f; [Header("Events")] [SerializeField] private UnityEvent creditAdded = new(); [SerializeField] private UnityEvent creditRemoved = new(); [SerializeField] private UnityEvent creditsChanged = new(); [SerializeField] private UnityEvent mainInputReceived = new(); [SerializeField] private UnityEvent deviceAuthorized = new(); [SerializeField] private UnityEvent deviceDisconnected = new(); [SerializeField] private UnityEvent prizeBoxOpened = new(); [SerializeField] private UnityEvent hopperSensorTriggered = new(); private readonly Dictionary devices = new(StringComparer.OrdinalIgnoreCase); private readonly object devicesLock = new(); private readonly ConcurrentQueue mainThreadQueue = new(); private readonly ConcurrentQueue coinQueue = new(); private readonly ConcurrentQueue inputQueue = new(); private readonly ConcurrentQueue prizeStatusQueue = new(); private readonly SemaphoreSlim prizeOperationLock = new(1, 1); private IComPortProvider portProvider; private IComTransportFactory transportFactory; private CancellationTokenSource systemCts; private 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 CreditAdded => creditAdded; public UnityEvent CreditRemoved => creditRemoved; public UnityEvent CreditsChanged => creditsChanged; public UnityEvent MainInputReceived => mainInputReceived; public UnityEvent DeviceAuthorized => deviceAuthorized; public UnityEvent DeviceDisconnected => deviceDisconnected; public UnityEvent 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 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> GetPrizeBoxStatusesAsync(CancellationToken cancellationToken = default) { if (prizeStandBoxesCount <= 0 || string.IsNullOrWhiteSpace(prizeStandDeviceId)) { return new Dictionary(); } ClearPrizeStatusQueue(); if (!SendToPrizeStand(RequestStandStatusCommand)) { return new Dictionary(); } Dictionary statuses = new(); Stopwatch stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed.TotalSeconds < prizeStatusTimeoutSeconds && statuses.Count < prizeStandBoxesCount) { cancellationToken.ThrowIfCancellationRequested(); while (prizeStatusQueue.TryDequeue(out PrizeBoxStatusEntry entry)) { statuses[entry.BoxNumber] = entry.Status; if (statuses.Count >= prizeStandBoxesCount) { break; } } await Task.Delay(10, cancellationToken); } return statuses; } public async Task GetReadyPrizeBoxesAsync(CancellationToken cancellationToken = default) { IReadOnlyDictionary statuses = await GetPrizeBoxStatusesAsync(cancellationToken); return statuses .Where(pair => IsReadyToOpen(pair.Value)) .Select(pair => pair.Key) .OrderBy(boxNumber => boxNumber) .ToArray(); } public async Task OpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default) { return await OpenPrizeBoxInternalAsync(boxNumber, false, cancellationToken); } public async Task ForceOpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default) { return await OpenPrizeBoxInternalAsync(boxNumber, true, cancellationToken); } public bool SetPrizeStandIdleLighting() { return SendToPrizeStand(PrizeStandIdleLightingCommand); } public bool HighlightPrizeBox(int boxNumber) { return boxNumber > 0 && SendToPrizeStand($"{PrizeStandHighlightBoxCommandPrefix}{boxNumber:D2}"); } public void PlayBoardLight(LightMode mode, float cycles = -1f) { StopBoardLightSequence(); float resolvedCycles = cycles > 0f ? cycles : defaultLightCycles; lightSequenceCoroutine = StartCoroutine(BoardLightSequenceCoroutine(new[] { new LightStep(mode, resolvedCycles) }, false)); } public void PlayBoardLightSequence(IReadOnlyList 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 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 ReadPrizeBoxStatusAsync( int boxNumber, float timeoutSeconds, CancellationToken cancellationToken) { ClearPrizeStatusQueue(); if (!SendToPrizeStand(RequestStandStatusCommand)) { return PrizeBoxStatus.NotFound; } return await WaitPrizeBoxStatusAsync(boxNumber, null, timeoutSeconds, cancellationToken); } private async Task WaitPrizeBoxStatusAsync( int boxNumber, PrizeBoxStatus? expectedStatus, float timeoutSeconds, CancellationToken cancellationToken) { PrizeBoxStatus lastStatus = PrizeBoxStatus.NotFound; Stopwatch stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed.TotalSeconds < timeoutSeconds) { cancellationToken.ThrowIfCancellationRequested(); while (prizeStatusQueue.TryDequeue(out PrizeBoxStatusEntry entry)) { if (entry.BoxNumber != boxNumber) { continue; } lastStatus = entry.Status; if (!expectedStatus.HasValue || entry.Status == expectedStatus.Value) { return entry.Status; } } await Task.Delay(10, cancellationToken); } return lastStatus; } private 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(); } if (useAllDetectedPorts) { return availablePorts.OrderBy(port => port, StringComparer.OrdinalIgnoreCase).ToArray(); } HashSet 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 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 handshakeCompletion; private TaskCompletionSource chalCompletion; private TaskCompletionSource roleCompletion; private TaskCompletionSource macCompletion; private HandshakeStage handshakeStage = HandshakeStage.None; private string requestHex = ""; private string lastHandshakeResponse = ""; private long lastCoinTime; private int inputReceivedInCycle; private int missedInputResponses; private bool isOperational; private bool disposed; 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 Authorized; public event Action Disconnected; public event Action CoinAccepted; public event Action InputReceived; public event Action RawLineReceived; public event Action HopperSensorTriggered; public bool Start(CancellationToken token) { parentToken = token; localCts = CancellationTokenSource.CreateLinkedTokenSource(token); if (!OpenPort()) { return false; } readTask = 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(); chalCompletion = NewCompletion(); requestHex = GenerateRandomBlock16Hex(); Send("07" + requestHex, true); Task chalTimeout = Task.Delay(Mathf.Min(1200, Mathf.Max(250, handshakeTimeoutMs / 4)), token); Task chalFinished = await Task.WhenAny(chalCompletion.Task, chalTimeout); if (chalFinished != chalCompletion.Task) { FailHandshake("No CHAL response."); return; } Task timeout = Task.Delay(handshakeTimeoutMs, token); Task finished = await Task.WhenAny(handshakeCompletion.Task, timeout); if (finished == handshakeCompletion.Task && handshakeCompletion.Task.Result) { MarkOperational(); return; } FailHandshake($"Handshake timeout. Last response: {lastHandshakeResponse}"); } private void ProcessHandshakeLine(string line) { lastHandshakeResponse = line; if (handshakeStage == HandshakeStage.WaitingChal && line.StartsWith("CHAL", StringComparison.Ordinal)) { HandleChal(line); return; } if (handshakeStage == HandshakeStage.WaitingRaes && line.StartsWith("RAES", StringComparison.Ordinal)) { HandleRaes(line); } } 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(); Send(roleRequestCommand); Task timeout = Task.Delay(roleRequestTimeoutMs, token); Task finished = await Task.WhenAny(roleCompletion.Task, timeout); if (finished == roleCompletion.Task && roleCompletion.Task.IsCompletedSuccessfully) { Role = roleCompletion.Task.Result; } } macCompletion = NewCompletion(); Send("06"); Task macTimeout = Task.Delay(roleRequestTimeoutMs, token); Task macFinished = await Task.WhenAny(macCompletion.Task, macTimeout); if (macFinished == macCompletion.Task && macCompletion.Task.IsCompletedSuccessfully) { MacAddress = macCompletion.Task.Result; } } catch (OperationCanceledException) { return; } finally { Authorized?.Invoke(this); } } private async Task KeepAliveLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromSeconds(keepAliveIntervalSeconds), token); inputReceivedInCycle = 0; Send(RequestInputCommand); await Task.Delay(1000, token); if (inputReceivedInCycle == 0) { missedInputResponses++; if (missedInputResponses >= missedInputResponsesBeforeReconnect) { await ReconnectAsync(token); } } else { missedInputResponses = 0; } } catch (OperationCanceledException) { break; } catch (Exception exception) { UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} keep alive error: {exception.Message}"); } } } 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 NewCompletion() { return new TaskCompletionSource(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; } } } }