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