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;
+ }
+ }
+}