#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO.Ports;
using System.Linq;
using System.Management;
using System.Text.RegularExpressions;
using System.Threading;
using T3.Core.Logging;
using T3.Core.Operator;
using T3.Core.Operator.Interfaces;
namespace T3.IoServices;
public enum PortModes { Standard, DMX }
public interface ISerialReceiver
{
void ReceiveLine(string line);
void SetStatus(string message, IStatusProvider.StatusLevel level);
}
public static class SerialConnectionManager
{
///
/// When true, the writer thread logs serial-write durations above 0.5 ms.
/// Driven from operator-side LogMessages toggles. Cheap volatile read on the hot path.
///
public static volatile bool DebugLogSlowWrites;
private static readonly Dictionary _connections = new();
private static readonly object _lock = new();
#region Connection Management
public static void Register(object owner, string portName, int baudRate, ISerialReceiver? receiver, PortModes mode)
{
lock (_lock)
{
if (string.IsNullOrEmpty(portName)) return;
// If a previous PortConnection here died (writer thread exited on a USB hiccup,
// device unplugged, etc.), dispose it and create a fresh one. Otherwise we'd
// keep handing out subscriptions to a zombie connection.
if (_connections.TryGetValue(portName, out var existing) && !existing.IsAlive)
{
existing.Dispose();
_connections.Remove(portName);
}
if (!_connections.TryGetValue(portName, out var connection))
{
connection = new PortConnection(portName, baudRate, mode);
_connections[portName] = connection;
}
if (connection.Mode != mode)
{
throw new InvalidOperationException($"Cannot register port {portName} for {mode} mode. It is already open in {connection.Mode} mode.");
}
connection.AddSubscriber(owner, receiver);
}
}
///
/// Forcibly disposes and removes a PortConnection regardless of subscriber count.
/// Use when a connection is wedged (hung writer thread, zombie state) and a clean
/// teardown is needed before reconnecting. Subscribers should re-Register afterward.
///
public static void ForceClose(string? portName)
{
lock (_lock)
{
if (string.IsNullOrEmpty(portName) || !_connections.TryGetValue(portName, out var connection)) return;
connection.Dispose();
_connections.Remove(portName);
}
}
public static void Unregister(object owner, string? portName)
{
lock (_lock)
{
if (string.IsNullOrEmpty(portName) || !_connections.TryGetValue(portName, out var connection)) return;
connection.RemoveSubscriber(owner);
if (connection.SubscriberCount == 0)
{
connection.Dispose();
_connections.Remove(portName);
}
}
}
public static bool IsPortOpen(string? portName)
{
lock (_lock) { return !string.IsNullOrEmpty(portName) && _connections.TryGetValue(portName, out var c) && c.IsOpen; }
}
#endregion
#region Data Methods
public static void WriteLine(string portName, string data) { lock (_lock) { if (_connections.TryGetValue(portName, out var c)) c.WriteLine(data); } }
public static void Write(string portName, string data) { lock (_lock) { if (_connections.TryGetValue(portName, out var c)) c.Write(data); } }
public static void SendDmxFrame(string portName, byte[] dmxFrame) { lock (_lock) { if (_connections.TryGetValue(portName, out var c)) c.SendDmxFrame(dmxFrame); } }
public static bool WriteBytes(string portName, byte[] data, int length) { lock (_lock) { return _connections.TryGetValue(portName, out var c) && c.WriteBytes(data, length); } }
#endregion
#region Port Scanning
private static List? _cachedPortList;
private static readonly Stopwatch _portListCacheStopwatch = new();
private static int _portListRefreshing;
private const int CacheDurationMs = 2000;
private static readonly Regex _comPortRegex = new(@"\((COM\d+)\)", RegexOptions.Compiled);
// Returns the cached port list immediately. When the cache is stale, kicks off a
// background refresh so the UI thread never blocks on the ~200-400 ms WMI query.
// Only the very first call (no cache yet) does a synchronous query to populate.
public static List GetAvailableSerialPortsWithDescriptions()
{
var cacheStale = !_portListCacheStopwatch.IsRunning || _portListCacheStopwatch.ElapsedMilliseconds >= CacheDurationMs;
if (_cachedPortList == null)
{
// Cold start: populate synchronously so the dropdown isn't empty.
_cachedPortList = QueryPortListBlocking();
_portListCacheStopwatch.Restart();
return _cachedPortList;
}
if (cacheStale && Interlocked.CompareExchange(ref _portListRefreshing, 1, 0) == 0)
{
ThreadPool.QueueUserWorkItem(static _ =>
{
try
{
var fresh = QueryPortListBlocking();
_cachedPortList = fresh;
_portListCacheStopwatch.Restart();
}
finally
{
Volatile.Write(ref _portListRefreshing, 0);
}
});
}
return _cachedPortList;
}
private static List QueryPortListBlocking()
{
var portList = new List();
try
{
var searcher = new ManagementObjectSearcher("root\\CIMV2", "SELECT * FROM Win32_PnPEntity WHERE Name LIKE '%(COM%)'");
portList.AddRange(searcher.Get().Cast().Select(p => p["Name"]?.ToString()).Where(s => s != null)!);
}
catch (Exception ex)
{
Log.Warning($"WMI query for serial ports failed, falling back to basic list. Error: {ex.Message}");
return SerialPort.GetPortNames().ToList();
}
return portList.OrderBy(s => s).ToList();
}
public static string GetPortNameFromDeviceDescription(string description)
{
var match = _comPortRegex.Match(description);
return match.Success ? match.Groups[1].Value : description;
}
#endregion
private class PortConnection : IDisposable
{
public bool IsOpen => _serialPort.IsOpen;
public bool IsAlive => _keepRunning && _serialPort.IsOpen;
public int SubscriberCount => _subscribers.Count;
public readonly PortModes Mode;
private readonly SerialPort _serialPort;
private Thread? _workerThread;
private readonly Thread? _writerThread;
private volatile bool _keepRunning;
private readonly HashSet