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 { /// /// 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. /// 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 _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 _lastLogTicks = new Dictionary(); private readonly TimeSpan _logThrottle = TimeSpan.FromSeconds(5); /// /// Mantém compatibilidade com as chamadas existentes de dois argumentos. /// Os parâmetros adicionais são opcionais. /// public TcpVideoReceiver( int port, System.Windows.Controls.Image imageControl, Action 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; } } /// /// Inicia listener, decoder e ciclo de recepção. /// Pode ser chamado novamente após Parar(). /// 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}."); } } /// /// Para listener, conexão e workers. É seguro chamar mais de uma vez. /// 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. } /// /// Snapshot thread-safe para logs, UI ou telemetria da base. /// 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; } } }