309 lines
12 KiB
C#
309 lines
12 KiB
C#
#nullable enable
|
|
using System.Diagnostics;
|
|
using System.Numerics;
|
|
using T3.Core.IO;
|
|
using T3.IoServices;
|
|
|
|
namespace Lib.io.serial;
|
|
|
|
[Guid("F830FAA6-02C1-475C-99AA-C5E0D9EA33B8")]
|
|
internal sealed class WLedSerialOutput : Instance<WLedSerialOutput>, IStatusProvider, ICustomDropdownHolder, IDisposable
|
|
{
|
|
public enum MapModes { Stretch, Repeat, Lerp }
|
|
public enum ColorOrders { GRB, RGB, BRG, RBG, BGR, GBR }
|
|
|
|
[Output(Guid = "CA36142C-CD17-41DF-B86C-CC2ED333AA61")] public readonly Slot<Command> Result = new();
|
|
[Output(Guid = "23AE93A1-0C4A-4296-8DE6-B2B2CF187EF0")] public readonly Slot<bool> IsConnected = new();
|
|
|
|
public WLedSerialOutput()
|
|
{
|
|
Result.UpdateAction = Update;
|
|
IsConnected.UpdateAction = Update;
|
|
}
|
|
|
|
private string? _lastPortName;
|
|
private int _lastBaudRate;
|
|
private bool _lastConnectState;
|
|
private byte[]? _frameBuffer;
|
|
private int _bufferLedCount;
|
|
private DateTime _nextRetryAt = DateTime.MinValue;
|
|
|
|
private void Update(EvaluationContext context)
|
|
{
|
|
var sending = Sending.GetValue(context);
|
|
var manualReconnect = Reconnect.GetValue(context);
|
|
var portName = PortName.GetValue(context) ?? string.Empty;
|
|
var baudRate = BaudRate.GetValue(context);
|
|
var logMessages = LogMessages.GetValue(context);
|
|
SerialConnectionManager.DebugLogSlowWrites = logMessages;
|
|
|
|
var updateStartTicks = logMessages ? Stopwatch.GetTimestamp() : 0L;
|
|
try
|
|
{
|
|
UpdateInner(context, sending, manualReconnect, portName, baudRate, logMessages);
|
|
}
|
|
finally
|
|
{
|
|
if (logMessages)
|
|
{
|
|
var totalMs = TicksToMilliseconds(Stopwatch.GetTimestamp() - updateStartTicks);
|
|
if (totalMs > 0.5)
|
|
Log.Debug($"WLED Update total: {totalMs:F2}ms", this);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateInner(EvaluationContext context, bool sending, bool manualReconnect, string portName, int baudRate, bool logMessages)
|
|
{
|
|
|
|
if (manualReconnect)
|
|
{
|
|
Reconnect.SetTypedInputValue(false);
|
|
// Force the connection to be torn down (regardless of other subscribers,
|
|
// hung writer thread, etc.) and reopened on the next frame.
|
|
SerialConnectionManager.ForceClose(_lastPortName);
|
|
SerialConnectionManager.ForceClose(portName);
|
|
_lastConnectState = false;
|
|
_nextRetryAt = DateTime.MinValue;
|
|
}
|
|
|
|
var settingsChanged = portName != _lastPortName || baudRate != _lastBaudRate || sending != _lastConnectState;
|
|
if (settingsChanged)
|
|
{
|
|
SerialConnectionManager.Unregister(this, _lastPortName);
|
|
if (sending && !string.IsNullOrEmpty(portName))
|
|
{
|
|
try
|
|
{
|
|
SerialConnectionManager.Register(this, portName, baudRate, null, PortModes.Standard);
|
|
SetStatus($"Connected to {portName} @ {baudRate}", IStatusProvider.StatusLevel.Success);
|
|
_nextRetryAt = DateTime.MinValue;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
SetStatus($"Failed to connect: {e.Message}", IStatusProvider.StatusLevel.Warning);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SetStatus("Disconnected", IStatusProvider.StatusLevel.Notice);
|
|
}
|
|
_lastPortName = portName;
|
|
_lastBaudRate = baudRate;
|
|
_lastConnectState = sending;
|
|
}
|
|
|
|
var isConnected = SerialConnectionManager.IsPortOpen(portName);
|
|
|
|
// Throttled auto-reconnect when the port has dropped
|
|
if (sending && !isConnected && !string.IsNullOrEmpty(portName))
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
if (now >= _nextRetryAt)
|
|
{
|
|
_nextRetryAt = now + RetryInterval;
|
|
try
|
|
{
|
|
SerialConnectionManager.Register(this, portName, baudRate, null, PortModes.Standard);
|
|
SetStatus($"Reconnected to {portName}", IStatusProvider.StatusLevel.Success);
|
|
isConnected = true;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
SetStatus($"Reconnecting to {portName}... ({e.Message})", IStatusProvider.StatusLevel.Warning);
|
|
}
|
|
}
|
|
}
|
|
|
|
IsConnected.Value = isConnected;
|
|
if (!isConnected)
|
|
return;
|
|
|
|
var colors = Colors.GetValue(context);
|
|
if (colors == null || colors.Count == 0)
|
|
{
|
|
SetStatus("No colors - frame skipped", IStatusProvider.StatusLevel.Notice);
|
|
return;
|
|
}
|
|
|
|
var requestedLedCount = LedCount.GetValue(context);
|
|
var ledCount = requestedLedCount <= 0 ? colors.Count : requestedLedCount;
|
|
if (ledCount > MaxLedCount)
|
|
ledCount = MaxLedCount;
|
|
|
|
var brightness = Brightness.GetValue(context);
|
|
if (brightness < 0f)
|
|
brightness = 0f;
|
|
else if (brightness > 1f)
|
|
brightness = 1f;
|
|
|
|
var colorOrder = (ColorOrders)ColorOrder.GetValue(context);
|
|
var mapMode = (MapModes)MapMode.GetValue(context);
|
|
|
|
EnsureBuffer(ledCount);
|
|
|
|
var loopStartTicks = logMessages ? Stopwatch.GetTimestamp() : 0L;
|
|
|
|
// Position of R / G / B in the per-pixel triplet on the wire
|
|
int rPos, gPos, bPos;
|
|
switch (colorOrder)
|
|
{
|
|
case ColorOrders.RGB: rPos = 0; gPos = 1; bPos = 2; break;
|
|
case ColorOrders.GRB: rPos = 1; gPos = 0; bPos = 2; break;
|
|
case ColorOrders.BRG: rPos = 1; gPos = 2; bPos = 0; break;
|
|
case ColorOrders.RBG: rPos = 0; gPos = 2; bPos = 1; break;
|
|
case ColorOrders.BGR: rPos = 2; gPos = 1; bPos = 0; break;
|
|
case ColorOrders.GBR: rPos = 2; gPos = 0; bPos = 1; break;
|
|
default: rPos = 1; gPos = 0; bPos = 2; break;
|
|
}
|
|
|
|
var srcCount = colors.Count;
|
|
var buffer = _frameBuffer!;
|
|
// Pre-compute the lerp scale only once (avoids divide in the hot loop)
|
|
var lerpScale = (mapMode == MapModes.Lerp && ledCount > 1)
|
|
? (srcCount - 1) / (float)(ledCount - 1)
|
|
: 0f;
|
|
|
|
for (var i = 0; i < ledCount; i++)
|
|
{
|
|
float r, g, b;
|
|
if (mapMode == MapModes.Repeat)
|
|
{
|
|
var c = colors[i % srcCount];
|
|
r = c.X;
|
|
g = c.Y;
|
|
b = c.Z;
|
|
}
|
|
else if (mapMode == MapModes.Lerp && srcCount > 1 && ledCount > 1)
|
|
{
|
|
var t = i * lerpScale;
|
|
var lo = (int)t;
|
|
if (lo >= srcCount - 1)
|
|
{
|
|
var c = colors[srcCount - 1];
|
|
r = c.X;
|
|
g = c.Y;
|
|
b = c.Z;
|
|
}
|
|
else
|
|
{
|
|
var frac = t - lo;
|
|
var ca = colors[lo];
|
|
var cb = colors[lo + 1];
|
|
r = ca.X + (cb.X - ca.X) * frac;
|
|
g = ca.Y + (cb.Y - ca.Y) * frac;
|
|
b = ca.Z + (cb.Z - ca.Z) * frac;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Stretch (nearest-neighbor) and degenerate cases
|
|
var srcIdx = (int)((long)i * srcCount / ledCount);
|
|
if (srcIdx >= srcCount)
|
|
srcIdx = srcCount - 1;
|
|
var c = colors[srcIdx];
|
|
r = c.X;
|
|
g = c.Y;
|
|
b = c.Z;
|
|
}
|
|
|
|
var pixelStart = AdalightHeaderSize + i * 3;
|
|
buffer[pixelStart + rPos] = ToByte(r * brightness);
|
|
buffer[pixelStart + gPos] = ToByte(g * brightness);
|
|
buffer[pixelStart + bPos] = ToByte(b * brightness);
|
|
}
|
|
|
|
long loopElapsedTicks = 0;
|
|
if (logMessages)
|
|
{
|
|
loopElapsedTicks = Stopwatch.GetTimestamp() - loopStartTicks;
|
|
}
|
|
|
|
var writeStartTicks = logMessages ? Stopwatch.GetTimestamp() : 0L;
|
|
var ok = SerialConnectionManager.WriteBytes(portName, buffer, buffer.Length);
|
|
if (logMessages)
|
|
{
|
|
var writeElapsedTicks = Stopwatch.GetTimestamp() - writeStartTicks;
|
|
var loopMs = TicksToMilliseconds(loopElapsedTicks);
|
|
var writeMs = TicksToMilliseconds(writeElapsedTicks);
|
|
if (writeMs > 0.5 || loopMs > 0.5)
|
|
{
|
|
Log.Debug($"WLED Update slow: loop={loopMs:F2}ms write={writeMs:F2}ms ({buffer.Length}B, {ledCount} LEDs)", this);
|
|
}
|
|
}
|
|
|
|
if (!ok)
|
|
{
|
|
SetStatus($"Write to {portName} failed - connection lost", IStatusProvider.StatusLevel.Warning);
|
|
IsConnected.Value = false;
|
|
// No forced re-register here. If the writer thread truly died, it has closed
|
|
// the port; IsPortOpen() will return false next frame and the throttled retry
|
|
// path picks it up. Avoids a 100+ms main-thread teardown on transient hiccups.
|
|
}
|
|
}
|
|
|
|
private static double TicksToMilliseconds(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
|
|
|
|
private void EnsureBuffer(int ledCount)
|
|
{
|
|
var requiredSize = AdalightHeaderSize + ledCount * 3;
|
|
if (_frameBuffer != null && _bufferLedCount == ledCount && _frameBuffer.Length == requiredSize)
|
|
return;
|
|
|
|
_frameBuffer = new byte[requiredSize];
|
|
_bufferLedCount = ledCount;
|
|
|
|
// Adalight header: 'A','d','a', countHi, countLo, checksum
|
|
var countMinus1 = ledCount - 1;
|
|
var hi = (byte)((countMinus1 >> 8) & 0xFF);
|
|
var lo = (byte)(countMinus1 & 0xFF);
|
|
_frameBuffer[0] = 0x41;
|
|
_frameBuffer[1] = 0x64;
|
|
_frameBuffer[2] = 0x61;
|
|
_frameBuffer[3] = hi;
|
|
_frameBuffer[4] = lo;
|
|
_frameBuffer[5] = (byte)(hi ^ lo ^ 0x55);
|
|
}
|
|
|
|
private static byte ToByte(float v)
|
|
{
|
|
if (v <= 0f)
|
|
return 0;
|
|
if (v >= 1f)
|
|
return 255;
|
|
return (byte)(v * 255f + 0.5f);
|
|
}
|
|
|
|
public void Dispose() { SerialConnectionManager.Unregister(this, PortName.Value); }
|
|
|
|
private string? _statusMessage;
|
|
private IStatusProvider.StatusLevel _statusLevel = IStatusProvider.StatusLevel.Notice;
|
|
public void SetStatus(string m, IStatusProvider.StatusLevel l) { _statusMessage = m; _statusLevel = l; }
|
|
public IStatusProvider.StatusLevel GetStatusLevel() => _statusLevel;
|
|
public string? GetStatusMessage() => _statusMessage;
|
|
|
|
string ICustomDropdownHolder.GetValueForInput(Guid id) => id == PortName.Id ? PortName.Value ?? string.Empty : string.Empty;
|
|
IEnumerable<string> ICustomDropdownHolder.GetOptionsForInput(Guid id) => id == PortName.Id ? SerialConnectionManager.GetAvailableSerialPortsWithDescriptions() : Enumerable.Empty<string>();
|
|
void ICustomDropdownHolder.HandleResultForInput(Guid id, string? s, bool i)
|
|
{
|
|
if (string.IsNullOrEmpty(s) || !i || id != PortName.Id)
|
|
return;
|
|
PortName.SetTypedInputValue(SerialConnectionManager.GetPortNameFromDeviceDescription(s));
|
|
}
|
|
|
|
private const int AdalightHeaderSize = 6;
|
|
private const int MaxLedCount = 4096;
|
|
private static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(2);
|
|
|
|
[Input(Guid = "166106D5-3354-440A-A37F-B21DDC49E597")] public readonly InputSlot<List<Vector4>> Colors = new();
|
|
[Input(Guid = "2DF165C3-6441-444F-ABE1-2D1915794710")] public readonly InputSlot<int> LedCount = new(-1);
|
|
[Input(Guid = "0128D1C3-0CFA-4ED1-8C8C-406B72A410BE", MappedType = typeof(MapModes))] public readonly InputSlot<int> MapMode = new((int)MapModes.Stretch);
|
|
[Input(Guid = "6CFD3BAD-7C7B-45BC-9AB6-E7C5B30CB0B1", MappedType = typeof(ColorOrders))] public readonly InputSlot<int> ColorOrder = new((int)ColorOrders.GRB);
|
|
[Input(Guid = "A9E27361-77AC-48FC-AE09-4BE0B2FF933B")] public readonly InputSlot<float> Brightness = new(1f);
|
|
[Input(Guid = "8A58F4B4-3250-4344-AF5C-346929ED2E6F")] public readonly InputSlot<string> PortName = new();
|
|
[Input(Guid = "BA4441FB-625E-4F49-8E9B-6C5237875F2B")] public readonly InputSlot<int> BaudRate = new(115200);
|
|
[Input(Guid = "AFF14DDF-0A72-4EE3-BE79-EA44102DDD4F")] public readonly InputSlot<bool> Sending = new(true);
|
|
[Input(Guid = "AD110199-D20A-48A8-8DC2-892E67E7A6AF")] public readonly InputSlot<bool> Reconnect = new();
|
|
[Input(Guid = "72CFDEDC-7999-43C6-9615-BD4CAE0A8903")] public readonly InputSlot<bool> LogMessages = new();
|
|
}
|