diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..47320d4
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,32 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+_nothing changed yet_
+
+
+## [v0.1.0] - 2025-08-28
+
+_Inital release_
+
+### Added
+
+- `CallMonitorClient` as client to connect to the call monitor endpoint
+- `CallMonitorEventArgs` are the custom arugments, when `OnEvent` is raised.
+- Notifying about
+ - `Ring`: An incoming call
+ - `Call`: An outgoing call
+ - `Connect`: The call is answered
+ - `Disconnect`: One party has hung up
+- An unknown caller means, the `CallerNumber` is empty
+
+
+
+[Unreleased]: https://github.com/AM-WD/FritzCallMonitor/compare/v0.1.0...HEAD
+
+[v0.1.0]: https://github.com/AM-WD/FritzCallMonitor/commits/v0.1.0
diff --git a/FritzCallMonitor.sln b/FritzCallMonitor.sln
index b4305cb..6d2f2c4 100644
--- a/FritzCallMonitor.sln
+++ b/FritzCallMonitor.sln
@@ -35,6 +35,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{6FA27A
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{B5851E79-416B-40CA-959C-ADCAFCC8BADB}"
ProjectSection(SolutionItems) = preProject
+ CHANGELOG.md = CHANGELOG.md
LICENSE.txt = LICENSE.txt
README.md = README.md
EndProjectSection
diff --git a/src/FritzCallMonitor/CallMonitorClient.cs b/src/FritzCallMonitor/CallMonitorClient.cs
index da6197b..15d0282 100644
--- a/src/FritzCallMonitor/CallMonitorClient.cs
+++ b/src/FritzCallMonitor/CallMonitorClient.cs
@@ -26,11 +26,11 @@ namespace AMWD.Net.Api.Fritz.CallMonitor
///
/// Initializes a new instance of the class.
///
- /// The hostname or IP address of the FRITZ!Box to monitor.
+ /// The hostname or IP address of the FRITZ!Box to monitor (Default: fritz.box).
/// 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)
+ /// Thrown if the is not set.
+ /// Thrown if the is not in a valid range of 1 to 65535.
+ public CallMonitorClient(string host = "fritz.box", int port = 1012)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentNullException(nameof(host));
diff --git a/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs b/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs
index 6410bb5..f33c8ac 100644
--- a/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs
+++ b/src/FritzCallMonitor/EventArgs/CallMonitorEventArgs.cs
@@ -24,7 +24,7 @@ namespace AMWD.Net.Api.Fritz.CallMonitor
public int? ConnectionId { get; private set; }
///
- /// Gets the line / port of signaled.
+ /// Gets the signaled line / port.
///
public int? LinePort { get; private set; }
@@ -39,7 +39,7 @@ namespace AMWD.Net.Api.Fritz.CallMonitor
public string? CalleeNumber { get; private set; }
///
- /// Gets the duarion of the call (only on event).
+ /// Gets the duration of the call (only on event).
///
public TimeSpan? Duration { get; private set; }
diff --git a/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs b/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs
index c8b864c..beb6ef2 100644
--- a/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs
+++ b/src/FritzCallMonitor/Utils/ReconnectTcpClient.cs
@@ -46,6 +46,10 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Utils
_isDisposed = true;
+ // Ensure no connection attempts are running
+ _connectLock.WaitAsync().Wait();
+
+ // Stop the client
StopAsyncInternally(CancellationToken.None).Wait();
_connectLock.Dispose();
@@ -84,6 +88,7 @@ namespace AMWD.Net.Api.Fritz.CallMonitor.Utils
var stopTask = Task.Run(async () =>
{
_stopCts?.Cancel();
+
try
{
await _monitorTask.ConfigureAwait(false);
diff --git a/test/FritzCallMonitor.Tests/CallMonitorClientTest.cs b/test/FritzCallMonitor.Tests/CallMonitorClientTest.cs
index 6c70e14..ca544db 100644
--- a/test/FritzCallMonitor.Tests/CallMonitorClientTest.cs
+++ b/test/FritzCallMonitor.Tests/CallMonitorClientTest.cs
@@ -15,17 +15,32 @@ namespace FritzCallMonitor.Tests
[TestClass]
public class CallMonitorClientTest
{
+ private const int ASYNC_DELAY = 100;
+
+ public TestContext TestContext { get; set; }
+
private const string HOST = "localhost";
private const int PORT = 1012;
+ private string _dateOffset;
+
+
private Mock _tcpClientMock;
private Mock _networkStreamMock;
+ private bool _tcpClientConnected;
private Queue<(int DelaySeconds, byte[] BufferResponse)> _readAsyncResponses;
[TestInitialize]
public void Initialize()
{
+ var offset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now);
+ _dateOffset = offset < TimeSpan.Zero
+ ? "-" + offset.ToString("hh\\:mm")
+ : "+" + offset.ToString("hh\\:mm");
+
+ _tcpClientConnected = true;
+
_readAsyncResponses = new Queue<(int, byte[])>();
_readAsyncResponses.Enqueue((0, Encoding.UTF8.GetBytes("25.08.25 20:15:30;RING;2;012345678901;9876543;SIP0;\r\n")));
@@ -62,38 +77,211 @@ namespace FritzCallMonitor.Tests
}
[TestMethod]
- public void ShouldSetAndGetLogger()
+ public async Task ShouldSetAndGetLogger()
{
// Arrange
var loggerMock = new Mock();
var client = GetClient();
+ await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token);
// Act
client.Logger = loggerMock.Object;
+ client.Dispose();
// Assert
Assert.AreEqual(loggerMock.Object, client.Logger);
_tcpClientMock.VerifySet(m => m.Logger = loggerMock.Object, Times.Once);
- _tcpClientMock.VerifyGet(m => m.IsConnected, Times.Once);
+ _tcpClientMock.VerifyGet(m => m.IsConnected, Times.Exactly(2));
_tcpClientMock.Verify(m => m.GetStream(), Times.Once);
+ _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+
+ _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2));
VerifyNoOtherCalls();
}
[TestMethod]
- public void ShouldDisposeOnlyOnce()
+ public async Task ShouldDisposeOnlyOnce()
{
// Arrange
var client = GetClient();
+ await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token);
// Act
client.Dispose();
client.Dispose();
// Assert
- _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+ _tcpClientMock.VerifyGet(m => m.IsConnected, Times.AtMost(2));
_tcpClientMock.Verify(m => m.GetStream(), Times.Once);
+ _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+
+ _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2));
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task ShouldSkipTaskWhenStreamIsNull()
+ {
+ // Arrange
+ var client = GetClient();
+ _tcpClientMock.Setup(m => m.GetStream()).Returns((NetworkStreamWrapper)null);
+
+ // Act
+ await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token);
+ client.Dispose();
+
+ // Assert
+ _tcpClientMock.VerifyGet(m => m.IsConnected, Times.AtMost(2));
+ _tcpClientMock.Verify(m => m.GetStream(), Times.Once);
+ _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task ShouldReadAndParseLine()
+ {
+ // Arrange
+ bool eventRaised = false;
+ CallMonitorEventArgs eventArgs = null;
+ var client = GetClient();
+ client.OnEvent += (s, e) =>
+ {
+ eventRaised = true;
+ eventArgs = e;
+ };
+
+ // Act
+ await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token);
+ client.Dispose();
+
+ // Assert
+ Assert.IsTrue(eventRaised);
+ Assert.IsNotNull(eventArgs);
+
+ Assert.AreEqual($"2025-08-25 20:15:30 {_dateOffset}", eventArgs.Timestamp?.ToString("yyyy-MM-dd HH:mm:ss K"));
+ Assert.AreEqual(EventType.Ring, eventArgs.Event);
+ Assert.AreEqual(2, eventArgs.ConnectionId);
+ Assert.IsNull(eventArgs.LinePort);
+ Assert.AreEqual("012345678901", eventArgs.CallerNumber);
+ Assert.AreEqual("9876543", eventArgs.CalleeNumber);
+ Assert.IsNull(eventArgs.Duration);
+
+ _tcpClientMock.VerifyGet(m => m.IsConnected, Times.Exactly(2));
+ _tcpClientMock.Verify(m => m.GetStream(), Times.Once);
+ _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+
+ _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2));
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task ShouldReadAndParseInMultipleReads()
+ {
+ // Arrange
+ _readAsyncResponses.Clear();
+ _readAsyncResponses.Enqueue((0, Encoding.UTF8.GetBytes("25.08.25 20:15:30;RING;")));
+ _readAsyncResponses.Enqueue((0, Encoding.UTF8.GetBytes("2;012345678901;9876543;SIP0;\n")));
+ _readAsyncResponses.Enqueue((Timeout.Infinite, Array.Empty()));
+
+ bool eventRaised = false;
+ CallMonitorEventArgs eventArgs = null;
+ var client = GetClient();
+ client.OnEvent += (s, e) =>
+ {
+ eventRaised = true;
+ eventArgs = e;
+ };
+
+ // Act
+ await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token);
+ client.Dispose();
+
+ // Assert
+ Assert.IsTrue(eventRaised);
+ Assert.IsNotNull(eventArgs);
+
+ Assert.AreEqual($"2025-08-25 20:15:30 {_dateOffset}", eventArgs.Timestamp?.ToString("yyyy-MM-dd HH:mm:ss K"));
+ Assert.AreEqual(EventType.Ring, eventArgs.Event);
+ Assert.AreEqual(2, eventArgs.ConnectionId);
+ Assert.IsNull(eventArgs.LinePort);
+ Assert.AreEqual("012345678901", eventArgs.CallerNumber);
+ Assert.AreEqual("9876543", eventArgs.CalleeNumber);
+ Assert.IsNull(eventArgs.Duration);
+
+ _tcpClientMock.VerifyGet(m => m.IsConnected, Times.Exactly(3));
+ _tcpClientMock.Verify(m => m.GetStream(), Times.Once);
+ _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+
+ _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3));
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task ShouldReadAndParseMultipleEvents()
+ {
+ // Arrange
+ _readAsyncResponses.Clear();
+ _readAsyncResponses.Enqueue((0, Encoding.UTF8.GetBytes("25.08.25 20:15:30;RING;2;012345678901;9876543;SIP0;\n25.08.25 20:15:30")));
+ _readAsyncResponses.Enqueue((0, Encoding.UTF8.GetBytes(";RING;2;012345678901;9876543;SIP0;\r\n")));
+ _readAsyncResponses.Enqueue((Timeout.Infinite, Array.Empty()));
+
+ int eventsRaised = 0;
+ var client = GetClient();
+ client.OnEvent += (s, e) =>
+ {
+ Interlocked.Increment(ref eventsRaised);
+ };
+
+ // Act
+ await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token);
+ client.Dispose();
+
+ // Assert
+ Assert.AreEqual(2, eventsRaised);
+
+ _tcpClientMock.VerifyGet(m => m.IsConnected, Times.Exactly(3));
+ _tcpClientMock.Verify(m => m.GetStream(), Times.Once);
+ _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+
+ _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3));
+
+ VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task ShouldReadAndParseMultipleEventsWithOneError()
+ {
+ // Arrange
+ _readAsyncResponses.Clear();
+ _readAsyncResponses.Enqueue((0, Encoding.UTF8.GetBytes("25.08.25 20:15:30;TEST;2;012345678901;9876543;SIP0;\n25.08.25 20:15:30")));
+ _readAsyncResponses.Enqueue((0, Encoding.UTF8.GetBytes(";RING;2;012345678901;9876543;SIP0;\r\n")));
+ _readAsyncResponses.Enqueue((Timeout.Infinite, Array.Empty()));
+
+ int eventsRaised = 0;
+ var client = GetClient();
+ client.OnEvent += (s, e) =>
+ {
+ Interlocked.Increment(ref eventsRaised);
+ };
+
+ // Act
+ await Task.Delay(ASYNC_DELAY, TestContext.CancellationTokenSource.Token);
+ client.Dispose();
+
+ // Assert
+ Assert.AreEqual(1, eventsRaised);
+
+ _tcpClientMock.VerifyGet(m => m.IsConnected, Times.Exactly(3));
+ _tcpClientMock.Verify(m => m.GetStream(), Times.Once);
+ _tcpClientMock.Verify(c => c.Dispose(), Times.Once);
+
+ _networkStreamMock.Verify(m => m.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3));
VerifyNoOtherCalls();
}
@@ -109,6 +297,10 @@ namespace FritzCallMonitor.Tests
_tcpClientMock = new Mock(HOST, PORT);
_networkStreamMock = new Mock(null);
+ _tcpClientMock
+ .Setup(m => m.IsConnected)
+ .Returns(() => _tcpClientConnected);
+
_tcpClientMock
.Setup(m => m.GetStream())
.Returns(_networkStreamMock.Object);
@@ -121,8 +313,10 @@ namespace FritzCallMonitor.Tests
return Task.Delay(TimeSpan.FromSeconds(delaySeconds), token).ContinueWith(t =>
{
- Array.Copy(bufferResponse, 0, buffer, offset, bufferResponse.Length);
- return bufferResponse.Length;
+ int bytesToCopy = Math.Min(count, bufferResponse.Length - offset);
+
+ Array.Copy(bufferResponse, 0, buffer, offset, bytesToCopy);
+ return bytesToCopy;
});
});