1080 lines
37 KiB
C#
1080 lines
37 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Threading;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
|
|
namespace OperationControl.Models
|
|
{
|
|
/// <summary>
|
|
/// Receptor TCP de vídeo orientado a operação de campo.
|
|
///
|
|
/// Protocolo preservado e compatível com CameraTcpStreamer do rover:
|
|
/// 4 bytes uint32 big-endian com o tamanho do JPEG
|
|
/// N bytes contendo o JPEG
|
|
///
|
|
/// Princípios desta implementação:
|
|
/// 1. A thread de rede apenas recebe e enquadra os JPEGs.
|
|
/// 2. Existe no máximo um JPEG pendente para decode (latest-only).
|
|
/// 3. O decode ocorre fora da thread de rede e fora da UI.
|
|
/// 4. Existe no máximo um Bitmap pendente para a UI.
|
|
/// 5. A UI nunca recebe uma fila ilimitada de BeginInvoke.
|
|
/// 6. Uma conexão nova substitui imediatamente uma conexão antiga/zumbi.
|
|
/// 7. Tamanhos inválidos são rejeitados antes de qualquer alocação grande.
|
|
/// 8. Parada, reinício e reconexão são idempotentes e thread-safe.
|
|
/// </summary>
|
|
public sealed class TcpVideoReceiver : IDisposable
|
|
{
|
|
private const int HeaderSize = 4;
|
|
private const int MinJpegBytes = 32;
|
|
private const int DefaultMaxFrameBytes = 4 * 1024 * 1024;
|
|
private const int DefaultReceiveTimeoutMs = 7000;
|
|
private const int DefaultReceiveBufferBytes = 256 * 1024;
|
|
|
|
private readonly int _port;
|
|
private readonly System.Windows.Controls.Image _imageControl;
|
|
private readonly Action<string> _mostrarLog;
|
|
private readonly int _maxFrameBytes;
|
|
private readonly int _receiveTimeoutMs;
|
|
|
|
public readonly string imageName;
|
|
|
|
private readonly object _lifecycleLock = new object();
|
|
private readonly object _clientLock = new object();
|
|
private readonly object _metricsLock = new object();
|
|
private readonly object _logLock = new object();
|
|
|
|
private readonly AutoResetEvent _frameReady = new AutoResetEvent(false);
|
|
|
|
private volatile bool _rodando;
|
|
private volatile bool _disposed;
|
|
|
|
private int _runGeneration;
|
|
private int _sessionSequence;
|
|
private int _activeSessionId;
|
|
|
|
private Thread _acceptThread;
|
|
private Thread _decodeThread;
|
|
private Thread _clientThread;
|
|
|
|
private TcpListener _listener;
|
|
private TcpClient _activeClient;
|
|
|
|
// Slot latest-only entre rede e decode.
|
|
private FramePacket _pendingFrame;
|
|
|
|
// Slot latest-only entre decode e UI.
|
|
private UiFrame _pendingUiFrame;
|
|
private int _uiDispatchScheduled;
|
|
|
|
// Métricas atômicas.
|
|
private long _connectionsAccepted;
|
|
private long _connectionsReplaced;
|
|
private long _disconnects;
|
|
private long _framesReceived;
|
|
private long _framesDecoded;
|
|
private long _framesDisplayed;
|
|
private long _framesDroppedPending;
|
|
private long _framesDroppedUi;
|
|
private long _framesInvalidSize;
|
|
private long _framesInvalidJpeg;
|
|
private long _framesDecodeErrors;
|
|
private long _receiveErrors;
|
|
private long _bytesReceived;
|
|
|
|
// Métricas compostas protegidas por lock.
|
|
private bool _clientConnected;
|
|
private string _remoteEndpoint;
|
|
private string _lastError;
|
|
private DateTime? _connectedSinceUtc;
|
|
private DateTime? _lastFrameReceivedUtc;
|
|
private DateTime? _lastFrameDisplayedUtc;
|
|
private double _receiveKbpsEma;
|
|
private double _receiveFpsEma;
|
|
private double _decodeMsEma;
|
|
private long _lastFrameReceivedTicks;
|
|
|
|
private readonly Dictionary<string, long> _lastLogTicks = new Dictionary<string, long>();
|
|
private readonly TimeSpan _logThrottle = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>
|
|
/// Mantém compatibilidade com as chamadas existentes de dois argumentos.
|
|
/// Os parâmetros adicionais são opcionais.
|
|
/// </summary>
|
|
public TcpVideoReceiver(
|
|
int port,
|
|
System.Windows.Controls.Image imageControl,
|
|
Action<string> mostrarLog = null,
|
|
int maxFrameBytes = DefaultMaxFrameBytes,
|
|
int receiveTimeoutMs = DefaultReceiveTimeoutMs)
|
|
{
|
|
if (port <= 0 || port > 65535)
|
|
throw new ArgumentOutOfRangeException(nameof(port));
|
|
|
|
if (imageControl == null)
|
|
throw new ArgumentNullException(nameof(imageControl));
|
|
|
|
if (maxFrameBytes < 64 * 1024)
|
|
throw new ArgumentOutOfRangeException(nameof(maxFrameBytes));
|
|
|
|
if (receiveTimeoutMs < 2000)
|
|
throw new ArgumentOutOfRangeException(nameof(receiveTimeoutMs));
|
|
|
|
_port = port;
|
|
_imageControl = imageControl;
|
|
_mostrarLog = mostrarLog ?? (msg => Console.WriteLine(msg));
|
|
_maxFrameBytes = maxFrameBytes;
|
|
_receiveTimeoutMs = receiveTimeoutMs;
|
|
|
|
imageName = imageControl.Name;
|
|
}
|
|
|
|
public bool Rodando => _rodando;
|
|
|
|
public bool ClienteConectado
|
|
{
|
|
get
|
|
{
|
|
lock (_metricsLock)
|
|
return _clientConnected;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inicia listener, decoder e ciclo de recepção.
|
|
/// Pode ser chamado novamente após Parar().
|
|
/// </summary>
|
|
public void Iniciar()
|
|
{
|
|
ThrowIfDisposed();
|
|
|
|
lock (_lifecycleLock)
|
|
{
|
|
if (_rodando)
|
|
return;
|
|
|
|
TcpListener listener = null;
|
|
|
|
try
|
|
{
|
|
listener = new TcpListener(IPAddress.Any, _port);
|
|
|
|
// Impede duas instâncias locais de ocuparem silenciosamente
|
|
// a mesma porta no Windows.
|
|
try
|
|
{
|
|
listener.Server.ExclusiveAddressUse = true;
|
|
}
|
|
catch
|
|
{
|
|
// Best effort.
|
|
}
|
|
|
|
listener.Start(4);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { listener?.Stop(); } catch { }
|
|
SetLastError("Falha ao iniciar listener: " + ex.Message);
|
|
Log($"[VideoReceiver:{imageName}] Falha ao ouvir porta {_port}: {ex.Message}");
|
|
return;
|
|
}
|
|
|
|
ResetPendingSlots();
|
|
ResetSessionMetrics();
|
|
|
|
int generation = Interlocked.Increment(ref _runGeneration);
|
|
|
|
_listener = listener;
|
|
_rodando = true;
|
|
|
|
_decodeThread = new Thread(() => DecodeLoop(generation))
|
|
{
|
|
IsBackground = true,
|
|
Name = $"video-decode-{imageName}-{_port}"
|
|
};
|
|
|
|
_acceptThread = new Thread(() => AcceptLoop(generation))
|
|
{
|
|
IsBackground = true,
|
|
Name = $"video-accept-{imageName}-{_port}"
|
|
};
|
|
|
|
_decodeThread.Start();
|
|
_acceptThread.Start();
|
|
|
|
Log($"[VideoReceiver:{imageName}] Ouvindo na porta {_port}.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Para listener, conexão e workers. É seguro chamar mais de uma vez.
|
|
/// </summary>
|
|
public void Parar()
|
|
{
|
|
Thread acceptThread;
|
|
Thread decodeThread;
|
|
Thread clientThread;
|
|
TcpListener listener;
|
|
TcpClient client;
|
|
int stoppedGeneration;
|
|
|
|
lock (_lifecycleLock)
|
|
{
|
|
if (!_rodando)
|
|
{
|
|
ScheduleImageClear(Volatile.Read(ref _runGeneration));
|
|
return;
|
|
}
|
|
|
|
_rodando = false;
|
|
stoppedGeneration = Interlocked.Increment(ref _runGeneration);
|
|
|
|
listener = _listener;
|
|
_listener = null;
|
|
|
|
acceptThread = _acceptThread;
|
|
decodeThread = _decodeThread;
|
|
_acceptThread = null;
|
|
_decodeThread = null;
|
|
|
|
lock (_clientLock)
|
|
{
|
|
client = _activeClient;
|
|
_activeClient = null;
|
|
_activeSessionId = 0;
|
|
clientThread = _clientThread;
|
|
_clientThread = null;
|
|
}
|
|
}
|
|
|
|
try { listener?.Stop(); } catch { }
|
|
CloseClient(client);
|
|
|
|
// Libera imediatamente o decoder caso esteja aguardando.
|
|
_frameReady.Set();
|
|
|
|
JoinThread(acceptThread, 1500);
|
|
JoinThread(clientThread, 1500);
|
|
JoinThread(decodeThread, 1500);
|
|
|
|
ResetPendingSlots();
|
|
|
|
lock (_metricsLock)
|
|
{
|
|
_clientConnected = false;
|
|
_remoteEndpoint = null;
|
|
_connectedSinceUtc = null;
|
|
}
|
|
|
|
ScheduleImageClear(stoppedGeneration);
|
|
Log($"[VideoReceiver:{imageName}] Receptor parado.");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
_disposed = true;
|
|
Parar();
|
|
|
|
// O AutoResetEvent é intencionalmente mantido até a coleta da
|
|
// instância. Isso evita uma corrida rara com uma thread encerrando
|
|
// após o timeout defensivo de Join.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot thread-safe para logs, UI ou telemetria da base.
|
|
/// </summary>
|
|
public TcpVideoReceiverMetrics GetMetrics()
|
|
{
|
|
bool connected;
|
|
string remote;
|
|
string lastError;
|
|
DateTime? connectedSince;
|
|
DateTime? lastReceived;
|
|
DateTime? lastDisplayed;
|
|
double kbps;
|
|
double fps;
|
|
double decodeMs;
|
|
|
|
lock (_metricsLock)
|
|
{
|
|
connected = _clientConnected;
|
|
remote = _remoteEndpoint;
|
|
lastError = _lastError;
|
|
connectedSince = _connectedSinceUtc;
|
|
lastReceived = _lastFrameReceivedUtc;
|
|
lastDisplayed = _lastFrameDisplayedUtc;
|
|
kbps = _receiveKbpsEma;
|
|
fps = _receiveFpsEma;
|
|
decodeMs = _decodeMsEma;
|
|
}
|
|
|
|
double lastFrameAgeMs = -1;
|
|
if (lastReceived.HasValue)
|
|
lastFrameAgeMs = Math.Max(0, (DateTime.UtcNow - lastReceived.Value).TotalMilliseconds);
|
|
|
|
return new TcpVideoReceiverMetrics
|
|
{
|
|
ImageName = imageName,
|
|
Port = _port,
|
|
Running = _rodando,
|
|
ClientConnected = connected,
|
|
RemoteEndpoint = remote,
|
|
ConnectedSinceUtc = connectedSince,
|
|
LastFrameReceivedUtc = lastReceived,
|
|
LastFrameDisplayedUtc = lastDisplayed,
|
|
LastFrameAgeMs = lastFrameAgeMs,
|
|
ReceiveKbps = kbps,
|
|
ReceiveFps = fps,
|
|
DecodeMs = decodeMs,
|
|
QueueDepth = Volatile.Read(ref _pendingFrame) == null ? 0 : 1,
|
|
UiQueueDepth = Volatile.Read(ref _pendingUiFrame) == null ? 0 : 1,
|
|
ConnectionsAccepted = Interlocked.Read(ref _connectionsAccepted),
|
|
ConnectionsReplaced = Interlocked.Read(ref _connectionsReplaced),
|
|
Disconnects = Interlocked.Read(ref _disconnects),
|
|
FramesReceived = Interlocked.Read(ref _framesReceived),
|
|
FramesDecoded = Interlocked.Read(ref _framesDecoded),
|
|
FramesDisplayed = Interlocked.Read(ref _framesDisplayed),
|
|
FramesDroppedPending = Interlocked.Read(ref _framesDroppedPending),
|
|
FramesDroppedUi = Interlocked.Read(ref _framesDroppedUi),
|
|
FramesInvalidSize = Interlocked.Read(ref _framesInvalidSize),
|
|
FramesInvalidJpeg = Interlocked.Read(ref _framesInvalidJpeg),
|
|
FramesDecodeErrors = Interlocked.Read(ref _framesDecodeErrors),
|
|
ReceiveErrors = Interlocked.Read(ref _receiveErrors),
|
|
BytesReceived = Interlocked.Read(ref _bytesReceived),
|
|
LastError = lastError
|
|
};
|
|
}
|
|
|
|
// =================================================================
|
|
// ACCEPT / CLIENT SESSION
|
|
// =================================================================
|
|
|
|
private void AcceptLoop(int generation)
|
|
{
|
|
while (IsRunActive(generation))
|
|
{
|
|
TcpClient client = null;
|
|
|
|
try
|
|
{
|
|
TcpListener listener = _listener;
|
|
if (listener == null)
|
|
break;
|
|
|
|
// Stop() em Parar() libera este bloqueio imediatamente.
|
|
client = listener.AcceptTcpClient();
|
|
|
|
if (!IsRunActive(generation))
|
|
{
|
|
CloseClient(client);
|
|
break;
|
|
}
|
|
|
|
ConfigureClient(client);
|
|
StartClientSession(client, generation);
|
|
client = null; // a sessão assumiu a propriedade
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
CloseClient(client);
|
|
|
|
if (!IsRunActive(generation))
|
|
break;
|
|
|
|
Interlocked.Increment(ref _receiveErrors);
|
|
SetLastError("Erro no Accept: " + ex.Message);
|
|
LogThrottled("accept_socket", $"[VideoReceiver:{imageName}] Erro ao aceitar conexão: {ex.Message}");
|
|
Thread.Sleep(250);
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
CloseClient(client);
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CloseClient(client);
|
|
|
|
if (!IsRunActive(generation))
|
|
break;
|
|
|
|
Interlocked.Increment(ref _receiveErrors);
|
|
SetLastError("Erro geral no Accept: " + ex.Message);
|
|
LogThrottled("accept_general", $"[VideoReceiver:{imageName}] Erro no listener: {ex.Message}");
|
|
Thread.Sleep(500);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void StartClientSession(TcpClient client, int generation)
|
|
{
|
|
int sessionId = Interlocked.Increment(ref _sessionSequence);
|
|
TcpClient oldClient;
|
|
|
|
var sessionThread = new Thread(() => ClientReadLoop(client, generation, sessionId))
|
|
{
|
|
IsBackground = true,
|
|
Name = $"video-client-{imageName}-{_port}-{sessionId}"
|
|
};
|
|
|
|
lock (_clientLock)
|
|
{
|
|
if (!IsRunActive(generation))
|
|
{
|
|
CloseClient(client);
|
|
return;
|
|
}
|
|
|
|
oldClient = _activeClient;
|
|
if (oldClient != null)
|
|
Interlocked.Increment(ref _connectionsReplaced);
|
|
|
|
_activeClient = client;
|
|
_activeSessionId = sessionId;
|
|
_clientThread = sessionThread;
|
|
}
|
|
|
|
// Uma conexão mais nova sempre vence. Isso elimina sessões zumbis
|
|
// que ainda pareciam conectadas para o sistema operacional.
|
|
CloseClient(oldClient);
|
|
|
|
string remote = SafeRemoteEndpoint(client);
|
|
|
|
lock (_metricsLock)
|
|
{
|
|
_clientConnected = true;
|
|
_remoteEndpoint = remote;
|
|
_connectedSinceUtc = DateTime.UtcNow;
|
|
_lastError = null;
|
|
}
|
|
|
|
Interlocked.Increment(ref _connectionsAccepted);
|
|
|
|
// Mesmo que Parar() aconteça neste pequeno intervalo, a thread
|
|
// inicia, detecta a geração inválida e encerra sem tocar na UI.
|
|
sessionThread.Start();
|
|
Log($"[VideoReceiver:{imageName}] Cliente conectado: {remote}.");
|
|
}
|
|
|
|
private void ClientReadLoop(TcpClient client, int generation, int sessionId)
|
|
{
|
|
string disconnectReason = null;
|
|
|
|
try
|
|
{
|
|
using (NetworkStream stream = client.GetStream())
|
|
{
|
|
byte[] header = new byte[HeaderSize];
|
|
|
|
while (IsSessionActive(client, generation, sessionId))
|
|
{
|
|
if (!ReadExactly(stream, header, 0, HeaderSize, client, generation, sessionId))
|
|
{
|
|
disconnectReason = "fim do stream";
|
|
break;
|
|
}
|
|
|
|
int size = ReadBigEndianInt32(header);
|
|
|
|
// Um tamanho inválido significa que não é seguro tentar
|
|
// continuar no mesmo fluxo: o enquadramento pode estar perdido.
|
|
if (size < MinJpegBytes || size > _maxFrameBytes)
|
|
{
|
|
Interlocked.Increment(ref _framesInvalidSize);
|
|
disconnectReason = $"tamanho de frame inválido: {size} bytes";
|
|
SetLastError(disconnectReason);
|
|
break;
|
|
}
|
|
|
|
byte[] jpeg = new byte[size];
|
|
|
|
if (!ReadExactly(stream, jpeg, 0, size, client, generation, sessionId))
|
|
{
|
|
disconnectReason = "frame incompleto";
|
|
break;
|
|
}
|
|
|
|
Interlocked.Add(ref _bytesReceived, size + HeaderSize);
|
|
|
|
if (!LooksLikeJpeg(jpeg))
|
|
{
|
|
// O tamanho foi consumido corretamente, portanto podemos
|
|
// descartar este frame e continuar no próximo cabeçalho.
|
|
Interlocked.Increment(ref _framesInvalidJpeg);
|
|
SetLastError("JPEG sem marcadores SOI/EOI válidos.");
|
|
continue;
|
|
}
|
|
|
|
RegisterReceivedFrame(size);
|
|
|
|
var packet = new FramePacket
|
|
{
|
|
Bytes = jpeg,
|
|
Generation = generation,
|
|
SessionId = sessionId,
|
|
ReceivedUtc = DateTime.UtcNow
|
|
};
|
|
|
|
FramePacket replaced = Interlocked.Exchange(ref _pendingFrame, packet);
|
|
if (replaced != null)
|
|
Interlocked.Increment(ref _framesDroppedPending);
|
|
|
|
_frameReady.Set();
|
|
}
|
|
}
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
if (IsSessionActive(client, generation, sessionId))
|
|
{
|
|
Interlocked.Increment(ref _receiveErrors);
|
|
disconnectReason = IsSocketTimeout(ex)
|
|
? $"sem frames por {_receiveTimeoutMs} ms"
|
|
: ex.Message;
|
|
SetLastError(disconnectReason);
|
|
LogThrottled("client_io", $"[VideoReceiver:{imageName}] Sessão interrompida: {disconnectReason}");
|
|
}
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
if (IsSessionActive(client, generation, sessionId))
|
|
{
|
|
Interlocked.Increment(ref _receiveErrors);
|
|
disconnectReason = ex.Message;
|
|
SetLastError(disconnectReason);
|
|
LogThrottled("client_socket", $"[VideoReceiver:{imageName}] Erro de socket: {ex.Message}");
|
|
}
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// Normal quando uma conexão nova substitui a antiga ou ao parar.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (IsSessionActive(client, generation, sessionId))
|
|
{
|
|
Interlocked.Increment(ref _receiveErrors);
|
|
disconnectReason = ex.Message;
|
|
SetLastError(disconnectReason);
|
|
LogThrottled("client_general", $"[VideoReceiver:{imageName}] Erro ao receber vídeo: {ex.Message}");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
bool wasActive = false;
|
|
|
|
lock (_clientLock)
|
|
{
|
|
if (_activeClient == client && _activeSessionId == sessionId)
|
|
{
|
|
_activeClient = null;
|
|
_activeSessionId = 0;
|
|
_clientThread = null;
|
|
wasActive = true;
|
|
}
|
|
}
|
|
|
|
CloseClient(client);
|
|
|
|
if (wasActive)
|
|
{
|
|
lock (_metricsLock)
|
|
{
|
|
_clientConnected = false;
|
|
_remoteEndpoint = null;
|
|
_connectedSinceUtc = null;
|
|
}
|
|
|
|
Interlocked.Increment(ref _disconnects);
|
|
|
|
// Fecha apenas o estado da sessão. O listener permanece pronto
|
|
// para o sender Python reconectar após seu backoff.
|
|
Log($"[VideoReceiver:{imageName}] Cliente desconectado{FormatReason(disconnectReason)}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// DECODE / UI LATEST-ONLY
|
|
// =================================================================
|
|
|
|
private void DecodeLoop(int generation)
|
|
{
|
|
while (IsRunActive(generation))
|
|
{
|
|
_frameReady.WaitOne(500);
|
|
|
|
if (!IsRunActive(generation))
|
|
break;
|
|
|
|
FramePacket packet = Interlocked.Exchange(ref _pendingFrame, null);
|
|
if (packet == null)
|
|
continue;
|
|
|
|
// Descarta frames pertencentes a uma execução anterior.
|
|
if (packet.Generation != generation)
|
|
continue;
|
|
|
|
Stopwatch sw = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
BitmapImage bitmap;
|
|
|
|
using (var ms = new MemoryStream(packet.Bytes, false))
|
|
{
|
|
bitmap = new BitmapImage();
|
|
bitmap.BeginInit();
|
|
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
|
bitmap.CreateOptions = BitmapCreateOptions.None;
|
|
bitmap.StreamSource = ms;
|
|
bitmap.EndInit();
|
|
bitmap.Freeze();
|
|
}
|
|
|
|
sw.Stop();
|
|
RegisterDecodedFrame(sw.Elapsed.TotalMilliseconds);
|
|
QueueBitmapForUi(bitmap, generation, packet.ReceivedUtc);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
sw.Stop();
|
|
Interlocked.Increment(ref _framesDecodeErrors);
|
|
SetLastError("Falha ao decodificar JPEG: " + ex.Message);
|
|
LogThrottled("decode", $"[VideoReceiver:{imageName}] JPEG inválido ou erro de decode: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void QueueBitmapForUi(BitmapSource bitmap, int generation, DateTime receivedUtc)
|
|
{
|
|
var uiFrame = new UiFrame
|
|
{
|
|
Bitmap = bitmap,
|
|
Generation = generation,
|
|
ReceivedUtc = receivedUtc
|
|
};
|
|
|
|
UiFrame replaced = Interlocked.Exchange(ref _pendingUiFrame, uiFrame);
|
|
if (replaced != null)
|
|
Interlocked.Increment(ref _framesDroppedUi);
|
|
|
|
TryScheduleUiDispatch();
|
|
}
|
|
|
|
private void TryScheduleUiDispatch()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _uiDispatchScheduled, 1, 0) != 0)
|
|
return;
|
|
|
|
try
|
|
{
|
|
if (_imageControl.Dispatcher.HasShutdownStarted ||
|
|
_imageControl.Dispatcher.HasShutdownFinished)
|
|
{
|
|
Interlocked.Exchange(ref _uiDispatchScheduled, 0);
|
|
return;
|
|
}
|
|
|
|
_imageControl.Dispatcher.BeginInvoke(
|
|
DispatcherPriority.Render,
|
|
new Action(ApplyLatestBitmapOnUi));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Interlocked.Exchange(ref _uiDispatchScheduled, 0);
|
|
SetLastError("Falha ao agendar atualização da UI: " + ex.Message);
|
|
}
|
|
}
|
|
|
|
private void ApplyLatestBitmapOnUi()
|
|
{
|
|
try
|
|
{
|
|
UiFrame frame = Interlocked.Exchange(ref _pendingUiFrame, null);
|
|
int currentGeneration = Volatile.Read(ref _runGeneration);
|
|
|
|
if (frame != null &&
|
|
_rodando &&
|
|
frame.Generation == currentGeneration)
|
|
{
|
|
_imageControl.Source = frame.Bitmap;
|
|
|
|
Interlocked.Increment(ref _framesDisplayed);
|
|
|
|
lock (_metricsLock)
|
|
_lastFrameDisplayedUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SetLastError("Falha ao atualizar imagem na UI: " + ex.Message);
|
|
LogThrottled("ui", $"[VideoReceiver:{imageName}] Falha ao atualizar UI: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _uiDispatchScheduled, 0);
|
|
|
|
// Se outro bitmap chegou enquanto a UI trabalhava, agenda apenas
|
|
// mais uma atualização, sempre com o bitmap mais recente.
|
|
if (_rodando && Volatile.Read(ref _pendingUiFrame) != null)
|
|
TryScheduleUiDispatch();
|
|
}
|
|
}
|
|
|
|
private void ScheduleImageClear(int stoppedGeneration)
|
|
{
|
|
try
|
|
{
|
|
Action clear = () =>
|
|
{
|
|
// Não limpa uma imagem de uma execução nova iniciada antes
|
|
// de este callback antigo chegar ao Dispatcher.
|
|
if (!_rodando && Volatile.Read(ref _runGeneration) == stoppedGeneration)
|
|
_imageControl.Source = null;
|
|
};
|
|
|
|
if (_imageControl.Dispatcher.CheckAccess())
|
|
{
|
|
clear();
|
|
}
|
|
else if (!_imageControl.Dispatcher.HasShutdownStarted &&
|
|
!_imageControl.Dispatcher.HasShutdownFinished)
|
|
{
|
|
_imageControl.Dispatcher.BeginInvoke(DispatcherPriority.Normal, clear);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Encerramento da UI não deve derrubar o serviço.
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// NETWORK HELPERS
|
|
// =================================================================
|
|
|
|
private void ConfigureClient(TcpClient client)
|
|
{
|
|
client.NoDelay = true;
|
|
client.ReceiveTimeout = _receiveTimeoutMs;
|
|
client.ReceiveBufferSize = DefaultReceiveBufferBytes;
|
|
|
|
try
|
|
{
|
|
client.Client.SetSocketOption(
|
|
SocketOptionLevel.Socket,
|
|
SocketOptionName.KeepAlive,
|
|
true);
|
|
}
|
|
catch
|
|
{
|
|
// Best effort.
|
|
}
|
|
|
|
// Keepalive mais curto no Windows. Se a plataforma ou runtime não
|
|
// suportar IOControl, o keepalive padrão continua ativo.
|
|
try
|
|
{
|
|
byte[] keepAlive = new byte[12];
|
|
BitConverter.GetBytes((uint)1).CopyTo(keepAlive, 0);
|
|
BitConverter.GetBytes((uint)10000).CopyTo(keepAlive, 4);
|
|
BitConverter.GetBytes((uint)3000).CopyTo(keepAlive, 8);
|
|
|
|
client.Client.IOControl(
|
|
IOControlCode.KeepAliveValues,
|
|
keepAlive,
|
|
null);
|
|
}
|
|
catch
|
|
{
|
|
// Best effort.
|
|
}
|
|
}
|
|
|
|
private bool ReadExactly(
|
|
NetworkStream stream,
|
|
byte[] buffer,
|
|
int offset,
|
|
int count,
|
|
TcpClient client,
|
|
int generation,
|
|
int sessionId)
|
|
{
|
|
int total = 0;
|
|
|
|
while (total < count)
|
|
{
|
|
if (!IsSessionActive(client, generation, sessionId))
|
|
return false;
|
|
|
|
int read = stream.Read(buffer, offset + total, count - total);
|
|
if (read <= 0)
|
|
return false;
|
|
|
|
total += read;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool IsSessionActive(TcpClient client, int generation, int sessionId)
|
|
{
|
|
if (!IsRunActive(generation))
|
|
return false;
|
|
|
|
lock (_clientLock)
|
|
{
|
|
return _activeClient == client &&
|
|
_activeSessionId == sessionId;
|
|
}
|
|
}
|
|
|
|
private bool IsRunActive(int generation)
|
|
{
|
|
return _rodando && Volatile.Read(ref _runGeneration) == generation;
|
|
}
|
|
|
|
private static int ReadBigEndianInt32(byte[] header)
|
|
{
|
|
return (header[0] << 24) |
|
|
(header[1] << 16) |
|
|
(header[2] << 8) |
|
|
header[3];
|
|
}
|
|
|
|
private static bool LooksLikeJpeg(byte[] bytes)
|
|
{
|
|
if (bytes == null || bytes.Length < MinJpegBytes)
|
|
return false;
|
|
|
|
return bytes[0] == 0xFF &&
|
|
bytes[1] == 0xD8 &&
|
|
bytes[bytes.Length - 2] == 0xFF &&
|
|
bytes[bytes.Length - 1] == 0xD9;
|
|
}
|
|
|
|
private static bool IsSocketTimeout(IOException ex)
|
|
{
|
|
var socketEx = ex.InnerException as SocketException;
|
|
return socketEx != null && socketEx.SocketErrorCode == SocketError.TimedOut;
|
|
}
|
|
|
|
private static void CloseClient(TcpClient client)
|
|
{
|
|
if (client == null)
|
|
return;
|
|
|
|
try { client.Client?.Shutdown(SocketShutdown.Both); } catch { }
|
|
try { client.Close(); } catch { }
|
|
}
|
|
|
|
private static void JoinThread(Thread thread, int timeoutMs)
|
|
{
|
|
if (thread == null || thread == Thread.CurrentThread)
|
|
return;
|
|
|
|
try { thread.Join(timeoutMs); } catch { }
|
|
}
|
|
|
|
private static string SafeRemoteEndpoint(TcpClient client)
|
|
{
|
|
try
|
|
{
|
|
return client?.Client?.RemoteEndPoint?.ToString() ?? "desconhecido";
|
|
}
|
|
catch
|
|
{
|
|
return "desconhecido";
|
|
}
|
|
}
|
|
|
|
private static string FormatReason(string reason)
|
|
{
|
|
return string.IsNullOrWhiteSpace(reason) ? "" : $" ({reason})";
|
|
}
|
|
|
|
// =================================================================
|
|
// METRICS / LOG
|
|
// =================================================================
|
|
|
|
private void RegisterReceivedFrame(int jpegBytes)
|
|
{
|
|
long nowTicks = Stopwatch.GetTimestamp();
|
|
DateTime nowUtc = DateTime.UtcNow;
|
|
|
|
Interlocked.Increment(ref _framesReceived);
|
|
|
|
lock (_metricsLock)
|
|
{
|
|
if (_lastFrameReceivedTicks > 0)
|
|
{
|
|
double dt = (nowTicks - _lastFrameReceivedTicks) /
|
|
(double)Stopwatch.Frequency;
|
|
|
|
if (dt > 0.0001)
|
|
{
|
|
double fps = 1.0 / dt;
|
|
double kbps = (jpegBytes * 8.0) / dt / 1000.0;
|
|
|
|
_receiveFpsEma = Ema(_receiveFpsEma, fps, 0.20);
|
|
_receiveKbpsEma = Ema(_receiveKbpsEma, kbps, 0.20);
|
|
}
|
|
}
|
|
|
|
_lastFrameReceivedTicks = nowTicks;
|
|
_lastFrameReceivedUtc = nowUtc;
|
|
_lastError = null;
|
|
}
|
|
}
|
|
|
|
private void RegisterDecodedFrame(double decodeMs)
|
|
{
|
|
Interlocked.Increment(ref _framesDecoded);
|
|
|
|
lock (_metricsLock)
|
|
_decodeMsEma = Ema(_decodeMsEma, decodeMs, 0.20);
|
|
}
|
|
|
|
private static double Ema(double previous, double current, double alpha)
|
|
{
|
|
if (previous <= 0)
|
|
return current;
|
|
|
|
return previous * (1.0 - alpha) + current * alpha;
|
|
}
|
|
|
|
private void SetLastError(string error)
|
|
{
|
|
lock (_metricsLock)
|
|
_lastError = error;
|
|
}
|
|
|
|
private void ResetSessionMetrics()
|
|
{
|
|
Interlocked.Exchange(ref _connectionsAccepted, 0);
|
|
Interlocked.Exchange(ref _connectionsReplaced, 0);
|
|
Interlocked.Exchange(ref _disconnects, 0);
|
|
Interlocked.Exchange(ref _framesReceived, 0);
|
|
Interlocked.Exchange(ref _framesDecoded, 0);
|
|
Interlocked.Exchange(ref _framesDisplayed, 0);
|
|
Interlocked.Exchange(ref _framesDroppedPending, 0);
|
|
Interlocked.Exchange(ref _framesDroppedUi, 0);
|
|
Interlocked.Exchange(ref _framesInvalidSize, 0);
|
|
Interlocked.Exchange(ref _framesInvalidJpeg, 0);
|
|
Interlocked.Exchange(ref _framesDecodeErrors, 0);
|
|
Interlocked.Exchange(ref _receiveErrors, 0);
|
|
Interlocked.Exchange(ref _bytesReceived, 0);
|
|
|
|
lock (_metricsLock)
|
|
{
|
|
_clientConnected = false;
|
|
_remoteEndpoint = null;
|
|
_lastError = null;
|
|
_connectedSinceUtc = null;
|
|
_lastFrameReceivedUtc = null;
|
|
_lastFrameDisplayedUtc = null;
|
|
_receiveKbpsEma = 0;
|
|
_receiveFpsEma = 0;
|
|
_decodeMsEma = 0;
|
|
_lastFrameReceivedTicks = 0;
|
|
}
|
|
}
|
|
|
|
private void ResetPendingSlots()
|
|
{
|
|
Interlocked.Exchange(ref _pendingFrame, null);
|
|
Interlocked.Exchange(ref _pendingUiFrame, null);
|
|
Interlocked.Exchange(ref _uiDispatchScheduled, 0);
|
|
_frameReady.Reset();
|
|
}
|
|
|
|
private void Log(string message)
|
|
{
|
|
try { _mostrarLog(message); } catch { }
|
|
}
|
|
|
|
private void LogThrottled(string key, string message)
|
|
{
|
|
long now = Stopwatch.GetTimestamp();
|
|
bool shouldLog = false;
|
|
|
|
lock (_logLock)
|
|
{
|
|
long last;
|
|
if (!_lastLogTicks.TryGetValue(key, out last) ||
|
|
(now - last) / (double)Stopwatch.Frequency >= _logThrottle.TotalSeconds)
|
|
{
|
|
_lastLogTicks[key] = now;
|
|
shouldLog = true;
|
|
}
|
|
}
|
|
|
|
if (shouldLog)
|
|
Log(message);
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (_disposed)
|
|
throw new ObjectDisposedException(nameof(TcpVideoReceiver));
|
|
}
|
|
|
|
private sealed class FramePacket
|
|
{
|
|
public byte[] Bytes;
|
|
public int Generation;
|
|
public int SessionId;
|
|
public DateTime ReceivedUtc;
|
|
}
|
|
|
|
private sealed class UiFrame
|
|
{
|
|
public BitmapSource Bitmap;
|
|
public int Generation;
|
|
public DateTime ReceivedUtc;
|
|
}
|
|
}
|
|
|
|
public sealed class TcpVideoReceiverMetrics
|
|
{
|
|
public string ImageName { get; set; }
|
|
public int Port { get; set; }
|
|
public bool Running { get; set; }
|
|
public bool ClientConnected { get; set; }
|
|
public string RemoteEndpoint { get; set; }
|
|
public DateTime? ConnectedSinceUtc { get; set; }
|
|
public DateTime? LastFrameReceivedUtc { get; set; }
|
|
public DateTime? LastFrameDisplayedUtc { get; set; }
|
|
public double LastFrameAgeMs { get; set; }
|
|
public double ReceiveKbps { get; set; }
|
|
public double ReceiveFps { get; set; }
|
|
public double DecodeMs { get; set; }
|
|
public int QueueDepth { get; set; }
|
|
public int UiQueueDepth { get; set; }
|
|
public long ConnectionsAccepted { get; set; }
|
|
public long ConnectionsReplaced { get; set; }
|
|
public long Disconnects { get; set; }
|
|
public long FramesReceived { get; set; }
|
|
public long FramesDecoded { get; set; }
|
|
public long FramesDisplayed { get; set; }
|
|
public long FramesDroppedPending { get; set; }
|
|
public long FramesDroppedUi { get; set; }
|
|
public long FramesInvalidSize { get; set; }
|
|
public long FramesInvalidJpeg { get; set; }
|
|
public long FramesDecodeErrors { get; set; }
|
|
public long ReceiveErrors { get; set; }
|
|
public long BytesReceived { get; set; }
|
|
public string LastError { get; set; }
|
|
}
|
|
}
|