Files
2026-07-13 13:13:17 +08:00

596 lines
27 KiB
C#

#nullable enable
using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using System.Threading;
namespace Lib.io.dmx
{
[Guid("98efc7c8-cafd-45ee-8746-14f37e9f59f8")]
internal sealed class ArtnetOutput : Instance<ArtnetOutput>, IStatusProvider, ICustomDropdownHolder, IDisposable
{
private const int ArtNetPort = 6454;
private static readonly byte[] _artnetId = "Art-Net\0"u8.ToArray();
[Output(Guid = "499329d0-15e9-410e-9f61-63724dbec937")]
public readonly Slot<Command> Result = new();
private readonly ConnectionSettings _connectionSettings = new();
private readonly object _dataLock = new();
private readonly object _connectionLock = new();
private readonly ConcurrentDictionary<string, string> _discoveredNodes = new();
private readonly ConcurrentDictionary<int, IPEndPoint> _universeRoutingTable = new();
private readonly byte[] _packetBuffer = new byte[18 + 512];
private readonly byte[] _universeSequenceNumbers = new byte[65536];
// --- Zero-Allocation Queue ---
private readonly ConcurrentQueue<FrameData> _dataQueue = new();
private readonly ConcurrentBag<FrameData> _frameDataPool = new();
private struct InputMapping
{
public int Offset;
public int Count;
public int StartUniverse;
}
private sealed class FrameData
{
public int[] Buffer = Array.Empty<int>();
public InputMapping[] Mappings = Array.Empty<InputMapping>();
public int MappingCount;
}
private FrameData RentFrameData(int requiredChannels, int requiredMappings)
{
if (_frameDataPool.TryTake(out var fd))
{
if (fd.Buffer.Length < requiredChannels) fd.Buffer = new int[requiredChannels + 1024];
if (fd.Mappings.Length < requiredMappings) fd.Mappings = new InputMapping[requiredMappings + 4];
return fd;
}
return new FrameData { Buffer = new int[requiredChannels + 1024], Mappings = new InputMapping[requiredMappings + 4] };
}
private Thread? _artPollListenerThread;
private Timer? _artPollTimer;
private volatile bool _connected;
private volatile bool _isPolling;
private string? _lastErrorMessage;
private IStatusProvider.StatusLevel _lastStatusLevel = IStatusProvider.StatusLevel.Notice;
private int _maxFps;
private volatile bool _printToLog;
private IPAddress? _selectedSubnetMask;
private CancellationTokenSource? _senderCts;
private double _lastRetryTime;
private double _lastNetworkRefreshTime;
private Thread? _senderThread;
private Socket? _socket;
private bool _syncToSend;
private bool _useArtNet4;
private bool _wasSendingLastFrame;
public ArtnetOutput()
{
Result.UpdateAction = Update;
}
private void Update(EvaluationContext context)
{
_printToLog = PrintToLog.GetValue(context);
var localIpString = LocalIpAddress.GetValue(context);
var targetIpString = TargetIpAddress.GetValue(context);
if (_selectedSubnetMask == null && !string.IsNullOrEmpty(localIpString))
{
var adapter = _networkInterfaces.FirstOrDefault(ni => ni.IpAddress.ToString() == localIpString);
if (adapter == null && context.LocalTime - _lastNetworkRefreshTime > 2.0)
{
_lastNetworkRefreshTime = context.LocalTime;
_networkInterfaces = GetNetworkInterfaces();
adapter = _networkInterfaces.FirstOrDefault(ni => ni.IpAddress.ToString() == localIpString);
}
if (adapter != null) _selectedSubnetMask = adapter.SubnetMask;
}
_connectionSettings.Update(localIpString ?? string.Empty, _selectedSubnetMask, targetIpString ?? string.Empty, SendUnicast.GetValue(context), out bool needsSocketRebind);
bool needsReconnect = Reconnect.GetValue(context) || needsSocketRebind;
bool shouldAutoRevive = !_connected && _connectionSettings.LocalIp != null && (context.LocalTime - _lastRetryTime > 2.0);
if (needsReconnect || shouldAutoRevive)
{
Reconnect.SetTypedInputValue(false);
bool needsThreadRestart = _wasSendingLastFrame && _senderThread != null;
if (needsThreadRestart) StopSenderThread();
lock (_connectionLock)
{
CloseSocket();
_connected = TryConnectArtNet(_connectionSettings.LocalIp);
if (shouldAutoRevive) _lastRetryTime = context.LocalTime;
}
if (needsThreadRestart) StartSenderThread();
}
var discoverNodes = PrintArtnetPoll.GetValue(context);
if (discoverNodes && !_isPolling) StartArtPolling();
else if (!discoverNodes && _isPolling) StopArtPolling();
var enableSending = SendTrigger.GetValue(context);
if (enableSending != _wasSendingLastFrame)
{
if (enableSending) StartSenderThread();
else StopSenderThread();
_wasSendingLastFrame = enableSending;
}
if (!enableSending)
{
SetStatus("Sending is disabled. Enable 'Send Trigger'.", IStatusProvider.StatusLevel.Notice);
return;
}
lock (_connectionLock)
{
if (!_connected)
{
SetStatus($"Not connected. {(_lastErrorMessage ?? "Check settings.")}", IStatusProvider.StatusLevel.Warning);
return;
}
}
SetStatus("Connected and sending.", IStatusProvider.StatusLevel.Success);
var inputValueLists = InputsValues.GetCollectedTypedInputs();
int totalChannels = 0;
for (int i = 0; i < inputValueLists.Count; i++)
{
var buf = inputValueLists[i].GetValue(context);
if (buf != null) totalChannels += buf.Count;
}
if (totalChannels == 0) return;
var universeChannels = UniverseChannels.GetValue(context) ?? new List<int>();
int nextUniverse = 1;
if (universeChannels.Count > 0)
{
var lastInputIndex = universeChannels.Count - 1;
if (lastInputIndex < inputValueLists.Count)
{
var lastBuffer = inputValueLists[lastInputIndex].GetValue(context);
if (lastBuffer != null) nextUniverse = universeChannels[lastInputIndex] + (int)Math.Ceiling(lastBuffer.Count / 512.0);
else nextUniverse = universeChannels[lastInputIndex];
}
else nextUniverse = universeChannels[^1] + 1;
}
while (universeChannels.Count < inputValueLists.Count)
{
universeChannels.Add(nextUniverse);
nextUniverse++;
}
UniverseChannels.SetTypedInputValue(universeChannels);
var frameData = RentFrameData(totalChannels, inputValueLists.Count);
frameData.MappingCount = 0;
int currentOffset = 0;
for (int i = 0; i < inputValueLists.Count; i++)
{
var buf = inputValueLists[i].GetValue(context);
if (buf == null || buf.Count == 0) continue;
buf.CopyTo(frameData.Buffer, currentOffset);
frameData.Mappings[frameData.MappingCount++] = new InputMapping { Offset = currentOffset, Count = buf.Count, StartUniverse = universeChannels[i] };
currentOffset += buf.Count;
}
lock (_dataLock)
{
_syncToSend = SendSync.GetValue(context);
_maxFps = MaxFps.GetValue(context);
_useArtNet4 = EnableArtNet4.GetValue(context);
}
_dataQueue.Enqueue(frameData);
while (_dataQueue.Count > 2)
{
if (_dataQueue.TryDequeue(out var oldData)) _frameDataPool.Add(oldData);
}
}
#region Sender Thread
private void StartSenderThread()
{
if (_senderThread != null) return;
_senderCts = new CancellationTokenSource();
_senderThread = new Thread(() => SenderLoop(_senderCts.Token)) { IsBackground = true, Name = "ArtNetSender", Priority = ThreadPriority.AboveNormal };
_senderThread.Start();
}
private void StopSenderThread()
{
if (_senderThread == null) return;
_senderCts?.Cancel();
if (_senderThread.Join(500)) _senderCts?.Dispose();
_senderCts = null;
_senderThread = null;
}
private void SenderLoop(CancellationToken token)
{
var stopwatch = new Stopwatch();
long nextFrameTimeTicks = 0;
int consecutiveErrors = 0;
const int MAX_CONSECUTIVE_ERRORS = 10;
while (!token.IsCancellationRequested)
{
try
{
if (!_dataQueue.TryDequeue(out var frameData) || frameData == null)
{
Thread.Sleep(1);
continue;
}
bool syncCopy;
int maxFpsCopy;
bool useArtNet4Copy;
bool isConnected;
lock (_dataLock) { syncCopy = _syncToSend; maxFpsCopy = _maxFps; useArtNet4Copy = _useArtNet4; }
lock (_connectionLock) { isConnected = _connected; }
if (maxFpsCopy > 0)
{
if (!stopwatch.IsRunning) stopwatch.Start();
while (true)
{
long now = stopwatch.ElapsedTicks;
if (now >= nextFrameTimeTicks)
{
if (now > nextFrameTimeTicks + Stopwatch.Frequency) nextFrameTimeTicks = now;
nextFrameTimeTicks += (long)(Stopwatch.Frequency / (double)maxFpsCopy);
break;
}
if (nextFrameTimeTicks - now > Stopwatch.Frequency / 1000) Thread.Sleep(1);
else Thread.SpinWait(10);
}
}
Socket? currentSocket;
IPEndPoint? targetEndPoint;
lock (_connectionSettings) { currentSocket = _socket; targetEndPoint = _connectionSettings.TargetEndPoint; }
if (currentSocket == null || !isConnected || targetEndPoint == null)
{
_frameDataPool.Add(frameData);
Thread.Sleep(10);
continue;
}
if (syncCopy) SendArtSync(currentSocket, targetEndPoint);
for (int m = 0; m < frameData.MappingCount; m++)
{
if (token.IsCancellationRequested) break;
var mapping = frameData.Mappings[m];
int remainingChannels = mapping.Count;
int currentOffset = mapping.Offset;
int currentUniverse = mapping.StartUniverse;
while (remainingChannels > 0)
{
if (token.IsCancellationRequested) break;
int chunkCount = Math.Min(remainingChannels, 512);
int sendLength = Math.Max(2, chunkCount);
if (sendLength % 2 != 0) sendLength++;
byte seq = _universeSequenceNumbers[currentUniverse];
seq = (byte)((seq % 255) + 1);
_universeSequenceNumbers[currentUniverse] = seq;
IPEndPoint specificTarget = targetEndPoint;
if (useArtNet4Copy && _universeRoutingTable.TryGetValue(currentUniverse, out var routedEp))
{
specificTarget = routedEp;
}
bool success = SendDmxPacket(currentSocket, specificTarget, currentUniverse, frameData.Buffer, currentOffset, chunkCount, sendLength, seq);
if (success) consecutiveErrors = 0;
else
{
consecutiveErrors++;
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS)
{
lock (_connectionLock) _connected = false;
consecutiveErrors = 0;
}
}
currentOffset += chunkCount;
remainingChannels -= chunkCount;
currentUniverse++;
}
}
_frameDataPool.Add(frameData);
}
catch (ThreadAbortException) { break; }
catch (Exception e)
{
if (_printToLog) Log.Warning($"ArtNet Sender survived error: {e.Message}", this);
consecutiveErrors++;
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { lock (_connectionLock) _connected = false; consecutiveErrors = 0; }
Thread.Sleep(10);
}
}
}
#endregion
#region Fast Packet Sending
private bool SendDmxPacket(Socket socket, IPEndPoint target, int universe, int[] rawData, int offset, int chunkCount, int sendLength, byte sequenceNumber)
{
try
{
Array.Copy(_artnetId, 0, _packetBuffer, 0, 8);
_packetBuffer[8] = 0x00;
_packetBuffer[9] = 0x50;
_packetBuffer[10] = 0x00;
_packetBuffer[11] = 14;
_packetBuffer[12] = sequenceNumber;
_packetBuffer[13] = 0x00;
_packetBuffer[14] = (byte)(universe & 0xFF);
_packetBuffer[15] = (byte)((universe >> 8) & 0x7F);
_packetBuffer[16] = (byte)(sendLength >> 8);
_packetBuffer[17] = (byte)(sendLength & 0xFF);
for (int i = 0; i < chunkCount; i++)
{
int val = rawData[offset + i];
_packetBuffer[18 + i] = (byte)(val < 0 ? 0 : (val > 255 ? 255 : val));
}
for (int i = chunkCount; i < sendLength; i++) _packetBuffer[18 + i] = 0;
socket.SendTo(_packetBuffer, 18 + sendLength, SocketFlags.None, target);
return true;
}
catch (SocketException e)
{
if (e.SocketErrorCode is SocketError.WouldBlock or SocketError.NoBufferSpaceAvailable or SocketError.ConnectionReset) return true;
return false;
}
catch (ObjectDisposedException) { return false; }
}
private void SendArtSync(Socket socket, IPEndPoint target)
{
try
{
Span<byte> syncPacket = stackalloc byte[12];
_artnetId.CopyTo(syncPacket);
syncPacket[8] = 0x00; syncPacket[9] = 0x52;
syncPacket[10] = 0x00; syncPacket[11] = 14;
socket.SendTo(syncPacket, target);
}
catch { /* Ignore */ }
}
#endregion
#region Discovery & Setup
private void StartArtPolling()
{
_discoveredNodes.Clear();
_universeRoutingTable.Clear();
_isPolling = true;
_artPollListenerThread = new Thread(ListenForArtPollReplies) { IsBackground = true, Name = "ArtNetPollListener" };
_artPollListenerThread.Start();
_artPollTimer = new Timer(_ => SendArtPoll(), null, 0, 3000);
}
private void StopArtPolling()
{
if (!_isPolling) return;
_isPolling = false;
_artPollTimer?.Dispose();
_artPollTimer = null;
_artPollListenerThread?.Join(200);
_artPollListenerThread = null;
}
private void SendArtPoll()
{
lock (_connectionSettings)
{
if (_socket == null || !_isPolling || _connectionSettings.LocalIp == null || _connectionSettings.TargetEndPoint == null) return;
try
{
Span<byte> pollPacket = stackalloc byte[14];
_artnetId.CopyTo(pollPacket);
pollPacket[8] = 0x00; pollPacket[9] = 0x20;
pollPacket[10] = 0x00; pollPacket[11] = 14;
pollPacket[12] = 2; pollPacket[13] = 0;
_socket.SendTo(pollPacket, _connectionSettings.TargetEndPoint);
}
catch { /* Swallowed */ }
}
}
private void ListenForArtPollReplies()
{
var buffer = new byte[1024];
while (_isPolling)
{
try
{
Socket? currentSocket;
lock (_connectionSettings) currentSocket = _socket;
if (currentSocket == null || currentSocket.Available == 0) { Thread.Sleep(10); continue; }
var remoteEp = new IPEndPoint(IPAddress.Any, 0) as EndPoint;
var receivedBytes = currentSocket.ReceiveFrom(buffer, ref remoteEp);
if (receivedBytes < 238 || !buffer.AsSpan(0, 8).SequenceEqual(_artnetId) || buffer[8] != 0x00 || buffer[9] != 0x21) continue;
var ipAddress = new IPAddress(new[] { buffer[10], buffer[11], buffer[12], buffer[13] });
var shortName = Encoding.ASCII.GetString(buffer, 26, 18).TrimEnd('\0');
var ipString = ipAddress.ToString();
var displayName = string.IsNullOrWhiteSpace(shortName) ? ipString : shortName;
_discoveredNodes[ipString] = $"{displayName} ({ipString})";
var nodeEndPoint = new IPEndPoint(ipAddress, ArtNetPort);
var netSwitch = buffer[18];
var subSwitch = buffer[19];
var numPorts = buffer[173] | (buffer[172] << 8);
for (int i = 0; i < 4; i++)
{
if (i < numPorts)
{
var swOut = buffer[190 + i];
var universeAddress = (netSwitch << 8) | (subSwitch << 4) | (swOut & 0x0F);
_universeRoutingTable[universeAddress] = nodeEndPoint;
}
}
}
catch { if (_isPolling) Thread.Sleep(10); }
}
}
public void Dispose() { StopSenderThread(); StopArtPolling(); CloseSocket(); }
private void CloseSocket()
{
lock (_connectionLock)
{
lock (_connectionSettings)
{
if (_socket == null) return;
try { _socket.Close(); } catch { } finally { _socket = null; _connected = false; _lastErrorMessage = "Socket closed."; }
}
}
}
private bool TryConnectArtNet(IPAddress? localIp)
{
lock (_connectionLock)
{
lock (_connectionSettings)
{
if (localIp == null) { _lastErrorMessage = "Local IP Address is not valid."; return false; }
try
{
_socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
_socket.SendBufferSize = 10 * 1024 * 1024;
_socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
// Broadcast is allowed even on localhost now
_socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, true);
try { _socket.IOControl(-1744830452, new byte[] { 0 }, null); } catch { }
_socket.Bind(new IPEndPoint(localIp, ArtNetPort));
_lastErrorMessage = null;
return _connected = true;
}
catch (Exception e) { _lastErrorMessage = $"Failed to bind socket: {e.Message}"; CloseSocket(); return false; }
}
}
}
private static IPAddress? CalculateBroadcastAddress(IPAddress address, IPAddress subnetMask)
{
byte[] ipBytes = address.GetAddressBytes();
byte[] maskBytes = subnetMask.GetAddressBytes();
if (ipBytes.Length != maskBytes.Length) return null;
byte[] broadcastBytes = new byte[ipBytes.Length];
for (int i = 0; i < broadcastBytes.Length; i++) broadcastBytes[i] = (byte)(ipBytes[i] | ~maskBytes[i]);
return new IPAddress(broadcastBytes);
}
private static List<NetworkAdapterInfo> _networkInterfaces = GetNetworkInterfaces();
private static List<NetworkAdapterInfo> GetNetworkInterfaces()
{
var list = new List<NetworkAdapterInfo> { new(IPAddress.Loopback, IPAddress.Parse("255.0.0.0"), "Localhost (127.0.0.1)") };
try
{
list.AddRange(from ni in NetworkInterface.GetAllNetworkInterfaces()
where ni.OperationalStatus == OperationalStatus.Up && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback
from ip in ni.GetIPProperties().UnicastAddresses
where ip.Address.AddressFamily == AddressFamily.InterNetwork
select new NetworkAdapterInfo(ip.Address, ip.IPv4Mask, ni.Name));
}
catch { }
return list;
}
private sealed record NetworkAdapterInfo(IPAddress IpAddress, IPAddress SubnetMask, string Name) { public string DisplayName => $"{Name}: {IpAddress}"; }
private sealed class ConnectionSettings
{
private string? _lastLocalIpStr;
public IPAddress? LocalIp { get; private set; }
public IPEndPoint? TargetEndPoint { get; private set; }
public void Update(string localIpStr, IPAddress? subnetMask, string targetIpStr, bool sendUnicast, out bool needsSocketRebind)
{
needsSocketRebind = false;
if (_lastLocalIpStr != localIpStr)
{
_lastLocalIpStr = localIpStr;
IPAddress.TryParse(localIpStr, out var parsedLocalIp);
LocalIp = parsedLocalIp;
needsSocketRebind = true;
}
IPAddress? targetIp = null;
if (sendUnicast && !string.IsNullOrWhiteSpace(targetIpStr))
{
IPAddress.TryParse(targetIpStr, out targetIp);
}
else if (!sendUnicast)
{
// Fallback securely to subnet broadcast (or global broadcast if it fails). Works with loopback correctly now!
targetIp = (LocalIp != null && subnetMask != null) ? CalculateBroadcastAddress(LocalIp, subnetMask) ?? IPAddress.Broadcast : IPAddress.Broadcast;
}
TargetEndPoint = targetIp != null ? new IPEndPoint(targetIp, ArtNetPort) : null;
}
}
#endregion
#region UI & Inputs
public IStatusProvider.StatusLevel GetStatusLevel() => _lastStatusLevel;
public string? GetStatusMessage() => _lastErrorMessage;
public void SetStatus(string m, IStatusProvider.StatusLevel l) { _lastErrorMessage = m; _lastStatusLevel = l; }
string ICustomDropdownHolder.GetValueForInput(Guid inputId) { if (inputId == LocalIpAddress.Id) return LocalIpAddress.Value ?? string.Empty; if (inputId == TargetIpAddress.Id) return TargetIpAddress.Value ?? string.Empty; return string.Empty; }
IEnumerable<string> ICustomDropdownHolder.GetOptionsForInput(Guid inputId) { if (inputId == LocalIpAddress.Id) { _networkInterfaces = GetNetworkInterfaces(); foreach (var adapter in _networkInterfaces) yield return adapter.DisplayName; } else if (inputId == TargetIpAddress.Id) { if (!_isPolling && _discoveredNodes.IsEmpty) yield return "Enable 'Discover Nodes' to search..."; else if (_isPolling && _discoveredNodes.IsEmpty) yield return "Searching for nodes..."; else foreach (var nodeName in _discoveredNodes.Values.OrderBy(name => name)) yield return nodeName; } }
void ICustomDropdownHolder.HandleResultForInput(Guid inputId, string? selected, bool isAListItem) { if (string.IsNullOrEmpty(selected) || !isAListItem) return; if (inputId == LocalIpAddress.Id) { var foundAdapter = _networkInterfaces.FirstOrDefault(i => i.DisplayName == selected); if (foundAdapter == null) return; LocalIpAddress.SetTypedInputValue(foundAdapter.IpAddress.ToString()); _selectedSubnetMask = foundAdapter.SubnetMask; } else if (inputId == TargetIpAddress.Id) { var finalIp = selected; var match = Regex.Match(selected, @"\(([^)]*)\)"); if (match.Success) finalIp = match.Groups[1].Value; TargetIpAddress.SetTypedInputValue(finalIp); } }
[Input(Guid = "F7520A37-C2D4-41FA-A6BA-A6ED0423A4EC")] public readonly MultiInputSlot<List<int>> InputsValues = new();
[Input(Guid = "B2C3D4E5-F6A7-8901-BCDE-F234567890AB")] public readonly InputSlot<List<int>> UniverseChannels = new();
[Input(Guid = "fcbfe87b-b8aa-461c-a5ac-b22bb29ad36d")] public readonly InputSlot<string> LocalIpAddress = new();
[Input(Guid = "168d0023-554f-46cd-9e62-8f3d1f564b8d")] public readonly InputSlot<bool> SendTrigger = new();
[Input(Guid = "73babdb1-f88f-4e4d-aa3f-0536678b0793")] public readonly InputSlot<bool> Reconnect = new();
[Input(Guid = "d293bb33-2fba-4048-99b8-86aa15a478f2")] public readonly InputSlot<bool> SendSync = new();
[Input(Guid = "7c15da5f-cfa1-4339-aceb-4ed0099ea041")] public readonly InputSlot<bool> SendUnicast = new();
[Input(Guid = "32DB51FA-EF32-478A-A8C4-C1F93F5451E9")] public readonly InputSlot<bool> EnableArtNet4 = new(true);
[Input(Guid = "0fc76369-788a-4ffe-9dde-8eea5f10cf32")] public readonly InputSlot<string> TargetIpAddress = new();
[Input(Guid = "65fb88ec-5772-4973-bd8b-bb2cb9f557e7")] public readonly InputSlot<bool> PrintArtnetPoll = new();
[Input(Guid = "6F5C4B3A-2E1D-4F9C-8A7B-3D2E1F0C9B8A")] public readonly InputSlot<int> MaxFps = new(60);
[Input(Guid = "D0E1F2A3-B4C5-4678-9012-3456789ABCDE")] public readonly InputSlot<bool> PrintToLog = new();
#endregion
}
}