diff --git a/src/Anrufmonitor.App/App.xaml.cs b/src/Anrufmonitor.App/App.xaml.cs index 2518b6b..250e767 100644 --- a/src/Anrufmonitor.App/App.xaml.cs +++ b/src/Anrufmonitor.App/App.xaml.cs @@ -109,6 +109,24 @@ public partial class App : System.Windows.Application CallPopupWindow.CloseByCallId(args.CallId); }); }; + + var hadOutage = false; + sipMonitor.RegistrationStatusChanged += (_, registered) => + { + _trayIconManager?.SetSipStatus(registered); + + if (!registered) + { + hadOutage = true; + _trayIconManager?.ShowNotification( + "SIP-Verbindung verloren", "Versuche automatisch, die Registrierung wiederherzustellen ..."); + } + else if (hadOutage) + { + hadOutage = false; + _trayIconManager?.ShowNotification("SIP wieder verbunden", "Die Registrierung wurde wiederhergestellt."); + } + }; } protected override async void OnExit(System.Windows.ExitEventArgs e) diff --git a/src/Anrufmonitor.App/TrayIconManager.cs b/src/Anrufmonitor.App/TrayIconManager.cs index 6b6f982..244bee6 100644 --- a/src/Anrufmonitor.App/TrayIconManager.cs +++ b/src/Anrufmonitor.App/TrayIconManager.cs @@ -35,6 +35,13 @@ public sealed class TrayIconManager : IDisposable public void Show() => _notifyIcon.Visible = true; + public void SetSipStatus(bool registered) + { + _notifyIcon.Text = registered + ? "Anrufmonitor - verbunden" + : "Anrufmonitor - SIP getrennt"; + } + public void ShowNotification(string title, string message) { _notifyIcon.Visible = true; diff --git a/src/Anrufmonitor.Sip/SipCallMonitor.cs b/src/Anrufmonitor.Sip/SipCallMonitor.cs index 3f7920d..6d11c19 100644 --- a/src/Anrufmonitor.Sip/SipCallMonitor.cs +++ b/src/Anrufmonitor.Sip/SipCallMonitor.cs @@ -1,3 +1,4 @@ +using System.Net; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -12,9 +13,18 @@ namespace Anrufmonitor.Sip; /// eingehende Anrufe an mehrere registrierte Kontakte forkt (Nebenstelle/Konto teilen /// sich mehrere Geraete) - der eigentliche Anruf wird weiterhin vom echten Telefon /// angenommen, dieser Client dient nur der Anzeige des Anrufers. +/// +/// Robustheit: ein Watchdog prueft periodisch, ob die letzte erfolgreiche Registrierung +/// nicht laenger als erwartet zurueckliegt. Falls doch (z.B. weil die interne +/// Neu-Registrierung aus welchem Grund auch immer haengen geblieben ist), wird der +/// komplette SIP-Stack (Transport, User-Agent, Registrierung) verworfen und neu +/// aufgebaut, statt dass die Ueberwachung stillschweigend tot bleibt. /// public sealed class SipCallMonitor : BackgroundService { + private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(15); + private static readonly TimeSpan WatchdogInterval = TimeSpan.FromSeconds(60); + private readonly SipOptions _options; private readonly ILogger _logger; @@ -22,6 +32,9 @@ public sealed class SipCallMonitor : BackgroundService private SIPUserAgent? _userAgent; private SIPRegistrationUserAgent? _registrationAgent; + private DateTime _lastRegistrationSuccessUtc; + private bool _isRegistered; + public SipCallMonitor(IOptions options, ILogger logger) { _options = options.Value; @@ -32,6 +45,9 @@ public sealed class SipCallMonitor : BackgroundService public event EventHandler? CallEnded; + /// Feuert bei jedem Wechsel des Registrierungsstatus (true = registriert, false = verloren). + public event EventHandler? RegistrationStatusChanged; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { if (!_options.Enabled) @@ -40,8 +56,47 @@ public sealed class SipCallMonitor : BackgroundService return; } + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunOnceAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError( + ex, "SIP-Monitor unterbrochen, versuche Neuverbindung in {Delay}s", ReconnectDelay.TotalSeconds); + } + + SetRegistered(false); + TeardownCurrent(); + + if (stoppingToken.IsCancellationRequested) + { + break; + } + + try + { + await Task.Delay(ReconnectDelay, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + + TeardownCurrent(); + } + + private async Task RunOnceAsync(CancellationToken stoppingToken) + { _transport = new SIPTransport(); - _transport.AddSIPChannel(new SIPUDPChannel(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 0))); + _transport.AddSIPChannel(new SIPUDPChannel(new IPEndPoint(IPAddress.Any, 0))); _userAgent = new SIPUserAgent(_transport, null); _userAgent.OnIncomingCall += OnIncomingCall; @@ -65,27 +120,84 @@ public sealed class SipCallMonitor : BackgroundService _options.Username, _options.Password, _options.Server, - _options.RegisterExpirySeconds); + _options.RegisterExpirySeconds, + maxRegistrationAttemptTimeout: 60, + registerFailureRetryInterval: 30, + maxRegisterAttempts: 3, + exitOnUnequivocalFailure: false); _registrationAgent.RegistrationSuccessful += (uri, resp) => + { _logger.LogInformation("SIP-Registrierung erfolgreich: {Uri}", uri); + _lastRegistrationSuccessUtc = DateTime.UtcNow; + SetRegistered(true); + }; _registrationAgent.RegistrationFailed += (uri, resp, err) => + { _logger.LogError("SIP-Registrierung fehlgeschlagen ({Uri}): {Error}", uri, err); + SetRegistered(false); + }; _registrationAgent.RegistrationTemporaryFailure += (uri, resp, msg) => _logger.LogWarning("SIP-Registrierung temporaer fehlgeschlagen ({Uri}): {Message}", uri, msg); _registrationAgent.RegistrationRemoved += (uri, resp) => + { _logger.LogWarning("SIP-Registrierung entfernt: {Uri}", uri); + SetRegistered(false); + }; + // Startzeitpunkt als Referenz setzen, damit der Watchdog der ersten (noch + // laufenden) Registrierung eine Gnadenfrist gibt, statt sofort auszuloesen. + _lastRegistrationSuccessUtc = DateTime.UtcNow; _registrationAgent.Start(); + var maxAge = TimeSpan.FromSeconds(_options.RegisterExpirySeconds) + WatchdogInterval + WatchdogInterval; + + using var timer = new PeriodicTimer(WatchdogInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + var age = DateTime.UtcNow - _lastRegistrationSuccessUtc; + if (age > maxAge) + { + throw new TimeoutException( + $"Keine erfolgreiche SIP-Registrierung seit {age.TotalMinutes:0.0} Minuten - erzwinge Neuverbindung."); + } + } + } + + private void SetRegistered(bool registered) + { + if (_isRegistered == registered) + { + return; + } + + _isRegistered = registered; + RegistrationStatusChanged?.Invoke(this, registered); + } + + private void TeardownCurrent() + { + try + { + _registrationAgent?.Stop(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Fehler beim Stoppen des Registrierungs-Agents (ignoriert)"); + } + try { - await Task.Delay(Timeout.Infinite, stoppingToken); + _transport?.Shutdown(); } - catch (OperationCanceledException) + catch (Exception ex) { - // erwartetes Verhalten beim Beenden + _logger.LogDebug(ex, "Fehler beim Herunterfahren des SIP-Transports (ignoriert)"); } + + _registrationAgent = null; + _userAgent = null; + _transport = null; } private void OnIncomingCall(SIPUserAgent userAgent, SIPRequest inviteRequest) @@ -118,8 +230,7 @@ public sealed class SipCallMonitor : BackgroundService public override void Dispose() { - _registrationAgent?.Stop(); - _transport?.Shutdown(); + TeardownCurrent(); base.Dispose(); } }