diff --git a/Src/CortexClient.cs b/Src/CortexClient.cs index 1855dc8..c474559 100644 --- a/Src/CortexClient.cs +++ b/Src/CortexClient.cs @@ -69,6 +69,8 @@ public abstract class CortexClient public event EventHandler UserLogoutNotify; public event EventHandler GetLicenseInfoDone; public event EventHandler<(CortexErrorCode error, License data)> GetLicenseInfoResult; + public event EventHandler<(CortexErrorCode error, string command)> ControlDeviceResult; + public event EventHandler<(CortexErrorCode error, List data)> QueryHeadsetResult; public event EventHandler CreateSessionOK; public event EventHandler UpdateSessionOK; public event EventHandler SubscribeDataDone; @@ -194,6 +196,15 @@ public void OnMessageReceived(string receievedMsg) { GetLicenseInfoResult?.Invoke(this, (CortexErrorCode.UnknownError, null)); } + else if (method == "queryHeadsets") + { + QueryHeadsetResult?.Invoke(this, (CortexErrorCode.UnknownError, null)); + } + else if (method == "controlDevice") + { + string command = (string)error["command"]; + ControlDeviceResult?.Invoke(this, (CortexErrorCode.UnknownError, command)); + } } else { // handle response @@ -278,7 +289,8 @@ private void HandleResponse(string method, JToken data) foreach (JObject item in data) { headsetLists.Add(new Headset(item)); } - QueryHeadsetOK(this, headsetLists); + // QueryHeadsetOK(this, headsetLists); + QueryHeadsetResult?.Invoke(this, (CortexErrorCode.OK, headsetLists)); } else if (method == "controlDevice") { @@ -287,6 +299,10 @@ private void HandleResponse(string method, JToken data) { HeadsetDisConnectedOK(this, true); } + else if (command == "refresh") + { + ControlDeviceResult?.Invoke(this, (CortexErrorCode.OK, command)); + } } else if (method == "getUserLogin") { @@ -778,14 +794,19 @@ public void ControlDevice(string command, string headsetId, JObject mappings) } // CreateSession - // Required params: cortexToken, status - public void CreateSession(string cortexToken, string headsetId, string status) + // Required params: status + public void CreateSession(string headsetId, string status = "active") { JObject param = new JObject(); if (!String.IsNullOrEmpty(headsetId)) { param.Add("headset", headsetId); } - param.Add("cortexToken", cortexToken); + if (string.IsNullOrEmpty(CurrentCortexToken)) + { + UnityEngine.Debug.LogWarning("CreateSession requested but no cortex token is available."); + return; + } + param.Add("cortexToken", CurrentCortexToken); param.Add("status", status); SendTextMessage(param, "createSession", true); } diff --git a/Src/Runtime/Models/CortexErrorCode.cs b/Src/Runtime/Models/CortexErrorCode.cs index 9ce8022..65e0cff 100644 --- a/Src/Runtime/Models/CortexErrorCode.cs +++ b/Src/Runtime/Models/CortexErrorCode.cs @@ -6,6 +6,8 @@ public enum CortexErrorCode NoUserLogin, // Indicates that there is no user logged in. AuthorizationFailed, // Indicates that the authorization process failed overall. LicenseError, // Indicates that there is an issue with the license (e.g., invalid or expired license). + HeadsetNotFound, // Indicates that the specified headset could not be found. + SubscriptionFailed, // Indicates that the subscription process failed. UnknownError } } diff --git a/Src/Runtime/Models/CortexRuntimeContext.cs b/Src/Runtime/Models/CortexRuntimeContext.cs index b14a2f1..902bcbf 100644 --- a/Src/Runtime/Models/CortexRuntimeContext.cs +++ b/Src/Runtime/Models/CortexRuntimeContext.cs @@ -1,4 +1,6 @@ +using System; using EmotivUnityPlugin; // TODO (Tung Nguyen): remove this line when move all old code to new SDK +using System.Collections.Generic; namespace Emotiv.Cortex.Models { public sealed class CortexRuntimeContext @@ -51,5 +53,53 @@ public void SetConnectionStatus(bool isConnected) } } + // ------------------------ + // Headsets + // ------------------------ + private List _headsets = new List(); + public List Headsets + { + get + { + lock (_lock) + { + return new List(_headsets); + } + } + } + + public void SetHeadsets(List headsets) + { + lock (_lock) + { + _headsets.Clear(); + if (headsets != null) + { + _headsets.AddRange(headsets); + } + } + } + + // Map headset id and session info + private Dictionary _sessionInfoByHeadsetId = new Dictionary(StringComparer.Ordinal); + public SessionInfo GetSessionInfoByHeadsetId(string headsetId) + { + lock (_lock) + { + if (_sessionInfoByHeadsetId.TryGetValue(headsetId, out var sessionInfo)) + { + return sessionInfo; + } + return null; + } + } + + public void SetSessionInfo(string headsetId, SessionInfo sessionInfo) + { + lock (_lock) + { + _sessionInfoByHeadsetId[headsetId] = sessionInfo; + } + } } } \ No newline at end of file diff --git a/Src/Runtime/Models/DataSamples.cs b/Src/Runtime/Models/DataSamples.cs new file mode 100644 index 0000000..52e255e --- /dev/null +++ b/Src/Runtime/Models/DataSamples.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace Emotiv.Cortex.Models +{ + public enum DataSampleType + { + CQ, + MentalCommand + } + + public interface IDataSample + { + DataSampleType Type { get; } + double Timestamp { get; } + } + + public class CQDataSample : IDataSample + { + public CQDataSample(double timestamp, IReadOnlyDictionary values) + { + Timestamp = timestamp; + Values = values ?? new Dictionary(); + } + + public DataSampleType Type => DataSampleType.CQ; + public double Timestamp { get; } + public IReadOnlyDictionary Values { get; } + } + + public class MentalCommandDataSample : IDataSample + { + public MentalCommandDataSample(double timestamp, string action, float power) + { + Timestamp = timestamp; + Action = action ?? string.Empty; + Power = power; + } + + public DataSampleType Type => DataSampleType.MentalCommand; + public double Timestamp { get; } + public string Action { get; } + public float Power { get; } + } +} diff --git a/Src/Runtime/Models/SessionInfo.cs b/Src/Runtime/Models/SessionInfo.cs new file mode 100644 index 0000000..9b8b5bd --- /dev/null +++ b/Src/Runtime/Models/SessionInfo.cs @@ -0,0 +1,43 @@ +using EmotivUnityPlugin; + +namespace Emotiv.Cortex.Models +{ + public enum SessionStatus + { + Opened = 0, + Activated = 1, + Closed = 2 + } + + public sealed class SessionInfo + { + public string SessionId { get; private set; } + public SessionStatus Status { get; private set; } + public string ApplicationId { get; private set; } + public string HeadsetId { get; private set; } + + public static SessionInfo FromSessionEventArgs(SessionEventArgs sessionInfo) + { + return new SessionInfo + { + SessionId = sessionInfo.SessionId, + Status = MapStatus(sessionInfo.Status), + ApplicationId = sessionInfo.ApplicationId, + HeadsetId = sessionInfo.HeadsetId + }; + } + + private static SessionStatus MapStatus(EmotivUnityPlugin.SessionStatus status) + { + switch (status) + { + case EmotivUnityPlugin.SessionStatus.Opened: + return SessionStatus.Opened; + case EmotivUnityPlugin.SessionStatus.Activated: + return SessionStatus.Activated; + default: + return SessionStatus.Closed; + } + } + } +} diff --git a/Src/Runtime/SDK/EmotivCortexRuntime.cs b/Src/Runtime/SDK/EmotivCortexRuntime.cs index e0a5f41..dfec828 100644 --- a/Src/Runtime/SDK/EmotivCortexRuntime.cs +++ b/Src/Runtime/SDK/EmotivCortexRuntime.cs @@ -10,6 +10,7 @@ public class EmotivCortexRuntime : IEmotivCortexRuntime private readonly CortexRuntimeContext _context; private CortexClient _client; private IAuthService _auth; + private IHeadsetService _headset; public EmotivCortexRuntime() { @@ -43,6 +44,9 @@ private void OnCortexConnectionStared(object sender, bool isConnected) } public IAuthService Auth => _auth ??= new AuthService(_context, _client); + + public IHeadsetService Headset + => _headset ??= new HeadsetService(_context, _client); public void Dispose() { diff --git a/Src/Runtime/SDK/Headset/HeadsetService.cs b/Src/Runtime/SDK/Headset/HeadsetService.cs new file mode 100644 index 0000000..b592bb4 --- /dev/null +++ b/Src/Runtime/SDK/Headset/HeadsetService.cs @@ -0,0 +1,541 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Emotiv.Cortex.Models; +using EmotivUnityPlugin; +using Newtonsoft.Json.Linq; + +namespace Emotiv.Cortex.Service +{ + public class HeadsetService : IHeadsetService + { + private readonly CortexRuntimeContext _context; + private readonly CortexClient _client; + private volatile bool _refreshAndQueryInProgress; + private readonly object _sampleLock = new object(); + private readonly Dictionary _latestSamples = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _streamHeaders = new Dictionary>(StringComparer.Ordinal); + private bool _streamDataHooked; + + public HeadsetService(CortexRuntimeContext context, CortexClient client) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _client = client ?? throw new ArgumentNullException(nameof(client)); + _client.HeadsetScanFinished += OnHeadsetScanFinished; + HookStreamData(); + } + + public async Task ScanHeadsetAsync() + { + var queryCode = await RefreshAndQueryAsync(); + if (queryCode != CortexErrorCode.OK) + { + return CortexResult.Fail(CortexErrorMapper.FromErrorCode(queryCode)); + } + + return CortexResult.Success(); + } + + public List GetHeadsets() + { + return _context.Headsets; + } + + public bool TakeLatestSample(string stream, out IDataSample sample) + { + sample = null; + if (string.IsNullOrWhiteSpace(stream)) + { + return false; + } + + lock (_sampleLock) + { + return _latestSamples.TryGetValue(stream, out sample) && sample != null; + } + } + + private void OnHeadsetScanFinished(object sender, string message) + { + UnityEngine.Debug.Log("Headset scan finished with message: " + message); + _ = RefreshAndQueryAsync(); + } + + private async Task RefreshAndQueryAsync() + { + if (_refreshAndQueryInProgress) + { + return CortexErrorCode.UnknownError; + } + + _refreshAndQueryInProgress = true; + try + { + var refreshTcs = new TaskCompletionSource(); + EventHandler<(CortexErrorCode error, string command)> refreshResultHandler = null; + + refreshResultHandler = (sender, result) => + { + if (result.command != "refresh") + { + return; + } + CleanupRefresh(); + refreshTcs.TrySetResult(result.error); + }; + + void CleanupRefresh() + { + _client.ControlDeviceResult -= refreshResultHandler; + } + + _client.ControlDeviceResult += refreshResultHandler; + RefreshHeadset(); + + var refreshCode = await refreshTcs.Task; + + if (refreshCode != CortexErrorCode.OK) + { + return refreshCode; + } + + var queryTcs = new TaskCompletionSource(); + EventHandler<(CortexErrorCode error, List data)> queryResultHandler = null; + + queryResultHandler = (sender, result) => + { + CleanupQuery(); + if (result.error != CortexErrorCode.OK) + { + queryTcs.TrySetResult(result.error); + return; + } + + if (result.data != null) + { + _context.SetHeadsets(result.data); + } + else + { + _context.SetHeadsets(null); + } + queryTcs.TrySetResult(CortexErrorCode.OK); + }; + + void CleanupQuery() + { + _client.QueryHeadsetResult -= queryResultHandler; + } + + _client.QueryHeadsetResult += queryResultHandler; + QueryHeadsets(); + + return await queryTcs.Task; + } + finally + { + _refreshAndQueryInProgress = false; + } + } + + + public async Task> ConnectHeadsetAsync( + string headsetId, + Dictionary mappings = null, + IReadOnlyList streams = null) + { + // check headset exists in the list + var headset = _context.Headsets.FirstOrDefault(h => string.Equals(h.HeadsetID, headsetId, StringComparison.Ordinal)); + if (headset == null) + { + return CortexResult.Fail(CortexErrorMapper.FromErrorCode(CortexErrorCode.HeadsetNotFound)); + } + + var connectCode = await ConnectHeadsetAsync(headsetId, mappings); + if (connectCode != CortexErrorCode.OK) + { + return CortexResult.Fail(CortexErrorMapper.FromErrorCode(connectCode)); + } + + var sessionResult = await CreateSessionAsync(headsetId); + if (sessionResult.Code != CortexErrorCode.OK) + { + return CortexResult.Fail(CortexErrorMapper.FromErrorCode(sessionResult.Code)); + } + + if (streams != null && streams.Count > 0) + { + var subscribeResult = await SubscribeAsync(sessionResult.Data.SessionId, streams); + if (subscribeResult.IsSuccess) + { + return CortexResult.Success(sessionResult.Data); + } + else { + return CortexResult.Fail(subscribeResult.Error); + } + + } + + return CortexResult.Success(sessionResult.Data); + } + + public async Task DisconnectHeadsetAsync(string headsetId) + { + if (string.IsNullOrWhiteSpace(headsetId)) + { + return CortexResult.Fail(CortexErrorMapper.FromErrorCode(CortexErrorCode.UnknownError)); + } + + var tcs = new TaskCompletionSource(); + EventHandler<(CortexErrorCode error, string command)> disconnectHandler = null; + + disconnectHandler = (sender, result) => + { + if (result.command != "disconnect") + { + return; + } + Cleanup(); + tcs.TrySetResult(result.error); + }; + + void Cleanup() + { + _client.ControlDeviceResult -= disconnectHandler; + } + + _client.ControlDeviceResult += disconnectHandler; + _client.ControlDevice("disconnect", headsetId, null); + + var code = await tcs.Task; + return code == CortexErrorCode.OK + ? CortexResult.Success() + : CortexResult.Fail(CortexErrorMapper.FromErrorCode(code)); + } + + private async Task ConnectHeadsetAsync(string headsetId, Dictionary mappings) + { + var tcs = new TaskCompletionSource(); + EventHandler<(CortexErrorCode error, string command)> connectHandler = null; + + connectHandler = (sender, result) => + { + if (result.command != "connect") + { + return; + } + Cleanup(); + tcs.TrySetResult(result.error); + }; + + void Cleanup() + { + _client.ControlDeviceResult -= connectHandler; + } + + _client.ControlDeviceResult += connectHandler; + + _client.ControlDevice("connect", headsetId, ToMappings(mappings)); + return await tcs.Task; + } + + private async Task<(CortexErrorCode Code, SessionInfo Data)> CreateSessionAsync(string headsetId) + { + var tcs = new TaskCompletionSource<(CortexErrorCode Code, SessionInfo Data)>(); + EventHandler okHandler = null; + EventHandler errorHandler = null; + + okHandler = (sender, sessionInfo) => + { + if (!string.Equals(sessionInfo.HeadsetId, headsetId, StringComparison.Ordinal)) + { + return; + } + Cleanup(); + tcs.TrySetResult((CortexErrorCode.OK, SessionInfo.FromSessionEventArgs(sessionInfo))); + }; + + errorHandler = (sender, error) => + { + if (error.MethodName != "createSession") + { + return; + } + Cleanup(); + tcs.TrySetResult((CortexErrorCode.UnknownError, null)); + }; + + void Cleanup() + { + _client.CreateSessionOK -= okHandler; + _client.ErrorMsgReceived -= errorHandler; + } + + _client.CreateSessionOK += okHandler; + _client.ErrorMsgReceived += errorHandler; + _client.CreateSession(headsetId); + + return await tcs.Task; + } + + private async Task SubscribeAsync(string sessionId, IReadOnlyList streams) + { + if (string.IsNullOrEmpty(sessionId)) + { + return CortexResult.Fail(CortexErrorMapper.FromErrorCode(CortexErrorCode.SubscriptionFailed)); + } + + var tcs = new TaskCompletionSource(); + EventHandler okHandler = null; + EventHandler errorHandler = null; + + okHandler = (sender, result) => + { + CacheStreamHeaders(result); + Cleanup(); + var hasSuccess = result.SuccessList != null && result.SuccessList.Count > 0; + tcs.TrySetResult(hasSuccess ? CortexResult.Success() : CortexResult.Fail(CortexErrorMapper.FromErrorCode(CortexErrorCode.SubscriptionFailed))); + }; + + errorHandler = (sender, error) => + { + if (error.MethodName != "subscribe") + { + return; + } + Cleanup(); + var cortexError = new CortexError((CortexErrorCode)error.Code, error.MessageError); + tcs.TrySetResult(CortexResult.Fail(cortexError)); + }; + + void Cleanup() + { + _client.SubscribeDataDone -= okHandler; + _client.ErrorMsgReceived -= errorHandler; + } + + _client.SubscribeDataDone += okHandler; + _client.ErrorMsgReceived += errorHandler; + _client.Subscribe(_client.CurrentCortexToken, sessionId, streams.ToList()); + + return await tcs.Task; + } + + private void HookStreamData() + { + if (_streamDataHooked) + { + return; + } + + _client.StreamDataReceived += OnStreamDataReceived; + _streamDataHooked = true; + } + + private void OnStreamDataReceived(object sender, StreamDataEventArgs e) + { + if (e == null || string.IsNullOrEmpty(e.StreamName)) + { + return; + } + + switch (e.StreamName) + { + case DataStreamName.DevInfos: + if (TryBuildCQSample(e, out var cqSample)) + { + SetLatestSample(DataStreamName.DevInfos, cqSample); + } + break; + case DataStreamName.MentalCommands: + if (TryBuildMentalCommandSample(e, out var comSample)) + { + SetLatestSample(DataStreamName.MentalCommands, comSample); + } + break; + } + } + + private void SetLatestSample(string stream, IDataSample sample) + { + if (sample == null || string.IsNullOrEmpty(stream)) + { + return; + } + lock (_sampleLock) + { + _latestSamples[stream] = sample; + } + } + + private bool TryBuildCQSample(StreamDataEventArgs e, out IDataSample sample) + { + sample = null; + if (e.Data == null || e.Data.Count == 0) + { + return false; + } + + if (!TryGetTimestamp(e.Data, out var timestamp)) + { + return false; + } + + IReadOnlyList headers = null; + lock (_sampleLock) + { + _streamHeaders.TryGetValue(DataStreamName.DevInfos, out headers); + } + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (headers != null) + { + for (var i = 0; i < headers.Count && i < e.Data.Count; i++) + { + var key = headers[i]; + var value = Convert.ToSingle(e.Data[i]); + values[key] = value; + } + } + else + { + // throw exception or return false if headers are not available, as we don't know how to parse the data + return false; + } + + sample = new CQDataSample(timestamp, values); + return true; + } + + private bool TryBuildMentalCommandSample(StreamDataEventArgs e, out IDataSample sample) + { + sample = null; + if (e.Data == null || e.Data.Count < 3) + { + return false; + } + + if (!TryGetTimestamp(e.Data, out var timestamp)) + { + return false; + } + + var action = Convert.ToString(e.Data[1]); + var power = Convert.ToSingle(e.Data[2]); + sample = new MentalCommandDataSample(timestamp, action, power); + return true; + } + + private static bool TryGetTimestamp(ArrayList data, out double timestamp) + { + timestamp = 0; + if (data == null || data.Count == 0) + { + return false; + } + + try + { + timestamp = Convert.ToDouble(data[0]); + return true; + } + catch + { + return false; + } + } + + private void CacheStreamHeaders(MultipleResultEventArgs result) + { + if (result?.SuccessList == null || result.SuccessList.Count == 0) + { + return; + } + + lock (_sampleLock) + { + foreach (JObject ele in result.SuccessList) + { + var streamName = (string)ele["streamName"]; + if (string.IsNullOrEmpty(streamName)) + { + continue; + } + + var header = (JArray)ele["cols"]; + if (header == null) + { + continue; + } + + _streamHeaders[streamName] = NormalizeHeaders(streamName, header); + } + } + } + + + private static IReadOnlyList NormalizeHeaders(string streamName, JArray header) + { + UnityEngine.Debug.Log("Normalizing headers for stream: " + streamName + " with header: " + header.Count); + var cols = new List(); + cols.Add("TimeStamp"); + if (streamName == DataStreamName.DevInfos && header.Count >= 2) + { + // the dev infos has data format kind of "Battery", "Signal", ["AF3","T7","Pz","T8","AF4","OVERALL"],"BatteryPercent" + cols.Add(header[0].ToString()); + cols.Add(header[1].ToString()); + + if (header.Count > 2 && header[2] is JArray channelList) + { + for (var i = 0; i < channelList.Count; i++) + { + cols.Add(channelList[i].ToString()); + } + } + + if (header.Count > 3) + { + for (var id = 3; id < header.Count; id++) + { + cols.Add(header[id].ToString()); + } + } + + return cols; + } + + foreach (var token in header) + { + cols.Add(token.ToString()); + } + return cols; + } + + private static JObject ToMappings(Dictionary mappings) + { + if (mappings == null || mappings.Count == 0) + { + return null; + } + + var obj = new JObject(); + foreach (var kvp in mappings) + { + obj[kvp.Key] = kvp.Value; + } + return obj; + } + + private void RefreshHeadset() + { + _client.ControlDevice("refresh", string.Empty, null); + } + + private void QueryHeadsets() + { + _client.QueryHeadsets(string.Empty); + } + } +} diff --git a/Src/Runtime/SDK/Headset/IHeadsetService.cs b/Src/Runtime/SDK/Headset/IHeadsetService.cs new file mode 100644 index 0000000..3b61e1a --- /dev/null +++ b/Src/Runtime/SDK/Headset/IHeadsetService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Emotiv.Cortex.Models; +using EmotivUnityPlugin; + +namespace Emotiv.Cortex.Service +{ + public interface IHeadsetService + { + /// + /// Scans for available headsets and queries their status. Automatically retries the process when warning 142 is received (scanning finish warning). + /// The API only returns success or fail (no data). To get the headset list, use GetHeadsets(). + /// + /// Success or fail result. No headset data returned. + Task ScanHeadsetAsync(); + + /// + /// Retrieves the list of available headsets. This is a synchronous API. + /// + /// List of available headsets. + List GetHeadsets(); + + /// + /// Connects to the headset with the specified headsetId. If not already connected, creates a working session with the headset. + /// If streams is not null, subscribes to the specified data streams (e.g., "eeg", "mot", "dev", ...). + /// + /// The ID of the headset to connect. + /// Optional. Channel mappings for the EPOC FLEX headset only. + /// Optional. List of data streams to subscribe (e.g., "eeg", "mot", "dev"). + /// Session info for the connected headset. + Task> ConnectHeadsetAsync( + string headsetId, + Dictionary mappings = null, + IReadOnlyList streams = null + ); + + /// + /// Disconnects the headset with the specified headsetId. + /// + /// The ID of the headset to disconnect. + /// Success or fail result. + Task DisconnectHeadsetAsync(string headsetId); + + /// + /// Takes the latest sample of the specified subscribed data stream. + /// + /// The name of the data stream (must be subscribed). + /// Output parameter for the latest sample. + /// True if a sample is available; otherwise, false. + bool TakeLatestSample(string stream, out IDataSample sample); + } +} diff --git a/Src/Runtime/SDK/IEmotivCortexRuntime.cs b/Src/Runtime/SDK/IEmotivCortexRuntime.cs index 2b86c33..a4d6008 100644 --- a/Src/Runtime/SDK/IEmotivCortexRuntime.cs +++ b/Src/Runtime/SDK/IEmotivCortexRuntime.cs @@ -5,5 +5,6 @@ namespace Emotiv.Cortex.Service public interface IEmotivCortexRuntime : IDisposable { IAuthService Auth { get; } + IHeadsetService Headset { get; } } } \ No newline at end of file diff --git a/Src/SessionHandler.cs b/Src/SessionHandler.cs index 13c8344..52e1edb 100644 --- a/Src/SessionHandler.cs +++ b/Src/SessionHandler.cs @@ -117,7 +117,7 @@ public void Create(string cortexToken, string headsetId, bool activeSession = fa !String.IsNullOrEmpty(headsetId)) { string status = activeSession ? "active" : "open"; - _ctxClient.CreateSession(cortexToken, headsetId, status); + _ctxClient.CreateSession(headsetId, status); // temporary remove cortexToken parameter since update the function of CortexClient. The SessionHanlder will be removed in future updates. } else { UnityEngine.Debug.Log("CreateSession: Invalid parameters");