#nullable enable using System.Net.NetworkInformation; using System.Net.WebSockets; using System.Threading; using System.Text.Json; // Added for JSON parsing using System.Globalization; // Added for CultureInfo.InvariantCulture namespace Lib.io.websocket { // MessageParsingMode enum is defined in WebSocketClient.cs and referenced here. [Guid("9B4E3C2D-A1F0-4567-89BC-DEF012345678")] // Updated GUID public sealed class WebSocketServer : Instance, IStatusProvider, ICustomDropdownHolder, IDisposable { [Output(Guid = "2A4C9B5E-3D7F-41B0-80F5-1F92D7E0B4C8")] public readonly Slot Result = new(); [Output(Guid = "3A2B1C0D-E9F8-4756-A3C1-B5E7D9F0A2B4")] // Updated GUID public readonly Slot IsListening = new(); [Output(Guid = "6F5E4D3C-2B1A-4987-AB9C-DEF012345678")] // Updated GUID public readonly Slot ConnectionCount = new(); // New outputs for incoming client message separation [Output(Guid = "6F7E8D9C-0B1A-4323-4567-89ABCDEF0123", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot LastReceivedClientString = new(); [Output(Guid = "7E8D9C0B-1A2F-4234-5678-9ABCDEF01234", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot> LastReceivedClientParts = new(new List()); // Changed initialization [Output(Guid = "8D9C0B1A-2F3E-4145-6789-ABCDEF012345", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot> LastReceivedClientDictionary = new(new Dict(0f)); // Changed initialization [Output(Guid = "9C0B1A2F-3E4D-4056-789A-BCDEF0123456", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot ClientMessageWasTrigger = new(); // Removed ExtractedFloat output // [Output(Guid = "4F5E6D7C-8B9A-4012-3456-789ABCDEF012", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] // New GUID // public readonly Slot ExtractedFloat = new(0f); // New output for single float extraction public WebSocketServer() { Result.UpdateAction = Update; IsListening.UpdateAction = Update; ConnectionCount.UpdateAction = Update; // Added update actions for new outputs LastReceivedClientString.UpdateAction = Update; LastReceivedClientParts.UpdateAction = Update; LastReceivedClientDictionary.UpdateAction = Update; ClientMessageWasTrigger.UpdateAction = Update; // ExtractedFloat.UpdateAction = Update; // Removed } private bool _lastListenState; private int _lastPort; private string? _lastPath; private string? _lastLocalIp; private string? _lastSentMessage; private HttpListener? _listener; private CancellationTokenSource? _cancellationTokenSource; private readonly ConcurrentDictionary _clients = new(); private string _statusMessage = "Not listening"; private IStatusProvider.StatusLevel _statusLevel = IStatusProvider.StatusLevel.Notice; private bool _disposed; private bool _printToLog; // New private fields for handling incoming client messages private readonly ConcurrentQueue _receivedClientQueue = new(); private string _currentClientMessage = string.Empty; private List _currentClientParts = new(); private Dict _currentClientDictionary = new(0f); private bool _clientMessageWasTriggeredThisFrame; private void Update(EvaluationContext context) { if (_disposed) return; _printToLog = PrintToLog.GetValue(context); var shouldListen = Listen.GetValue(context); var port = Port.GetValue(context); var path = Path.GetValue(context); var localIp = LocalIpAddress.GetValue(context); // New inputs for client message parsing var clientParsingMode = (MessageParsingMode)ClientMessageParsingMode.GetValue(context); var clientDelimiter = ClientMessageDelimiter.GetValue(context) ?? string.Empty; // var keyToExtract = KeyToExtract.GetValue(context); // Removed var settingsChanged = shouldListen != _lastListenState || port != _lastPort || path != _lastPath || localIp != _lastLocalIp; if (settingsChanged) { StopListening(); if (shouldListen) { var host = localIp; if (string.IsNullOrEmpty(host) || host.Equals("0.0.0.0") || host.Equals("0.0.0.0 (Listen on all interfaces)")) { host = "+"; } else if (!IPAddress.TryParse(host, out _)) { host = "+"; SetStatus($"Invalid Local IP '{localIp}', defaulting to listen on all interfaces.", IStatusProvider.StatusLevel.Warning); } var urlPath = path?.TrimStart('/') ?? string.Empty; var prefix = $"http://{host}:{port}/" + (!string.IsNullOrEmpty(urlPath) ? $"{urlPath}/" : ""); StartListeningInternal(prefix); } _lastListenState = shouldListen; _lastPort = port; _lastPath = path; _lastLocalIp = localIp; } IsListening.Value = _listener is { IsListening: true }; ConnectionCount.Value = _clients.Count; var message = Message.GetValue(context); var sendOnChange = SendOnChange.GetValue(context); var triggerSend = SendTrigger.GetValue(context); var messageChanged = message != _lastSentMessage; if (triggerSend || (sendOnChange && messageChanged)) { if (!string.IsNullOrEmpty(message)) { _ = BroadcastMessageAsync(message); _lastSentMessage = message; } if (triggerSend) SendTrigger.SetTypedInputValue(false); } _clientMessageWasTriggeredThisFrame = false; _currentClientParts.Clear(); _currentClientDictionary.Clear(); // ExtractedFloat.Value = 0f; // Removed while (_receivedClientQueue.TryDequeue(out var clientMsg)) { _currentClientMessage = clientMsg; _clientMessageWasTriggeredThisFrame = true; switch (clientParsingMode) { case MessageParsingMode.Raw: break; case MessageParsingMode.SpaceSeparated: var stringParts = clientMsg.Split(new[] { clientDelimiter }, StringSplitOptions.RemoveEmptyEntries); foreach (var part in stringParts) { if (TryGetFloatFromObject(part, out var floatValue)) { _currentClientParts.Add(floatValue); } else if (_printToLog) { Log.Warning($"WS Server: Could not parse '{part}' as float in SpaceSeparated mode.", this); } } break; case MessageParsingMode.JsonKeyValue: try { using (JsonDocument doc = JsonDocument.Parse(clientMsg)) { if (doc.RootElement.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in doc.RootElement.EnumerateObject()) { if (TryGetFloatFromObject(property.Value, out var floatValue)) { _currentClientDictionary[property.Name] = floatValue; } else if (_printToLog) { Log.Warning($"WS Server: Could not parse JSON value '{property.Value}' for key '{property.Name}' as float.", this); } } } else { Log.Warning($"WS Server: Received JSON from client is not an object for key-value parsing: '{clientMsg}'", this); } } } catch (JsonException e) { Log.Warning($"WS Server: Failed to parse incoming client message as JSON: {e.Message}. Message: '{clientMsg}'", this); } break; } } LastReceivedClientString.Value = _currentClientMessage; LastReceivedClientParts.Value = _currentClientParts; LastReceivedClientDictionary.Value = _currentClientDictionary; // Removed single float extraction logic // if (!string.IsNullOrEmpty(keyToExtract) && _currentClientDictionary.TryGetValue(keyToExtract, out var extractedValue)) // { // ExtractedFloat.Value = extractedValue; // } // else // { // ExtractedFloat.Value = 0f; // } ClientMessageWasTrigger.Value = _clientMessageWasTriggeredThisFrame; LastReceivedClientString.DirtyFlag.Invalidate(); LastReceivedClientParts.DirtyFlag.Invalidate(); LastReceivedClientDictionary.DirtyFlag.Invalidate(); ClientMessageWasTrigger.DirtyFlag.Invalidate(); // ExtractedFloat.DirtyFlag.Invalidate(); // Removed } private static bool TryGetFloatFromObject(object arg, out float value) { value = arg switch { float f => f, int i => i, bool b => b ? 1f : 0f, string s => float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f) ? f : float.NaN, double d => (float)d, JsonElement je => je.ValueKind switch { JsonValueKind.Number => je.TryGetSingle(out var fVal) ? fVal : (je.TryGetDouble(out var dVal) ? (float)dVal : float.NaN), JsonValueKind.True => 1f, JsonValueKind.False => 0f, JsonValueKind.String => float.TryParse(je.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var fValString) ? fValString : float.NaN, _ => float.NaN }, _ => float.NaN }; return !float.IsNaN(value); } private void StartListeningInternal(string prefix) { if (_listener is { IsListening: true }) return; _listener = new HttpListener(); _cancellationTokenSource = new CancellationTokenSource(); try { _listener.Prefixes.Add(prefix); _listener.Start(); SetStatus($"Listening on {prefix.Replace("+", "localhost")}", IStatusProvider.StatusLevel.Success); if (_printToLog) { Log.Debug($"WS Server: Started listening on {prefix.Replace("+", "localhost")}", this); } _ = Task.Run(AcceptConnectionsLoop, _cancellationTokenSource.Token); } catch (HttpListenerException hlex) when (hlex.ErrorCode == 5) { SetStatus($"Access denied. Try running T3 as Administrator or: netsh http add urlacl url={prefix} user=Everyone", IStatusProvider.StatusLevel.Error); if (_printToLog) { Log.Error($"WS Server: Access denied starting listener on {prefix}: {hlex.Message}", this); } _listener?.Close(); _listener = null; } catch (Exception e) { SetStatus($"Failed to start: {e.Message}", IStatusProvider.StatusLevel.Error); if (_printToLog) { Log.Error($"WS Server: Failed to start listener on {prefix}: {e.Message}", this); } _listener?.Close(); _listener = null; } } private async Task AcceptConnectionsLoop() { try { while (_listener?.IsListening == true && !_cancellationTokenSource!.IsCancellationRequested) { var context = await _listener.GetContextAsync(); if (context.Request.IsWebSocketRequest) { var webSocketContext = await context.AcceptWebSocketAsync(subProtocol: null); var webSocket = webSocketContext.WebSocket; var clientId = Guid.NewGuid(); _clients[clientId] = webSocket; ConnectionCount.DirtyFlag.Invalidate(); if (_printToLog) { Log.Debug($"WS Server: Client {clientId} connected.", this); } _ = HandleClient(clientId, webSocket); } else { context.Response.StatusCode = 400; context.Response.Close(); } } } catch (OperationCanceledException) { } catch (Exception e) when (e is HttpListenerException or ObjectDisposedException) { if (_printToLog) { Log.Debug($"WS Server: Listener loop stopped due to shutdown: {e.Message}", this); } } catch (Exception e) { if (_listener?.IsListening == true) { Log.Warning($"WS Server: Listener loop stopped unexpectedly: {e.Message}", this); } } } private async Task HandleClient(Guid clientId, WebSocket webSocket) { var buffer = new byte[8192]; try { while (webSocket.State == WebSocketState.Open && !_cancellationTokenSource!.IsCancellationRequested) { var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), _cancellationTokenSource.Token); if (result.MessageType == WebSocketMessageType.Close) { await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", CancellationToken.None); break; } if (result.MessageType == WebSocketMessageType.Text) { var message = Encoding.UTF8.GetString(buffer, 0, result.Count); if (_printToLog) { Log.Debug($"WS Server ← '{message}' from client {clientId}", this); } _receivedClientQueue.Enqueue(message); LastReceivedClientString.DirtyFlag.Invalidate(); LastReceivedClientParts.DirtyFlag.Invalidate(); LastReceivedClientDictionary.DirtyFlag.Invalidate(); ClientMessageWasTrigger.DirtyFlag.Invalidate(); // ExtractedFloat.DirtyFlag.Invalidate(); // Removed } } } catch (OperationCanceledException) { } catch (WebSocketException) { } catch (Exception e) { Log.Warning($"WS Server: Error handling client {clientId}: {e.Message}", this); } finally { if (_clients.TryRemove(clientId, out _)) { ConnectionCount.DirtyFlag.Invalidate(); if (_printToLog) { Log.Debug($"WS Server: Client {clientId} disconnected.", this); } } webSocket.Dispose(); } } private async Task BroadcastMessageAsync(string message) { if (_clients.IsEmpty) return; var messageBuffer = new ArraySegment(Encoding.UTF8.GetBytes(message)); var clientsToBroadcast = _clients.Values.ToList(); if (_printToLog) { Log.Debug($"WS Server → Broadcast '{message}' to {clientsToBroadcast.Count} clients", this); } foreach (var client in clientsToBroadcast) { if (client.State == WebSocketState.Open) { try { await client.SendAsync(messageBuffer, WebSocketMessageType.Text, true, CancellationToken.None); } catch (Exception e) { Log.Warning($"WS Server: Failed to send message to a client: {e.Message}", this); } } } } private void StopListening() { try { _cancellationTokenSource?.Cancel(); _listener?.Stop(); foreach (var client in _clients.Values) { client.Dispose(); } _clients.Clear(); if (!_lastListenState) SetStatus("Not listening", IStatusProvider.StatusLevel.Notice); else if (_printToLog) { Log.Debug("WS Server: Stopped listening.", this); } IsListening.DirtyFlag.Invalidate(); ConnectionCount.DirtyFlag.Invalidate(); } catch (Exception e) { Log.Warning($"WS Server: Error stopping server: {e.Message}", this); } } public void Dispose() { if (_disposed) return; _disposed = true; Task.Run(StopListening); _cancellationTokenSource?.Dispose(); _listener?.Close(); } public IStatusProvider.StatusLevel GetStatusLevel() => _statusLevel; public string GetStatusMessage() => _statusMessage; private void SetStatus(string message, IStatusProvider.StatusLevel level) { _statusMessage = message; _statusLevel = level; } #region ICustomDropdownHolder Implementation string ICustomDropdownHolder.GetValueForInput(Guid id) { if (id == LocalIpAddress.Id) { return LocalIpAddress.Value; } if (id == ClientMessageParsingMode.Id) { return ((MessageParsingMode)ClientMessageParsingMode.Value).ToString(); } return string.Empty; } IEnumerable ICustomDropdownHolder.GetOptionsForInput(Guid id) { if (id == LocalIpAddress.Id) { return GetLocalIPv4Addresses(); } if (id == ClientMessageParsingMode.Id) { return Enum.GetNames(typeof(MessageParsingMode)); } return Enumerable.Empty(); } void ICustomDropdownHolder.HandleResultForInput(Guid id, string? s, bool isAListItem) { if (string.IsNullOrEmpty(s) || !isAListItem) return; if (id == LocalIpAddress.Id) { LocalIpAddress.SetTypedInputValue(s.Split(' ')[0]); } else if (id == ClientMessageParsingMode.Id) { if (Enum.TryParse(s, out MessageParsingMode mode)) { ClientMessageParsingMode.SetTypedInputValue((int)mode); } } } private static IEnumerable GetLocalIPv4Addresses() { yield return "0.0.0.0 (Listen on all interfaces)"; yield return "127.0.0.1"; if (!NetworkInterface.GetIsNetworkAvailable()) yield break; foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) { if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; foreach (var ipInfo in ni.GetIPProperties().UnicastAddresses) { if (ipInfo.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) { yield return ipInfo.Address.ToString(); } } } } #endregion [Input(Guid = "3D4F5E6A-7B8C-9D0E-F1A2-B3C4D5E6F7A8")] public readonly InputSlot Listen = new(); [Input(Guid = "1D2E3F4A-5B6C-4789-A0B1-C2D3E4F5A6B7")] public readonly InputSlot Port = new(8080); [Input(Guid = "8E7D6C5B-4A3F-4123-B9C8-D7E6F5A4B3C2")] public readonly InputSlot Path = new(); [Input(Guid = "0A1B2C3D-4E5F-4678-9012-3456789ABCDE")] public readonly InputSlot Message = new(); [Input(Guid = "A0E35517-337A-4770-985A-34CAC95B5B5F")] public readonly InputSlot SendOnChange = new(true); [Input(Guid = "4D3C2B1A-0F9E-4876-5432-10FEDCBA9876")] public readonly InputSlot SendTrigger = new(); [Input(Guid = "73B1F8C9-9A2E-47C0-B1C8-29C34A2E7D01")] public readonly InputSlot LocalIpAddress = new("0.0.0.0 (Listen on all interfaces)"); [Input(Guid = "2E725916-4143-4759-8651-E12185C658D3")] public readonly InputSlot PrintToLog = new(); [Input(Guid = "4B5A6F7E-8D9C-4501-2345-6789ABCDEF01")] public readonly InputSlot ClientMessageParsingMode = new((int)MessageParsingMode.Raw); [Input(Guid = "5A6F7E8D-9C0B-4412-3456-789ABCDEF012")] public readonly InputSlot ClientMessageDelimiter = new(" "); // Removed KeyToExtract input // [Input(Guid = "9F0A1B2C-3D4E-4567-8901-23456789ABCDEF")] // New GUID // public readonly InputSlot KeyToExtract = new(""); // New input for single float extraction } }