From 1153741e0b6ab49ef503907fdf024218d515bd63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sun, 24 Aug 2025 17:29:25 +0200 Subject: [PATCH] Implementing call monitor endpoint --- src/FritzCallMonitor/CallMonitorClient.cs | 154 ++++++++++++ src/FritzCallMonitor/Enums/EventType.cs | 34 +++ .../EventArgs/CallMonitorEventArgs.cs | 98 ++++++++ src/FritzCallMonitor/Utils/Extensions.cs | 21 ++ .../Utils/ReconnectTcpClient.cs | 222 ++++++++++++++++++ .../Wrappers/NetworkStreamWrapper.cs | 32 +++ .../Wrappers/SocketWrapper.cs | 25 ++ .../Wrappers/TcpClientWrapper.cs | 42 ++++ .../Wrappers/TcpClientWrapperFactory.cs | 18 ++ 9 files changed, 646 insertions(+) create mode 100644 src/FritzCallMonitor/CallMonitorClient.cs create mode 100644 src/FritzCallMonitor/Enums/EventType.cs create mode 100644 src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs create mode 100644 src/FritzCallMonitor/Utils/Extensions.cs create mode 100644 src/FritzCallMonitor/Utils/ReconnectTcpClient.cs create mode 100644 src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs create mode 100644 src/FritzCallMonitor/Wrappers/SocketWrapper.cs create mode 100644 src/FritzCallMonitor/Wrappers/TcpClientWrapper.cs create mode 100644 src/FritzCallMonitor/Wrappers/TcpClientWrapperFactory.cs diff --git a/src/FritzCallMonitor/CallMonitorClient.cs b/src/FritzCallMonitor/CallMonitorClient.cs new file mode 100644 index 0000000..4e4d157 --- /dev/null +++ b/src/FritzCallMonitor/CallMonitorClient.cs @@ -0,0 +1,154 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Fritz.CallMonitor.Utils; +using Microsoft.Extensions.Logging; + +namespace AMWD.Net.Api.Fritz.CallMonitor +{ + /// + /// Represents a client for monitoring call events from a FRITZ!Box. + /// + /// + /// The FRITZ!Box has a built-in realtime call monitoring feature that can be accessed via TCP on port 1012. + /// + public class CallMonitorClient : IDisposable + { + private bool _isDisposed; + + private ILogger? _logger; + private readonly ReconnectTcpClient _client; + private readonly CancellationTokenSource _disposeCts; + + private Task _monitorTask = Task.CompletedTask; + + /// + /// Initializes a new instance of the class. + /// + /// The hostname or IP address of the FRITZ!Box to monitor. + /// The port to connect to (Default: 1012). + /// The hostname is not set. + /// The port is not in valid range of 1 to 65535. + public CallMonitorClient(string host, int port = 1012) + { + if (string.IsNullOrWhiteSpace(host)) + throw new ArgumentNullException(nameof(host)); + + if (port <= ushort.MinValue || ushort.MaxValue < port) + throw new ArgumentOutOfRangeException(nameof(port)); + + _disposeCts = new CancellationTokenSource(); + _client = new ReconnectTcpClient(host, port) { OnConnected = OnConnected }; + + // Start the client in the background + _client.StartAsync(_disposeCts.Token).Forget(); + } + + /// + /// Occurs when a call monitoring event is raised. + /// + /// + /// The event provides details using the parameter. + /// + public event EventHandler? OnEvent; + + /// + /// Gets or sets a logger instance. + /// + public ILogger? Logger + { + get => _logger; + set + { + _logger = value; + _client.Logger = value; + } + } + + /// + /// Releases all resources used by the current instance of the . + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + _disposeCts.Cancel(); + + try + { + _monitorTask.Wait(); + } + catch + { } + + _client.Dispose(); + _disposeCts.Dispose(); + + GC.SuppressFinalize(this); + } + + private Task OnConnected(ReconnectTcpClient client) + { + _monitorTask = Task.Run(async () => + { + try + { + var stream = client.GetStream(); + if (stream == null) + return; + + string? buffer = null; + byte[] rawBuffer = new byte[4096]; + while (!_disposeCts.IsCancellationRequested && client.IsConnected) + { + try + { + int bytesRead = await stream.ReadAsync(rawBuffer, 0, rawBuffer.Length, _disposeCts.Token); + string data = Encoding.UTF8.GetString(rawBuffer, 0, bytesRead); + + if (buffer != null) + { + data = buffer + data; + buffer = null; + } + + if (!data.EndsWith("\n")) + { + buffer = data; + continue; + } + + string[] lines = data.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (string line in lines) + { + var eventArgs = CallMonitorEventArgs.Parse(line); + if (eventArgs == null) + continue; + + Task.Run(() => OnEvent?.Invoke(this, eventArgs), _disposeCts.Token).Forget(); + } + } + catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested) + { + // Client was stopped or disposed. + return; + } + catch (Exception ex) when (!_disposeCts.IsCancellationRequested) + { + Logger?.LogError(ex, "Error while reading from the call monitor stream."); + return; + } + } + } + catch + { } + }); + + return Task.CompletedTask; + } + } +} diff --git a/src/FritzCallMonitor/Enums/EventType.cs b/src/FritzCallMonitor/Enums/EventType.cs new file mode 100644 index 0000000..39df329 --- /dev/null +++ b/src/FritzCallMonitor/Enums/EventType.cs @@ -0,0 +1,34 @@ +using System.Runtime.Serialization; + +namespace AMWD.Net.Api.Fritz.CallMonitor +{ + /// + /// Represents the types of events that can occur during a call lifecycle on a FRITZ!Box. + /// + public enum EventType + { + /// + /// A call is incoming to the Fritz!Box. + /// + [EnumMember(Value = "RING")] + Ring = 1, + + /// + /// A call is connected - the parties are now talking. + /// + [EnumMember(Value = "CONNECT")] + Connect = 2, + + /// + /// A call is disconnected - one party has hung up. + /// + [EnumMember(Value = "DISCONNECT")] + Disconnect = 3, + + /// + /// A call is outgoing from the Fritz!Box. + /// + [EnumMember(Value = "CALL")] + Call = 4, + } +} diff --git a/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs b/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs new file mode 100644 index 0000000..8d02756 --- /dev/null +++ b/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs @@ -0,0 +1,98 @@ +using System; +using System.Globalization; + +namespace AMWD.Net.Api.Fritz.CallMonitor +{ + /// + /// Provides data for call monitoring events, including details about the call type, identifiers, and optional metadata. + /// + public class CallMonitorEventArgs : EventArgs + { + /// + /// Gets the timestamp of the event. + /// + public DateTimeOffset? Timestamp { get; private set; } + + /// + /// Gets the type of event. + /// + public EventType? Event { get; private set; } + + /// + /// Gets the connection ID. + /// + public int? ConnectionId { get; private set; } + + /// + /// Gets the line / port of signaled. + /// + public int? LinePort { get; private set; } + + /// + /// Gets the external number displayed in the FRITZ!Box. + /// + public string? CallerNumber { get; private set; } + + /// + /// Gets the internal number registered in the FRITZ!Box. + /// + public string? CalleeNumber { get; private set; } + + /// + /// Gets the duarion of the call (only on event). + /// + public TimeSpan? Duration { get; private set; } + + /// + /// Tries to parse a line from the call monitor output into a instance. + /// + /// The line from the call monitor output. + /// when parsing fails, otherwise a new instance of the . + internal static CallMonitorEventArgs? Parse(string line) + { + string[] columns = line.Trim().Split(';'); + + if (!DateTimeOffset.TryParseExact(columns[0], "dd.MM.yy HH:mm:ss", null, DateTimeStyles.None, out var timestamp)) + return null; + + if (!Enum.TryParse(columns[1], true, out var eventType)) + return null; + + if (!int.TryParse(columns[2], out int connectionId)) + return null; + + var args = new CallMonitorEventArgs + { + Timestamp = timestamp, + Event = eventType, + ConnectionId = connectionId + }; + + switch (eventType) + { + case EventType.Ring: + args.CallerNumber = columns[3]; + args.CalleeNumber = columns[4]; + break; + + case EventType.Connect: + args.LinePort = int.TryParse(columns[3], out int connectLinePort) ? connectLinePort : null; + args.CallerNumber = columns[4]; + break; + + case EventType.Disconnect: + if (int.TryParse(columns[3], out int durationSeconds)) + args.Duration = TimeSpan.FromSeconds(durationSeconds); + break; + + case EventType.Call: + args.LinePort = int.TryParse(columns[3], out int callLinePort) ? callLinePort : null; + args.CalleeNumber = columns[4]; + args.CallerNumber = columns[5]; + break; + } + + return args; + } + } +} diff --git a/src/FritzCallMonitor/Utils/Extensions.cs b/src/FritzCallMonitor/Utils/Extensions.cs new file mode 100644 index 0000000..13039c7 --- /dev/null +++ b/src/FritzCallMonitor/Utils/Extensions.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace AMWD.Net.Api.Fritz.CallMonitor.Utils +{ + internal static class Extensions + { + public static async void Forget(this Task task, ILogger? logger = null) + { + try + { + await task.ConfigureAwait(false); + } + catch (Exception ex) + { + logger?.LogError(ex, "An error occurred in a fire-and-forget task."); + } + } + } +} diff --git a/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs b/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs new file mode 100644 index 0000000..406b79a --- /dev/null +++ b/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs @@ -0,0 +1,222 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Fritz.CallMonitor.Wrappers; +using Microsoft.Extensions.Logging; + +namespace AMWD.Net.Api.Fritz.CallMonitor.Utils +{ + internal class ReconnectTcpClient : IDisposable + { + private bool _isDisposed; + private readonly SemaphoreSlim _connectLock = new(1, 1); + + private readonly string _host; + private readonly int _port; + + private TcpClientWrapper? _tcpClient; + private readonly TcpClientWrapperFactory _tcpClientFactory = new(); + + private CancellationTokenSource? _stopCts; + private Task _monitorTask = Task.CompletedTask; + + public ReconnectTcpClient(string host, int port) + { + if (string.IsNullOrWhiteSpace(host)) + throw new ArgumentNullException(nameof(host), "The host is required."); + + if (port <= ushort.MinValue || ushort.MaxValue < port) + throw new ArgumentOutOfRangeException(nameof(port), $"The port must be between {ushort.MinValue + 1} and {ushort.MaxValue}."); + + _host = host; + _port = port; + } + + public virtual bool IsConnected => _tcpClient?.Connected ?? false; + + public virtual ILogger? Logger { get; set; } + + public virtual Func? OnConnected { get; set; } + + public virtual void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + StopAsyncInternally(CancellationToken.None).Wait(); + + _connectLock.Dispose(); + } + + public virtual NetworkStreamWrapper? GetStream() + { + ThrowIfDisposed(); + return _tcpClient?.GetStream(); + } + + public virtual async Task StartAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + _stopCts = new CancellationTokenSource(); + + using (var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _stopCts.Token)) + { + await ConnectWithRetryAsync(combinedTokenSource.Token).ConfigureAwait(false); + if (combinedTokenSource.IsCancellationRequested) + return; + } + + _monitorTask = Task.Run(() => MonitorConnectionAsync(_stopCts.Token), _stopCts.Token); + } + + public virtual Task StopAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + return StopAsyncInternally(cancellationToken); + } + + private async Task StopAsyncInternally(CancellationToken cancellationToken) + { + var stopTask = Task.Run(async () => + { + _stopCts?.Cancel(); + try + { + await _monitorTask.ConfigureAwait(false); + } + catch + { } + + _monitorTask = Task.CompletedTask; + + _stopCts?.Dispose(); + _stopCts = null; + + _tcpClient?.Dispose(); + _tcpClient = null; + }); + + try + { + await Task.WhenAny(stopTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false); + } + catch + { } + } + + private async Task ConnectWithRetryAsync(CancellationToken cancellationToken) + { + await _connectLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_isDisposed || IsConnected) + return; + + int delay = 250; + while (!cancellationToken.IsCancellationRequested && !_isDisposed) + { + try + { + _tcpClient?.Dispose(); + + _tcpClient = _tcpClientFactory.Create(); + _tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + +#if NET6_0_OR_GREATER + var connectTask = _tcpClient.ConnectAsync(_host, _port, cancellationToken); +#else + var connectTask = _tcpClient.ConnectAsync(_host, _port); +#endif + var completedTask = await Task.WhenAny(connectTask, Task.Delay(1000, cancellationToken)).ConfigureAwait(false); + if (completedTask != connectTask) + throw new TimeoutException("Connection attempt timed out."); + + if (OnConnected != null) + await OnConnected(this).ConfigureAwait(false); + + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Client was stopped or disposed. + return; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + delay *= 2; + + // Limit the delay to a maximum of 1 minute. + if (delay > 60 * 1000) + delay = 60 * 1000; + + Logger?.LogWarning(ex, $"Failed to connect to {_host}:{_port}. Retrying in {delay}ms..."); + try + { + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + catch + { } + } + } + } + finally + { + _connectLock.Release(); + } + } + + private async Task MonitorConnectionAsync(CancellationToken cancellationToken) + { + try + { + byte[] buffer = new byte[1]; + while (!cancellationToken.IsCancellationRequested && !_isDisposed) + { + if (!IsConnected) + { + await ConnectWithRetryAsync(cancellationToken).ConfigureAwait(false); + continue; + } + + var stream = _tcpClient?.GetStream(); + if (stream != null && stream.CanRead) + { + bool disconnected = false; + try + { + // Attempt to read zero bytes to check if the connection is still alive. + // Should return immediately if the connection is still active. + // So the timeout of 1sec is more than enough. + var readTask = stream.ReadAsync(buffer, 0, 0, cancellationToken); + var completedTask = await Task.WhenAny(readTask, Task.Delay(1000, cancellationToken)).ConfigureAwait(false); + if (completedTask != readTask) + continue; // Timeout + } + catch + { + disconnected = true; + } + + if (disconnected || !IsConnected) + await ConnectWithRetryAsync(cancellationToken).ConfigureAwait(false); + } + + // Check for an active connection every 5 seconds. + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { } + } + + private void ThrowIfDisposed() + { + if (_isDisposed) + throw new ObjectDisposedException(GetType().FullName); + } + } +} diff --git a/src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs b/src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs new file mode 100644 index 0000000..2de6ed4 --- /dev/null +++ b/src/FritzCallMonitor/Wrappers/NetworkStreamWrapper.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Net.Api.Fritz.CallMonitor.Wrappers +{ + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal class NetworkStreamWrapper : IDisposable + { + private readonly NetworkStream _networkStream; + + public NetworkStreamWrapper(NetworkStream networkStream) + { + _networkStream = networkStream; + } + + /// + public virtual void Dispose() + => _networkStream.Dispose(); + + /// + public virtual bool CanRead => + _networkStream.CanRead; + + /// + public virtual Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _networkStream.ReadAsync(buffer, offset, count, cancellationToken); + } +} diff --git a/src/FritzCallMonitor/Wrappers/SocketWrapper.cs b/src/FritzCallMonitor/Wrappers/SocketWrapper.cs new file mode 100644 index 0000000..bd8ef51 --- /dev/null +++ b/src/FritzCallMonitor/Wrappers/SocketWrapper.cs @@ -0,0 +1,25 @@ +using System; +using System.Net.Sockets; + +namespace AMWD.Net.Api.Fritz.CallMonitor.Wrappers +{ + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal class SocketWrapper : IDisposable + { + private readonly Socket _socket; + + public SocketWrapper(Socket socket) + { + _socket = socket; + } + + /// + public virtual void Dispose() + => _socket.Dispose(); + + /// + public virtual void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, bool optionValue) + => _socket.SetSocketOption(optionLevel, optionName, optionValue); + } +} diff --git a/src/FritzCallMonitor/Wrappers/TcpClientWrapper.cs b/src/FritzCallMonitor/Wrappers/TcpClientWrapper.cs new file mode 100644 index 0000000..5c40071 --- /dev/null +++ b/src/FritzCallMonitor/Wrappers/TcpClientWrapper.cs @@ -0,0 +1,42 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Net.Api.Fritz.CallMonitor.Wrappers +{ + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal class TcpClientWrapper : IDisposable + { + private readonly TcpClient _tcpClient; + + public TcpClientWrapper() + { + _tcpClient = new TcpClient(); + } + + /// + public virtual void Dispose() + => _tcpClient.Dispose(); + + /// + public virtual SocketWrapper Client => new(_tcpClient.Client); + + /// + public virtual bool Connected => _tcpClient.Connected; + + /// + public virtual Task ConnectAsync(string host, int port) + => _tcpClient.ConnectAsync(host, port); + + /// + public virtual NetworkStreamWrapper GetStream() + => new(_tcpClient.GetStream()); + +#if NET6_0_OR_GREATER + public virtual Task ConnectAsync(string host, int port, CancellationToken cancellationToken) + => _tcpClient.ConnectAsync(host, port, cancellationToken).AsTask(); +#endif + } +} diff --git a/src/FritzCallMonitor/Wrappers/TcpClientWrapperFactory.cs b/src/FritzCallMonitor/Wrappers/TcpClientWrapperFactory.cs new file mode 100644 index 0000000..e33d673 --- /dev/null +++ b/src/FritzCallMonitor/Wrappers/TcpClientWrapperFactory.cs @@ -0,0 +1,18 @@ +namespace AMWD.Net.Api.Fritz.CallMonitor.Wrappers +{ + /// + /// Factory for creating instances of . + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal class TcpClientWrapperFactory + { + /// + /// Create a new instance of . + /// + public virtual TcpClientWrapper Create() + { + var client = new TcpClientWrapper(); + return client; + } + } +}