From 57c5eae36016c457b19a9c36595f233a029c94a9 Mon Sep 17 00:00:00 2001
From: NoName <306628470+trueroguan@users.noreply.github.com>
Date: Sat, 17 Oct 2026 19:23:38 +0300
Subject: [PATCH 1/2] First Release
---
Directory.Packages.props | 4 +
README.md | 10 +-
.../Services/DesktopNotificationService.cs | 40 +-
.../Services/ISystemNotificationSink.cs | 31 +
src/OpenIPC.Viewer.App/Services/Localizer.cs | 148 +++++
.../Services/NotificationSoundService.cs | 151 +++++
.../Services/UserSettings.cs | 20 +
.../Services/UserSettingsService.cs | 23 +
src/OpenIPC.Viewer.App/Themes/Theme.axaml | 2 +
.../NotificationSettingsViewModel.cs | 575 ++++++++++++++++++
.../ViewModels/SettingsPageViewModel.cs | 63 +-
.../Views/Pages/SettingsPage.axaml | 353 ++++++++++-
.../Views/Pages/SettingsPage.axaml.cs | 11 +-
.../SharedComposition.cs | 31 +-
.../Events/AnalyticsMotionEventSource.cs | 6 +-
src/OpenIPC.Viewer.Core/Events/CameraEvent.cs | 8 +-
.../Events/EventIngestionService.cs | 69 ++-
.../Events/IMotionEventSource.cs | 8 +-
.../Notifications/EventActionContext.cs | 155 +++++
.../Notifications/EventActionSettings.cs | 78 +++
.../Notifications/IEventActionDispatcher.cs | 41 ++
.../Notifications/NotificationCoordinator.cs | 289 ++++++++-
.../Notifications/NotificationSoundPlayer.cs | 112 ++++
.../Notifications/PcmClip.cs | 267 ++++++++
.../Notifications/SignalPayload.cs | 88 +++
.../Settings/IUserSettingsAccessor.cs | 10 +
src/OpenIPC.Viewer.Desktop/Composition.cs | 30 +-
.../Notifications/WindowsToastNotifier.cs | 322 ++++++++++
.../OpenIPC.Viewer.Desktop.csproj | 2 +
.../Notifications/EventActionDispatcher.cs | 235 +++++++
.../Notifications/MailCredentialStores.cs | 161 +++++
.../Notifications/MapiMailSender.cs | 137 +++++
.../Notifications/OutlookMailSender.cs | 82 +++
.../Notifications/SmtpMailSender.cs | 49 ++
.../OpenIPC.Viewer.Infrastructure.csproj | 1 +
.../Pipeline/FfmpegAudioClipDecoder.cs | 76 +++
.../Backend/ServerSettings.cs | 5 +
.../Notifications/EventActionTests.cs | 200 ++++++
.../Notifications/EventIngestionPeakTests.cs | 110 ++++
.../NotificationCoordinatorTests.cs | 293 ++++++++-
.../EventActionDispatcherTests.cs | 201 ++++++
.../SshSessionIntegrationTests.cs | 10 +-
.../TestDoubles.cs | 5 +
43 files changed, 4356 insertions(+), 156 deletions(-)
create mode 100644 src/OpenIPC.Viewer.App/Services/ISystemNotificationSink.cs
create mode 100644 src/OpenIPC.Viewer.App/Services/NotificationSoundService.cs
create mode 100644 src/OpenIPC.Viewer.App/ViewModels/NotificationSettingsViewModel.cs
create mode 100644 src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs
create mode 100644 src/OpenIPC.Viewer.Core/Notifications/EventActionSettings.cs
create mode 100644 src/OpenIPC.Viewer.Core/Notifications/IEventActionDispatcher.cs
create mode 100644 src/OpenIPC.Viewer.Core/Notifications/NotificationSoundPlayer.cs
create mode 100644 src/OpenIPC.Viewer.Core/Notifications/PcmClip.cs
create mode 100644 src/OpenIPC.Viewer.Core/Notifications/SignalPayload.cs
create mode 100644 src/OpenIPC.Viewer.Desktop/Notifications/WindowsToastNotifier.cs
create mode 100644 src/OpenIPC.Viewer.Infrastructure/Notifications/EventActionDispatcher.cs
create mode 100644 src/OpenIPC.Viewer.Infrastructure/Notifications/MailCredentialStores.cs
create mode 100644 src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs
create mode 100644 src/OpenIPC.Viewer.Infrastructure/Notifications/OutlookMailSender.cs
create mode 100644 src/OpenIPC.Viewer.Infrastructure/Notifications/SmtpMailSender.cs
create mode 100644 src/OpenIPC.Viewer.Video/Pipeline/FfmpegAudioClipDecoder.cs
create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Notifications/EventActionTests.cs
create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs
create mode 100644 tests/OpenIPC.Viewer.Infrastructure.Tests/EventActionDispatcherTests.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9b029b6..fac4d95 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -63,6 +63,10 @@
NuGetAudit + TreatWarningsAsErrors the advisory also fails restore, so
staying on 2025.1.0 is not an option anyway. -->
+
+
M19.07,4.93 A10,10 0 0 0 6.99,3.34 M4,6 h0.01 M2.29,9.62 A10,10 0 1 0 21.31,8.35 M16.24,7.76 A6,6 0 1 0 8.23,16.67 M12,18 h0.01 M17.99,11.66 A6,6 0 0 1 15.77,16.67 M10,12 A2,2 0 1 0 14,12 A2,2 0 1 0 10,12 Z M13.41,10.59 L19.07,4.93
+
+ M10.268,21 a2,2 0 0 0 3.464,0 M3.262,15.326 A1,1 0 0 0 4,17 h16 a1,1 0 0 0 0.74,-1.673 C19.41,13.956 18,12.499 18,8 A6,6 0 0 0 6,8 c0,4.499 -1.411,5.956 -2.738,7.326
M10,5 H3 M12,19 H3 M14,3 V7 M16,17 V21 M21,12 H12 M21,19 H16 M21,5 H14 M8,10 V14 M8,12 H3
diff --git a/src/OpenIPC.Viewer.App/ViewModels/NotificationSettingsViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/NotificationSettingsViewModel.cs
new file mode 100644
index 0000000..6156eed
--- /dev/null
+++ b/src/OpenIPC.Viewer.App/ViewModels/NotificationSettingsViewModel.cs
@@ -0,0 +1,575 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.App.Services;
+using OpenIPC.Viewer.Core.Events;
+using OpenIPC.Viewer.Core.Notifications;
+
+namespace OpenIPC.Viewer.App.ViewModels;
+
+// Settings → Notifications: what fires (types + thresholds + cooldown + quiet
+// hours), how it is shown (app toast with own sound, or native Windows toast),
+// and the external actions (TCP/UDP signal, webhook, e-mail, program).
+//
+// Field edits are saved debounced (typing in a text box must not rewrite the
+// settings file per keystroke) through UserSettingsService's functional update,
+// so they never clobber fields owned by the main settings page. The SMTP
+// password is never part of usersettings.json — "Save login" writes it to the
+// credential store (Windows Credential Manager on Windows).
+public sealed partial class NotificationSettingsViewModel : ViewModelBase
+{
+ private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(400);
+
+ private static readonly HashSet Persisted = new(StringComparer.Ordinal)
+ {
+ nameof(NotificationsEnabled), nameof(NotifyOnMotion), nameof(NotifyOnDetection),
+ nameof(NotificationCooldownSeconds), nameof(QuietHoursEnabled), nameof(QuietHoursStartHour),
+ nameof(QuietHoursEndHour), nameof(MinConfidencePercent), nameof(MinDuration), nameof(MinObjects),
+ nameof(UseSystemStyle), nameof(SoundMode), nameof(Volume),
+ nameof(ActionsEnabled), nameof(ActionsIgnoreQuietHours), nameof(ActionsCooldown),
+ nameof(SignalEnabled), nameof(SignalProtocol), nameof(SignalHost), nameof(SignalPort), nameof(SignalPayload),
+ nameof(HttpEnabled), nameof(HttpMethod), nameof(HttpUrl), nameof(HttpBody),
+ nameof(EmailEnabled), nameof(EmailTransport), nameof(EmailTo), nameof(EmailSubject), nameof(EmailBody),
+ nameof(SmtpHost), nameof(SmtpPort), nameof(SmtpSecurity), nameof(SmtpFrom),
+ nameof(ProgramEnabled), nameof(ProgramPath), nameof(ProgramArguments),
+ };
+
+ private readonly UserSettingsService _settings;
+ private readonly INotificationService _notifications;
+ private readonly NotificationSoundService _sound;
+ private readonly ISystemNotificationSink? _system;
+ private readonly IEventActionDispatcher _actions;
+ private readonly IMailCredentialStore _credentials;
+ private readonly IDialogService _dialogs;
+ private readonly ILogger _log;
+
+ private bool _suppressSave;
+ private bool _credentialsDirty;
+ private CancellationTokenSource? _pendingSave;
+ private string _customSoundFile = "";
+
+ public NotificationSettingsViewModel(
+ UserSettingsService settings,
+ INotificationService notifications,
+ NotificationSoundService sound,
+ IEventActionDispatcher actions,
+ IMailCredentialStore credentials,
+ IDialogService dialogs,
+ ILogger log,
+ ISystemNotificationSink? system = null)
+ {
+ _settings = settings;
+ _notifications = notifications;
+ _sound = sound;
+ _actions = actions;
+ _credentials = credentials;
+ _dialogs = dialogs;
+ _log = log;
+ _system = system;
+
+ var l = Localizer.Instance;
+ DurationOptions = new[] { 0, 1, 2, 3, 5, 10, 20, 30, 60 }
+ .Select(s => new IntOption(s == 0
+ ? l["Settings.Notify.Duration.Any"]
+ : string.Format(CultureInfo.CurrentCulture, l["Settings.Notify.Seconds"], s), s))
+ .ToList();
+ ObjectOptions = new[] { 1, 2, 3, 4, 5, 10 }
+ .Select(n => new IntOption(n == 1
+ ? l["Settings.Notify.Objects.Any"]
+ : string.Format(CultureInfo.CurrentCulture, l["Settings.Notify.Objects.AtLeast"], n), n))
+ .ToList();
+ ActionCooldownOptions = new[] { 0, 10, 30, 60, 120, 300, 600 }
+ .Select(s => new IntOption(s == 0
+ ? l["Settings.Notify.Cooldown.None"]
+ : string.Format(CultureInfo.CurrentCulture, l["Settings.Notify.Seconds"], s), s))
+ .ToList();
+ SoundModeOptions = new[]
+ {
+ new StringOption(l["Settings.Notify.Sound.Chime"], NotificationSoundService.SoundChime),
+ new StringOption(l["Settings.Notify.Sound.Custom"], NotificationSoundService.SoundCustom),
+ new StringOption(l["Settings.Notify.Sound.Off"], NotificationSoundService.SoundOff),
+ };
+ SignalProtocolOptions = new[]
+ {
+ new StringOption("TCP", EventActionSettings.ProtocolTcp),
+ new StringOption("UDP", EventActionSettings.ProtocolUdp),
+ };
+ var transports = new List
+ {
+ new(l["Settings.Actions.Email.Transport.Smtp"], EventActionSettings.TransportSmtp),
+ };
+ if (IsWindows)
+ {
+ transports.Add(new(l["Settings.Actions.Email.Transport.Mapi"], EventActionSettings.TransportMapi));
+ transports.Add(new(l["Settings.Actions.Email.Transport.Outlook"], EventActionSettings.TransportOutlook));
+ }
+ EmailTransportOptions = transports;
+ SmtpSecurityOptions = new[]
+ {
+ new StringOption(l["Settings.Actions.Smtp.Security.Auto"], "auto"),
+ new StringOption("SSL/TLS", "ssl"),
+ new StringOption("STARTTLS", "starttls"),
+ new StringOption(l["Settings.Actions.Smtp.Security.None"], "none"),
+ };
+
+ Load();
+ }
+
+ // --- platform gates ------------------------------------------------------
+
+ public bool IsWindows { get; } = OperatingSystem.IsWindows();
+ public bool IsDesktop { get; } = !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
+ public bool IsSystemStyleSupported => _system is { IsSupported: true };
+ public bool IsSoundAvailable => _sound.IsAvailable;
+
+ // --- what fires ------------------------------------------------------------
+
+ [ObservableProperty] private bool _notificationsEnabled = true;
+ [ObservableProperty] private bool _notifyOnMotion = true;
+ [ObservableProperty] private bool _notifyOnDetection = true;
+ [ObservableProperty] private int _notificationCooldownSeconds = 30;
+ [ObservableProperty] private bool _quietHoursEnabled;
+ [ObservableProperty] private int _quietHoursStartHour = 22;
+ [ObservableProperty] private int _quietHoursEndHour = 7;
+ public int[] HourOptions { get; } = Enumerable.Range(0, 24).ToArray();
+ public int[] CooldownOptions { get; } = { 0, 10, 30, 60, 120, 300 };
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(MinConfidenceText))]
+ private double _minConfidencePercent;
+ [ObservableProperty] private IntOption? _minDuration;
+ [ObservableProperty] private IntOption? _minObjects;
+ public IReadOnlyList DurationOptions { get; }
+ public IReadOnlyList ObjectOptions { get; }
+
+ public string MinConfidenceText => (int)MinConfidencePercent <= 0
+ ? Localizer.Instance["Settings.Notify.Confidence.Off"]
+ : $"≥ {(int)MinConfidencePercent} %";
+
+ // --- how it looks / sounds -----------------------------------------------
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(UseAppStyle))]
+ [NotifyPropertyChangedFor(nameof(ShowSoundSettings))]
+ private bool _useSystemStyle;
+
+ // Second radio button of the pair.
+ public bool UseAppStyle
+ {
+ get => !UseSystemStyle;
+ set { if (value) UseSystemStyle = false; }
+ }
+
+ public bool ShowSoundSettings => IsSoundAvailable && !UseSystemStyle;
+
+ [ObservableProperty] private string? _systemStatus;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsCustomSound))]
+ private StringOption? _soundMode;
+ public IReadOnlyList SoundModeOptions { get; }
+ public bool IsCustomSound => SoundMode?.Value == NotificationSoundService.SoundCustom;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(VolumeText))]
+ private double _volume = 70;
+ public string VolumeText => $"{(int)Volume} %";
+
+ [ObservableProperty] private string _customSoundName = "";
+ [ObservableProperty] private string? _soundStatus;
+
+ // --- external actions ----------------------------------------------------
+
+ [ObservableProperty] private bool _actionsEnabled;
+ [ObservableProperty] private bool _actionsIgnoreQuietHours = true;
+ [ObservableProperty] private IntOption? _actionsCooldown;
+ public IReadOnlyList ActionCooldownOptions { get; }
+
+ public string PlaceholdersHint { get; } =
+ string.Join(" ", EventActionTemplate.Placeholders.Select(p => "{" + p + "}"));
+
+ [ObservableProperty] private bool _signalEnabled;
+ [ObservableProperty] private StringOption? _signalProtocol;
+ [ObservableProperty] private string _signalHost = "";
+ [ObservableProperty] private int _signalPort = 9000;
+ [ObservableProperty] private string _signalPayload = "";
+ [ObservableProperty] private string? _signalStatus;
+ public IReadOnlyList SignalProtocolOptions { get; }
+
+ [ObservableProperty] private bool _httpEnabled;
+ [ObservableProperty] private string _httpMethod = "POST";
+ [ObservableProperty] private string _httpUrl = "";
+ [ObservableProperty] private string _httpBody = "";
+ [ObservableProperty] private string? _httpStatus;
+ public string[] HttpMethodOptions { get; } = { "POST", "GET", "PUT" };
+
+ [ObservableProperty] private bool _emailEnabled;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsSmtp))]
+ [NotifyPropertyChangedFor(nameof(IsMapi))]
+ [NotifyPropertyChangedFor(nameof(IsOutlook))]
+ private StringOption? _emailTransport;
+ public IReadOnlyList EmailTransportOptions { get; }
+ public bool IsSmtp => EmailTransport?.Value is null or EventActionSettings.TransportSmtp;
+ public bool IsMapi => EmailTransport?.Value == EventActionSettings.TransportMapi;
+ public bool IsOutlook => EmailTransport?.Value == EventActionSettings.TransportOutlook;
+
+ [ObservableProperty] private string _emailTo = "";
+ [ObservableProperty] private string _emailSubject = "";
+ [ObservableProperty] private string _emailBody = "";
+ [ObservableProperty] private string _smtpHost = "";
+ [ObservableProperty] private int _smtpPort = 587;
+ [ObservableProperty] private StringOption? _smtpSecurity;
+ public IReadOnlyList SmtpSecurityOptions { get; }
+ [ObservableProperty] private string _smtpFrom = "";
+ [ObservableProperty] private string _smtpUser = "";
+ [ObservableProperty] private string _smtpPassword = "";
+ [ObservableProperty] private string? _emailStatus;
+
+ public string CredentialStorageHint => Localizer.Instance[IsWindows
+ ? "Settings.Actions.Smtp.StoredIn.Windows"
+ : "Settings.Actions.Smtp.StoredIn.Secure"];
+
+ [ObservableProperty] private bool _programEnabled;
+ [ObservableProperty] private string _programPath = "";
+ [ObservableProperty] private string _programArguments = "";
+ [ObservableProperty] private string? _programStatus;
+
+ partial void OnSmtpUserChanged(string value) { if (!_suppressSave) _credentialsDirty = true; }
+ partial void OnSmtpPasswordChanged(string value) { if (!_suppressSave) _credentialsDirty = true; }
+
+ // --- load / save ---------------------------------------------------------
+
+ private void Load()
+ {
+ _suppressSave = true;
+ try
+ {
+ var s = _settings.Current;
+ NotificationsEnabled = s.NotificationsEnabled;
+ NotifyOnMotion = s.NotifyOnMotion;
+ NotifyOnDetection = s.NotifyOnDetection;
+ NotificationCooldownSeconds = s.NotificationCooldownSeconds;
+ QuietHoursEnabled = s.QuietHoursEnabled;
+ QuietHoursStartHour = s.QuietHoursStartHour;
+ QuietHoursEndHour = s.QuietHoursEndHour;
+ MinConfidencePercent = Math.Clamp(s.NotifyMinConfidencePercent, 0, 95);
+ MinDuration = Pick(DurationOptions, s.NotifyMinDurationSeconds);
+ MinObjects = Pick(ObjectOptions, s.NotifyMinObjects);
+
+ UseSystemStyle = IsSystemStyleSupported &&
+ string.Equals(s.NotificationStyle, DesktopNotificationService.StyleWindows, StringComparison.OrdinalIgnoreCase);
+ SoundMode = SoundModeOptions.FirstOrDefault(o => string.Equals(o.Value, s.NotificationSound, StringComparison.OrdinalIgnoreCase))
+ ?? SoundModeOptions[0];
+ Volume = Math.Clamp(s.NotificationVolume, 0, 100);
+ _customSoundFile = s.NotificationSoundFile ?? "";
+ CustomSoundName = s.NotificationSoundName ?? "";
+
+ var a = s.EventActions ?? EventActionSettings.Default;
+ ActionsEnabled = a.Enabled;
+ ActionsIgnoreQuietHours = a.IgnoreQuietHours;
+ ActionsCooldown = Pick(ActionCooldownOptions, a.CooldownSeconds);
+ SignalEnabled = a.SignalEnabled;
+ SignalProtocol = SignalProtocolOptions.FirstOrDefault(o => string.Equals(o.Value, a.SignalProtocol, StringComparison.OrdinalIgnoreCase))
+ ?? SignalProtocolOptions[0];
+ SignalHost = a.SignalHost;
+ SignalPort = a.SignalPort > 0 ? a.SignalPort : 9000;
+ SignalPayload = a.SignalPayload;
+ HttpEnabled = a.HttpEnabled;
+ HttpMethod = HttpMethodOptions.FirstOrDefault(m => string.Equals(m, a.HttpMethod, StringComparison.OrdinalIgnoreCase)) ?? "POST";
+ HttpUrl = a.HttpUrl;
+ HttpBody = a.HttpBody;
+ EmailEnabled = a.EmailEnabled;
+ EmailTransport = EmailTransportOptions.FirstOrDefault(o => string.Equals(o.Value, a.EmailTransport, StringComparison.OrdinalIgnoreCase))
+ ?? EmailTransportOptions[0];
+ EmailTo = a.EmailTo;
+ EmailSubject = a.EmailSubject;
+ EmailBody = a.EmailBody;
+ SmtpHost = a.SmtpHost;
+ SmtpPort = a.SmtpPort > 0 ? a.SmtpPort : 587;
+ SmtpSecurity = SmtpSecurityOptions.FirstOrDefault(o => string.Equals(o.Value, a.SmtpSecurity, StringComparison.OrdinalIgnoreCase))
+ ?? SmtpSecurityOptions[0];
+ SmtpFrom = a.SmtpFrom;
+ ProgramEnabled = a.ProgramEnabled;
+ ProgramPath = a.ProgramPath;
+ ProgramArguments = a.ProgramArguments;
+ }
+ finally { _suppressSave = false; }
+ }
+
+ // Called when the Settings page is shown: the SMTP login (async store) and
+ // the Windows notification state (a WinRT call).
+ public async Task LoadAsync()
+ {
+ try
+ {
+ var cred = await _credentials.GetAsync(CancellationToken.None).ConfigureAwait(true);
+ _suppressSave = true;
+ try
+ {
+ SmtpUser = cred?.UserName ?? "";
+ SmtpPassword = cred?.Password ?? "";
+ }
+ finally { _suppressSave = false; }
+ _credentialsDirty = false;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Could not read the SMTP login from {Store}", _credentials.StorageName);
+ }
+ await RefreshSystemStatusAsync().ConfigureAwait(true);
+ }
+
+ private static IntOption Pick(IReadOnlyList options, int value) =>
+ options.FirstOrDefault(o => o.Value == value)
+ ?? options.LastOrDefault(o => o.Value <= value)
+ ?? options[0];
+
+ protected override void OnPropertyChanged(PropertyChangedEventArgs e)
+ {
+ base.OnPropertyChanged(e);
+ if (_suppressSave || e.PropertyName is null || !Persisted.Contains(e.PropertyName)) return;
+ if (e.PropertyName == nameof(UseSystemStyle)) _ = RefreshSystemStatusAsync();
+ ScheduleSave();
+ }
+
+ private void ScheduleSave()
+ {
+ _pendingSave?.Cancel();
+ var cts = _pendingSave = new CancellationTokenSource();
+ _ = SaveLaterAsync(cts.Token);
+ }
+
+ private async Task SaveLaterAsync(CancellationToken ct)
+ {
+ try { await Task.Delay(SaveDelay, ct).ConfigureAwait(true); }
+ catch (OperationCanceledException) { return; }
+ await SaveNowAsync().ConfigureAwait(true);
+ }
+
+ // Writes a still-pending debounced edit now (page left mid-typing).
+ public Task FlushAsync() => _pendingSave is null ? Task.CompletedTask : SaveNowAsync();
+
+ // Writes immediately (Test buttons need the current values on disk).
+ public Task SaveNowAsync()
+ {
+ _pendingSave?.Cancel();
+ _pendingSave = null;
+
+ // Snapshot on the UI thread; the mutation may run on the save thread.
+ var enabled = NotificationsEnabled;
+ var onMotion = NotifyOnMotion;
+ var onDetection = NotifyOnDetection;
+ var cooldown = NotificationCooldownSeconds;
+ var quiet = QuietHoursEnabled;
+ var quietStart = QuietHoursStartHour;
+ var quietEnd = QuietHoursEndHour;
+ var minConfidence = (int)Math.Round(MinConfidencePercent);
+ var minDuration = MinDuration?.Value ?? 0;
+ var minObjects = MinObjects?.Value ?? 1;
+ var style = UseSystemStyle ? DesktopNotificationService.StyleWindows : DesktopNotificationService.StyleApp;
+ var sound = SoundMode?.Value ?? NotificationSoundService.SoundChime;
+ var volume = (int)Math.Round(Volume);
+ var soundFile = _customSoundFile;
+ var soundName = CustomSoundName;
+ var actions = BuildActions();
+
+ return _settings.UpdateAsync(s => s with
+ {
+ NotificationsEnabled = enabled,
+ NotifyOnMotion = onMotion,
+ NotifyOnDetection = onDetection,
+ NotificationCooldownSeconds = cooldown,
+ QuietHoursEnabled = quiet,
+ QuietHoursStartHour = quietStart,
+ QuietHoursEndHour = quietEnd,
+ NotifyMinConfidencePercent = minConfidence,
+ NotifyMinDurationSeconds = minDuration,
+ NotifyMinObjects = minObjects,
+ NotificationStyle = style,
+ NotificationSound = sound,
+ NotificationVolume = volume,
+ NotificationSoundFile = soundFile,
+ NotificationSoundName = soundName,
+ EventActions = actions,
+ });
+ }
+
+ private EventActionSettings BuildActions() => new(
+ Enabled: ActionsEnabled,
+ IgnoreQuietHours: ActionsIgnoreQuietHours,
+ CooldownSeconds: ActionsCooldown?.Value ?? 60,
+ SignalEnabled: SignalEnabled,
+ SignalProtocol: SignalProtocol?.Value ?? EventActionSettings.ProtocolTcp,
+ SignalHost: SignalHost.Trim(),
+ SignalPort: SignalPort,
+ SignalPayload: SignalPayload,
+ HttpEnabled: HttpEnabled,
+ HttpMethod: HttpMethod,
+ HttpUrl: HttpUrl.Trim(),
+ HttpBody: HttpBody,
+ EmailEnabled: EmailEnabled,
+ EmailTransport: EmailTransport?.Value ?? EventActionSettings.TransportSmtp,
+ EmailTo: EmailTo.Trim(),
+ EmailSubject: EmailSubject,
+ EmailBody: EmailBody,
+ SmtpHost: SmtpHost.Trim(),
+ SmtpPort: SmtpPort,
+ SmtpSecurity: SmtpSecurity?.Value ?? "auto",
+ SmtpFrom: SmtpFrom.Trim(),
+ ProgramEnabled: ProgramEnabled,
+ ProgramPath: ProgramPath.Trim(),
+ ProgramArguments: ProgramArguments);
+
+ // --- commands: look & sound ----------------------------------------------
+
+ [RelayCommand]
+ private async Task TestNotificationAsync()
+ {
+ await SaveNowAsync().ConfigureAwait(true);
+ // Straight at the sink — bypasses thresholds/cooldown on purpose, it's a preview.
+ _notifications.Show(new NotificationRequest(
+ Localizer.Instance["Settings.Notifications.TestTitle"],
+ Localizer.Instance["Settings.Notifications.TestBody"],
+ EventKind.Motion));
+ if (UseSystemStyle) await RefreshSystemStatusAsync().ConfigureAwait(true);
+ }
+
+ [RelayCommand]
+ private void PreviewSound() =>
+ _sound.Play(SoundMode?.Value ?? NotificationSoundService.SoundChime, _customSoundFile, (int)Math.Round(Volume));
+
+ [RelayCommand]
+ private async Task PickSoundAsync()
+ {
+ var path = await _dialogs.PickAnyFileAsync(Localizer.Instance["Settings.Notify.Sound.PickTitle"]).ConfigureAwait(true);
+ if (string.IsNullOrEmpty(path)) return;
+ try
+ {
+ SoundStatus = Localizer.Instance["Settings.Notify.Sound.Importing"];
+ _customSoundFile = await _sound.ImportAsync(path, CancellationToken.None).ConfigureAwait(true);
+ CustomSoundName = Path.GetFileName(path);
+ SoundMode = SoundModeOptions.First(o => o.Value == NotificationSoundService.SoundCustom);
+ SoundStatus = null;
+ await SaveNowAsync().ConfigureAwait(true);
+ PreviewSound();
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Importing notification sound {Path} failed", path);
+ SoundStatus = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Settings.Notify.Sound.ImportFailed"], ex.Message);
+ }
+ }
+
+ [RelayCommand]
+ private void OpenSystemSettings() => _system?.OpenSettings();
+
+ // Only in the Windows style: querying the state registers our AppUserModelID,
+ // which users of the built-in style never need.
+ private async Task RefreshSystemStatusAsync()
+ {
+ if (_system is not { IsSupported: true } || !UseSystemStyle) return;
+ var state = await Task.Run(() => _system.State).ConfigureAwait(true);
+ SystemStatus = Localizer.Instance[state switch
+ {
+ SystemNotificationState.Enabled => "Settings.Notify.System.Enabled",
+ SystemNotificationState.DisabledForApp => "Settings.Notify.System.DisabledForApp",
+ SystemNotificationState.DisabledForUser => "Settings.Notify.System.DisabledForUser",
+ SystemNotificationState.DisabledByPolicy => "Settings.Notify.System.DisabledByPolicy",
+ _ => "Settings.Notify.System.Unknown",
+ }];
+ }
+
+ // --- commands: external actions ------------------------------------------
+
+ [RelayCommand]
+ private Task TestSignalAsync() => TestChannelAsync(EventActionChannel.Signal, s => SignalStatus = s);
+
+ [RelayCommand]
+ private Task TestHttpAsync() => TestChannelAsync(EventActionChannel.Http, s => HttpStatus = s);
+
+ [RelayCommand]
+ private async Task TestEmailAsync()
+ {
+ if (IsSmtp && _credentialsDirty) await SaveCredentialsAsync().ConfigureAwait(true);
+ await TestChannelAsync(EventActionChannel.Email, s => EmailStatus = s).ConfigureAwait(true);
+ }
+
+ [RelayCommand]
+ private Task TestProgramAsync() => TestChannelAsync(EventActionChannel.Program, s => ProgramStatus = s);
+
+ private async Task TestChannelAsync(EventActionChannel channel, Action report)
+ {
+ report(Localizer.Instance["Settings.Actions.Testing"]);
+ try
+ {
+ await SaveNowAsync().ConfigureAwait(true);
+ var ctx = EventActionContext.Sample(DateTime.UtcNow) with
+ {
+ Title = Localizer.Instance["Settings.Notifications.TestTitle"],
+ };
+ await _actions.TestAsync(channel, ctx, BuildActions(), CancellationToken.None).ConfigureAwait(true);
+ report(Localizer.Instance["Settings.Actions.TestOk"]);
+ }
+ catch (Exception ex)
+ {
+ report(string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Settings.Actions.TestFailed"], ex.Message));
+ }
+ }
+
+ [RelayCommand]
+ private async Task SaveCredentialsAsync()
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(SmtpUser) && string.IsNullOrEmpty(SmtpPassword))
+ await _credentials.ClearAsync(CancellationToken.None).ConfigureAwait(true);
+ else
+ await _credentials.SetAsync(new MailCredential(SmtpUser.Trim(), SmtpPassword), CancellationToken.None).ConfigureAwait(true);
+ _credentialsDirty = false;
+ EmailStatus = CredentialStorageHint;
+ }
+ catch (Exception ex)
+ {
+ EmailStatus = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Settings.Actions.TestFailed"], ex.Message);
+ }
+ }
+
+ // Control Panel → Credential Manager, where the stored SMTP login can be
+ // reviewed, edited or removed with the standard Windows tool.
+ [RelayCommand]
+ private void OpenCredentialManager()
+ {
+ if (!OperatingSystem.IsWindows()) return;
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("control.exe", "/name Microsoft.CredentialManager")
+ {
+ UseShellExecute = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Could not open Credential Manager");
+ }
+ }
+
+ [RelayCommand]
+ private async Task PickProgramAsync()
+ {
+ var path = await _dialogs.PickAnyFileAsync(Localizer.Instance["Settings.Actions.Program.PickTitle"]).ConfigureAwait(true);
+ if (!string.IsNullOrEmpty(path)) ProgramPath = path;
+ }
+}
+
+// Combo items with a localized label and the persisted value.
+public sealed record IntOption(string Display, int Value);
+public sealed record StringOption(string Display, string Value);
diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
index d5b64c0..e0701e5 100644
--- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
+++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
@@ -25,7 +25,6 @@ public sealed partial class SettingsPageViewModel : ViewModelBase, IBackNavigabl
private readonly ISshHostKeyStore _hostKeys;
private readonly OpenIPC.Viewer.Core.Persistence.IConfigBackupService _backup;
private readonly ConfigSyncService _configSync;
- private readonly OpenIPC.Viewer.Core.Notifications.INotificationService _notifications;
private readonly OpenIPC.Viewer.Core.Persistence.ILayoutRepository _layouts;
private readonly ILogger _logger;
private bool _suppressSave;
@@ -110,16 +109,9 @@ private static TerminalEmulator CreateTerminalPreview()
return preview;
}
- // Notifications (Phase 19.3).
- [ObservableProperty] private bool _notificationsEnabled = true;
- [ObservableProperty] private bool _notifyOnMotion = true;
- [ObservableProperty] private bool _notifyOnDetection = true;
- [ObservableProperty] private int _notificationCooldownSeconds = 30;
- [ObservableProperty] private bool _quietHoursEnabled;
- [ObservableProperty] private int _quietHoursStartHour = 22;
- [ObservableProperty] private int _quietHoursEndHour = 7;
- public int[] HourOptions { get; } = System.Linq.Enumerable.Range(0, 24).ToArray();
- public int[] CooldownOptions { get; } = new[] { 0, 10, 30, 60, 120, 300 };
+ // Notifications (Phase 19.3) — its own section with its own view model
+ // (types, thresholds, style, sound, external actions).
+ public NotificationSettingsViewModel Notifications { get; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EffectiveRecordingsDirectory))]
@@ -127,10 +119,10 @@ private static TerminalEmulator CreateTerminalPreview()
private string _recordingsDirOverride = "";
// Master-detail state. -1 = no section selected (list view on narrow);
- // 0..5 maps to Appearance/Video/Recording/Discovery/Advanced/About. On
- // wide viewports a section is always selected (defaults to 0). Code-behind
- // toggles IsWide based on viewport width and resizes the Grid columns to
- // hide one pane on narrow.
+ // 0..7 maps to Appearance/Video/Recording/Discovery/SSH/Advanced/
+ // Notifications/About. On wide viewports a section is always selected
+ // (defaults to 0). Code-behind toggles IsWide based on viewport width and
+ // resizes the Grid columns to hide one pane on narrow.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAppearance))]
[NotifyPropertyChangedFor(nameof(IsVideo))]
@@ -138,6 +130,7 @@ private static TerminalEmulator CreateTerminalPreview()
[NotifyPropertyChangedFor(nameof(IsDiscovery))]
[NotifyPropertyChangedFor(nameof(IsSsh))]
[NotifyPropertyChangedFor(nameof(IsAdvanced))]
+ [NotifyPropertyChangedFor(nameof(IsNotifications))]
[NotifyPropertyChangedFor(nameof(IsAbout))]
[NotifyPropertyChangedFor(nameof(ShowList))]
[NotifyPropertyChangedFor(nameof(ShowDetail))]
@@ -156,7 +149,8 @@ private static TerminalEmulator CreateTerminalPreview()
public bool IsDiscovery => SelectedSectionIndex == 3;
public bool IsSsh => SelectedSectionIndex == 4;
public bool IsAdvanced => SelectedSectionIndex == 5;
- public bool IsAbout => SelectedSectionIndex == 6;
+ public bool IsNotifications => SelectedSectionIndex == 6;
+ public bool IsAbout => SelectedSectionIndex == 7;
public bool ShowList => IsWide || SelectedSectionIndex < 0;
public bool ShowDetail => IsWide || SelectedSectionIndex >= 0;
@@ -185,7 +179,7 @@ public bool TryGoBack()
// Deep-link target for the tray "About" item — keeps the section index in
// one place (it shifts between editions with different section sets).
- public void SelectAboutSection() => SelectedSectionIndex = 6;
+ public void SelectAboutSection() => SelectedSectionIndex = 7;
public bool IsRecordingsDirOverridden => !string.IsNullOrWhiteSpace(RecordingsDirOverride);
@@ -227,7 +221,7 @@ public SettingsPageViewModel(
ISshHostKeyStore hostKeys,
OpenIPC.Viewer.Core.Persistence.IConfigBackupService backup,
ConfigSyncService configSync,
- OpenIPC.Viewer.Core.Notifications.INotificationService notifications,
+ NotificationSettingsViewModel notifications,
OpenIPC.Viewer.Core.Persistence.ILayoutRepository layouts,
ILogger logger)
{
@@ -237,7 +231,7 @@ public SettingsPageViewModel(
_hostKeys = hostKeys;
_backup = backup;
_configSync = configSync;
- _notifications = notifications;
+ Notifications = notifications;
_layouts = layouts;
_logger = logger;
@@ -318,13 +312,6 @@ private void Load()
SshTerminalCursorBlink = s.SshTerminalCursorBlink;
SshTerminalScrollback = s.SshTerminalScrollback;
MajesticConfigPath = s.MajesticConfigPath;
- NotificationsEnabled = s.NotificationsEnabled;
- NotifyOnMotion = s.NotifyOnMotion;
- NotifyOnDetection = s.NotifyOnDetection;
- NotificationCooldownSeconds = s.NotificationCooldownSeconds;
- QuietHoursEnabled = s.QuietHoursEnabled;
- QuietHoursStartHour = s.QuietHoursStartHour;
- QuietHoursEndHour = s.QuietHoursEndHour;
ConfigSyncEnabled = s.ConfigSyncEnabled;
ConfigSyncPath = s.ConfigSyncPath;
ConfigSyncIncludeCredentials = s.ConfigSyncIncludeCredentials;
@@ -357,13 +344,6 @@ private void Load()
partial void OnSshTerminalCursorBlinkChanged(bool value) => Persist();
partial void OnSshTerminalScrollbackChanged(int value) => Persist();
partial void OnMajesticConfigPathChanged(string value) => Persist();
- partial void OnNotificationsEnabledChanged(bool value) => Persist();
- partial void OnNotifyOnMotionChanged(bool value) => Persist();
- partial void OnNotifyOnDetectionChanged(bool value) => Persist();
- partial void OnNotificationCooldownSecondsChanged(int value) => Persist();
- partial void OnQuietHoursEnabledChanged(bool value) => Persist();
- partial void OnQuietHoursStartHourChanged(int value) => Persist();
- partial void OnQuietHoursEndHourChanged(int value) => Persist();
private void Persist()
{
@@ -398,13 +378,6 @@ private void Persist()
SshTerminalScrollback = Math.Clamp(SshTerminalScrollback,
TerminalEmulator.MinScrollbackLimit, TerminalEmulator.MaxScrollbackLimit),
MajesticConfigPath = MajesticConfigPath,
- NotificationsEnabled = NotificationsEnabled,
- NotifyOnMotion = NotifyOnMotion,
- NotifyOnDetection = NotifyOnDetection,
- NotificationCooldownSeconds = NotificationCooldownSeconds,
- QuietHoursEnabled = QuietHoursEnabled,
- QuietHoursStartHour = QuietHoursStartHour,
- QuietHoursEndHour = QuietHoursEndHour,
ConfigSyncEnabled = ConfigSyncEnabled,
ConfigSyncPath = ConfigSyncPath,
ConfigSyncIncludeCredentials = ConfigSyncIncludeCredentials,
@@ -496,16 +469,6 @@ private void ReportIssue()
private void OpenLogsDirectory() =>
OpenInShell(System.IO.Path.Combine(_fs.AppDataDir.FullName, "logs"));
- // Fire a sample toast straight at the sink (Phase 19.3) so the user can see
- // the corner notification without waiting for a real camera event. Bypasses
- // the coordinator's cooldown/quiet-hours on purpose — it's a UI preview.
- [RelayCommand]
- private void TestNotification() =>
- _notifications.Show(new OpenIPC.Viewer.Core.Notifications.NotificationRequest(
- Localizer.Instance["Settings.Notifications.TestTitle"],
- Localizer.Instance["Settings.Notifications.TestBody"],
- OpenIPC.Viewer.Core.Events.EventKind.Motion));
-
// --- Network config auto-sync (Phase 20) ------------------------------
[ObservableProperty] private bool _configSyncEnabled;
[ObservableProperty] private string _configSyncPath = "";
diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
index 8010d0a..d776309 100644
--- a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
+++ b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
@@ -175,6 +175,19 @@
IsVisible="{Binding !IsWide}" />
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HorizontalAlignment="Left" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs
index 91da35c..f15019d 100644
--- a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs
+++ b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml.cs
@@ -24,9 +24,16 @@ public SettingsPage()
AttachedToVisualTree += (_, _) =>
{
ApplyLayout();
- // Layouts may have changed on the Live page since the last visit.
- if (_vm is not null) _ = _vm.LoadStartupLayoutsAsync();
+ // Layouts may have changed on the Live page since the last visit;
+ // the Windows notification state may have changed in Windows Settings.
+ if (_vm is not null)
+ {
+ _ = _vm.LoadStartupLayoutsAsync();
+ _ = _vm.Notifications.LoadAsync();
+ }
};
+ // Don't lose a debounced edit when the user navigates away mid-typing.
+ DetachedFromVisualTree += (_, _) => _ = _vm?.Notifications.FlushAsync();
SizeChanged += OnSizeChanged;
}
diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs
index 80921fb..f58c67e 100644
--- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs
+++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs
@@ -178,15 +178,37 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton();
// Notifications (Phase 19.3). Native sink per head (TryAdd Null fallback);
- // the coordinator applies cooldown / quiet-hours / type toggles over the
- // event stream. Eager-Start()ed by each platform host after build.
+ // the coordinator applies thresholds / cooldown / quiet-hours / type
+ // toggles over the event stream and fans out to the sink and the external
+ // actions. Eager-Start()ed by each platform host after build.
services.TryAddSingleton();
+ // Custom notification sound import (any format FFmpeg reads) + playback.
+ // The player itself is registered by heads that own an audio device.
+ services.AddSingleton();
+ services.AddSingleton();
+ // External actions: SMTP login lives in Windows Credential Manager on
+ // Windows, in the platform secrets store elsewhere.
+ services.TryAddSingleton(sp =>
+ {
+ if (OperatingSystem.IsWindows())
+ return new OpenIPC.Viewer.Infrastructure.Notifications.WindowsCredentialMailStore();
+ return new OpenIPC.Viewer.Infrastructure.Notifications.SecretsStoreMailCredentialStore(
+ sp.GetRequiredService());
+ });
+ services.AddSingleton();
services.AddSingleton(sp =>
- new OpenIPC.Viewer.Core.Notifications.NotificationCoordinator(
+ {
+ var cameras = sp.GetRequiredService();
+ return new OpenIPC.Viewer.Core.Notifications.NotificationCoordinator(
sp.GetRequiredService().Events,
sp.GetRequiredService(),
- sp.GetRequiredService()));
+ sp.GetRequiredService(),
+ actions: sp.GetRequiredService(),
+ cameraName: async (id, ct) => (await cameras.GetAsync(id, ct).ConfigureAwait(false))?.Name);
+ });
// UI services
services.AddSingleton();
@@ -231,6 +253,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
return services;
diff --git a/src/OpenIPC.Viewer.Core/Events/AnalyticsMotionEventSource.cs b/src/OpenIPC.Viewer.Core/Events/AnalyticsMotionEventSource.cs
index 340e2fd..ccff2c8 100644
--- a/src/OpenIPC.Viewer.Core/Events/AnalyticsMotionEventSource.cs
+++ b/src/OpenIPC.Viewer.Core/Events/AnalyticsMotionEventSource.cs
@@ -55,9 +55,13 @@ public ResultObserver(CameraId cameraId, IObserver target)
public void OnNext(DetectionResult value)
{
if (value.CameraId != _cameraId || value.Detections.Count == 0) return;
+ var best = 0f;
+ foreach (var d in value.Detections)
+ if (d.Confidence > best) best = d.Confidence;
_target.OnNext(new MotionTick(
_cameraId, value.OccurredAt, "analytics",
- EventKind.Detection, Summarize(value.Detections)));
+ EventKind.Detection, Summarize(value.Detections),
+ best, value.Detections.Count));
}
public void OnError(Exception error) { }
diff --git a/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs b/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs
index bd9fd4b..ddeb6dd 100644
--- a/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs
+++ b/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs
@@ -19,6 +19,10 @@ public enum EventSeverity
// "Event" alone collides with System.Event-related conventions; CameraEvent is
// explicit about scope.
+//
+// PeakConfidence / PeakObjects are the best values seen so far in the episode
+// (detection events only). They live on the in-memory stream for the
+// notification thresholds and are not persisted.
public sealed record CameraEvent(
EventId Id,
CameraId CameraId,
@@ -27,4 +31,6 @@ public sealed record CameraEvent(
DateTime OccurredAt,
DateTime? EndedAt,
string? Source,
- string? Summary);
+ string? Summary,
+ float? PeakConfidence = null,
+ int PeakObjects = 0);
diff --git a/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs b/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs
index e6950ae..49f2dc9 100644
--- a/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs
+++ b/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs
@@ -12,6 +12,10 @@ namespace OpenIPC.Viewer.Core.Events;
// -> open CameraEvent in repo with OccurredAt
// -> 5s of quiet -> close event (set EndedAt, persist)
// Plays back to a single Observed event channel that UI subscribes to once.
+// Besides open and close, an open event is re-published (same Id, EndedAt
+// still null) whenever its peak confidence or object count grows, so the
+// notification thresholds see a detection that firms up mid-episode.
+// Subscribers de-dup by Id.
public sealed class EventIngestionService : IAsyncDisposable
{
private readonly IEnumerable _sources;
@@ -64,6 +68,7 @@ private void OnTick(MotionTick tick)
{
OpenEventState? state;
bool isNew = false;
+ CameraEvent? update = null;
lock (_gate)
{
if (!_open.TryGetValue(tick.CameraId, out state))
@@ -75,31 +80,62 @@ private void OnTick(MotionTick tick)
state.LastTickAt = tick.At;
// Latest tick's summary wins (e.g. peak class counts during the episode).
if (tick.Summary is not null) state.Summary = tick.Summary;
+
+ var grew = false;
+ if (tick.Confidence is { } c && (state.PeakConfidence is not { } peak || c > peak))
+ {
+ state.PeakConfidence = c;
+ grew = true;
+ }
+ if (tick.Objects > state.PeakObjects)
+ {
+ state.PeakObjects = tick.Objects;
+ grew = true;
+ }
+ // Until OpenAsync has published, the open event itself carries the
+ // latest peaks — no separate update needed.
+ if (grew && !isNew && state.Published)
+ update = Snapshot(state, endedAt: null);
+
state.QuietTimer?.Change(CloseAfterQuiet, Timeout.InfiniteTimeSpan);
state.QuietTimer ??= new Timer(CloseQuietState, state, CloseAfterQuiet, Timeout.InfiniteTimeSpan);
}
if (isNew)
_ = OpenAsync(state);
+ else if (update is not null)
+ NotifyObservers(update);
}
private async Task OpenAsync(OpenEventState state)
{
- var ev = new CameraEvent(
- Id: state.EventId,
- CameraId: state.CameraId,
- Kind: state.Kind,
- Severity: EventSeverity.Info,
- OccurredAt: state.StartedAt,
- EndedAt: null,
- Source: state.Source,
- Summary: state.Summary);
+ CameraEvent ev;
+ lock (_gate) ev = Snapshot(state, endedAt: null);
try { await _repo.AddAsync(ev, CancellationToken.None).ConfigureAwait(false); }
catch { /* surface via finalize event instead — swallow here */ }
+ // Re-snapshot: ticks that landed while the row was being written may
+ // have raised the peaks.
+ lock (_gate)
+ {
+ ev = Snapshot(state, endedAt: null);
+ state.Published = true;
+ }
NotifyObservers(ev);
}
+ private static CameraEvent Snapshot(OpenEventState state, DateTime? endedAt) => new(
+ Id: state.EventId,
+ CameraId: state.CameraId,
+ Kind: state.Kind,
+ Severity: EventSeverity.Info,
+ OccurredAt: state.StartedAt,
+ EndedAt: endedAt,
+ Source: state.Source,
+ Summary: state.Summary,
+ PeakConfidence: state.PeakConfidence,
+ PeakObjects: state.PeakObjects);
+
private void CloseQuietState(object? boxed)
{
if (boxed is not OpenEventState state) return;
@@ -121,15 +157,8 @@ private void CloseQuietState(object? boxed)
private async Task FinalizeAsync(OpenEventState state)
{
- var finalized = new CameraEvent(
- Id: state.EventId,
- CameraId: state.CameraId,
- Kind: state.Kind,
- Severity: EventSeverity.Info,
- OccurredAt: state.StartedAt,
- EndedAt: state.LastTickAt,
- Source: state.Source,
- Summary: state.Summary);
+ CameraEvent finalized;
+ lock (_gate) finalized = Snapshot(state, endedAt: state.LastTickAt);
try { await _repo.UpdateAsync(finalized, CancellationToken.None).ConfigureAwait(false); }
catch { /* swallow */ }
@@ -173,6 +202,10 @@ private sealed class OpenEventState
public string? Source { get; }
public EventKind Kind { get; }
public string? Summary { get; set; }
+ public float? PeakConfidence { get; set; }
+ public int PeakObjects { get; set; }
+ // Set once the open event has gone out; later peak rises publish updates.
+ public bool Published { get; set; }
public Timer? QuietTimer { get; set; }
public OpenEventState(EventId id, CameraId cam, DateTime started, string? source, EventKind kind)
diff --git a/src/OpenIPC.Viewer.Core/Events/IMotionEventSource.cs b/src/OpenIPC.Viewer.Core/Events/IMotionEventSource.cs
index 0c3f7aa..77e45a1 100644
--- a/src/OpenIPC.Viewer.Core/Events/IMotionEventSource.cs
+++ b/src/OpenIPC.Viewer.Core/Events/IMotionEventSource.cs
@@ -22,10 +22,14 @@ public interface IMotionEventSource
// Kind/Summary default to a plain motion tick; analytics sources set Kind to
// Detection and Summary to the class counts (Phase 15.7) so the same ingestion
-// path produces both motion and detection events.
+// path produces both motion and detection events. Confidence (0..1, best box in
+// the frame) and Objects (boxes in the frame) feed the notification thresholds;
+// a plain motion tick has neither.
public readonly record struct MotionTick(
CameraId CameraId,
DateTime At,
string Source,
EventKind Kind = EventKind.Motion,
- string? Summary = null);
+ string? Summary = null,
+ float? Confidence = null,
+ int Objects = 0);
diff --git a/src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs b/src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs
new file mode 100644
index 0000000..d0326dc
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using OpenIPC.Viewer.Core.Events;
+
+namespace OpenIPC.Viewer.Core.Notifications;
+
+// Everything an external action may want to say about one event. CameraName
+// falls back to the id when the camera is unknown (deleted mid-episode).
+public sealed record EventActionContext(
+ CameraEvent Event,
+ string CameraName,
+ string Title,
+ DateTime NowUtc)
+{
+ public string KindToken => Event.Kind switch
+ {
+ EventKind.Detection => "detection",
+ EventKind.Connection => "connection",
+ EventKind.Snapshot => "snapshot",
+ _ => "motion",
+ };
+
+ // Whole seconds the episode has lasted (to its end, or to now while open).
+ public int DurationSeconds
+ {
+ get
+ {
+ var end = Event.EndedAt ?? NowUtc;
+ var seconds = (end.ToUniversalTime() - Event.OccurredAt.ToUniversalTime()).TotalSeconds;
+ return seconds > 0 ? (int)seconds : 0;
+ }
+ }
+
+ // A ready-made context for the Settings "Test" buttons.
+ public static EventActionContext Sample(DateTime nowUtc) => new(
+ new CameraEvent(
+ EventId.New(), new Entities.CameraId(Guid.Empty), EventKind.Detection, EventSeverity.Info,
+ nowUtc.AddSeconds(-3), null, "test", "person ×1", 0.9f, 1),
+ "Test camera", "Test event", nowUtc);
+}
+
+// Expands {placeholders} in a user template. Only the known names below are
+// touched, so literal braces (e.g. a JSON body) pass through unchanged. encode,
+// when given, is applied to every substituted value — the HTTP channel passes a
+// JSON string escaper so a camera called `Gate "A"` can't break the body.
+public static class EventActionTemplate
+{
+ public static readonly IReadOnlyList Placeholders = new[]
+ {
+ "title", "camera", "cameraId", "kind", "summary", "confidence",
+ "objects", "duration", "time", "timeUtc", "eventId",
+ };
+
+ public static string Render(string template, EventActionContext ctx, Func? encode = null)
+ {
+ if (string.IsNullOrEmpty(template)) return template ?? "";
+
+ var sb = new StringBuilder(template.Length + 64);
+ var i = 0;
+ while (i < template.Length)
+ {
+ var open = template.IndexOf('{', i);
+ if (open < 0)
+ {
+ sb.Append(template, i, template.Length - i);
+ break;
+ }
+ sb.Append(template, i, open - i);
+ var close = template.IndexOf('}', open + 1);
+ if (close < 0)
+ {
+ sb.Append(template, open, template.Length - open);
+ break;
+ }
+
+ var name = template.Substring(open + 1, close - open - 1);
+ var value = Resolve(name, ctx);
+ if (value is null)
+ {
+ // Not one of ours — emit the brace and keep scanning after it,
+ // so "{\"a\":\"{kind}\"}" still expands the inner {kind}.
+ sb.Append('{');
+ i = open + 1;
+ continue;
+ }
+ sb.Append(encode is null ? value : encode(value));
+ i = close + 1;
+ }
+ return sb.ToString();
+ }
+
+ private static string? Resolve(string name, EventActionContext ctx)
+ {
+ var e = ctx.Event;
+ switch (name)
+ {
+ case "title": return ctx.Title;
+ case "camera": return ctx.CameraName;
+ case "cameraId": return e.CameraId.ToString();
+ case "kind": return ctx.KindToken;
+ case "summary": return e.Summary ?? "";
+ case "confidence":
+ return e.PeakConfidence is { } c
+ ? ((int)Math.Round(c * 100)).ToString(CultureInfo.InvariantCulture)
+ : "";
+ case "objects": return e.PeakObjects.ToString(CultureInfo.InvariantCulture);
+ case "duration": return ctx.DurationSeconds.ToString(CultureInfo.InvariantCulture);
+ case "time":
+ return e.OccurredAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
+ case "timeUtc":
+ return e.OccurredAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture);
+ case "eventId": return e.Id.ToString();
+ default: return null;
+ }
+ }
+
+ // Splits "a@x.ru; b@y.com, c@z.org" into trimmed, non-empty addresses.
+ public static IReadOnlyList SplitAddresses(string list)
+ {
+ var result = new List();
+ if (string.IsNullOrWhiteSpace(list)) return result;
+ foreach (var part in list.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var a = part.Trim();
+ if (a.Length > 0) result.Add(a);
+ }
+ return result;
+ }
+
+ // Minimal JSON string-content escaper for the webhook body.
+ public static string JsonEscape(string value)
+ {
+ var sb = new StringBuilder(value.Length + 8);
+ foreach (var ch in value)
+ {
+ switch (ch)
+ {
+ case '"': sb.Append("\\\""); break;
+ case '\\': sb.Append("\\\\"); break;
+ case '\n': sb.Append("\\n"); break;
+ case '\r': sb.Append("\\r"); break;
+ case '\t': sb.Append("\\t"); break;
+ default:
+ if (ch < 0x20)
+ sb.Append("\\u").Append(((int)ch).ToString("x4", CultureInfo.InvariantCulture));
+ else
+ sb.Append(ch);
+ break;
+ }
+ }
+ return sb.ToString();
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Notifications/EventActionSettings.cs b/src/OpenIPC.Viewer.Core/Notifications/EventActionSettings.cs
new file mode 100644
index 0000000..995db11
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Notifications/EventActionSettings.cs
@@ -0,0 +1,78 @@
+namespace OpenIPC.Viewer.Core.Notifications;
+
+// External reactions to a camera event — the "hooks" that let the app drive
+// something outside itself: a TCP/UDP datagram to an alarm panel or PLC, an HTTP
+// webhook, an e-mail, or a local program/script. Persisted as one nested object
+// in usersettings.json. Strings rather than enums keep that file readable and
+// forward-compatible (an unknown value falls back to the default).
+//
+// Placeholders such as {camera} or {summary} in the payload/body/subject/
+// arguments are expanded by EventActionTemplate.
+public sealed record EventActionSettings(
+ bool Enabled = false,
+ // An alarm matters most at night, so by default actions keep firing during
+ // the quiet hours that silence the on-screen notifications.
+ bool IgnoreQuietHours = true,
+ // Per camera, separate from the on-screen notification cooldown.
+ int CooldownSeconds = 60,
+
+ // Raw network signal: "tcp" (connect, send, close) or "udp" (one datagram).
+ bool SignalEnabled = false,
+ string SignalProtocol = EventActionSettings.ProtocolTcp,
+ string SignalHost = "",
+ int SignalPort = 0,
+ string SignalPayload = "OPENIPC {kind} {camera}",
+
+ // HTTP webhook. GET sends no body; POST/PUT send HttpBody as JSON.
+ bool HttpEnabled = false,
+ string HttpMethod = "POST",
+ string HttpUrl = "",
+ string HttpBody = EventActionSettings.DefaultHttpBody,
+
+ // E-mail. Transport "smtp" talks to SmtpHost directly (login/password are
+ // kept in the OS credential store, never in this file); "mapi" hands the
+ // message to the default Windows mail client (Simple MAPI); "outlook"
+ // automates an installed classic Outlook. EmailTo takes ; or , separated
+ // addresses.
+ bool EmailEnabled = false,
+ string EmailTransport = EventActionSettings.TransportSmtp,
+ string EmailTo = "",
+ string EmailSubject = "{title}: {camera}",
+ string EmailBody = EventActionSettings.DefaultEmailBody,
+ string SmtpHost = "",
+ int SmtpPort = 587,
+ // "auto" (465 → TLS on connect, otherwise STARTTLS when offered), "ssl",
+ // "starttls" or "none".
+ string SmtpSecurity = "auto",
+ string SmtpFrom = "",
+
+ // Local program or script, started without a window and not waited for.
+ bool ProgramEnabled = false,
+ string ProgramPath = "",
+ string ProgramArguments = "\"{camera}\" {kind} \"{summary}\"")
+{
+ public const string ProtocolTcp = "tcp";
+ public const string ProtocolUdp = "udp";
+
+ public const string TransportSmtp = "smtp";
+ public const string TransportMapi = "mapi";
+ public const string TransportOutlook = "outlook";
+
+ public const string DefaultHttpBody =
+ "{\"event\":\"{kind}\",\"camera\":\"{camera}\",\"cameraId\":\"{cameraId}\"," +
+ "\"summary\":\"{summary}\",\"confidence\":\"{confidence}\",\"objects\":\"{objects}\"," +
+ "\"time\":\"{timeUtc}\",\"eventId\":\"{eventId}\"}";
+
+ public const string DefaultEmailBody =
+ "{title}\n\nCamera: {camera}\nTime: {time}\nDetails: {summary}\nConfidence: {confidence}%\nObjects: {objects}\n\n— OpenIPC Viewer";
+
+ public static EventActionSettings Default { get; } = new();
+
+ // True when at least one channel is switched on and has enough configuration
+ // to attempt a send.
+ public bool AnyChannelEnabled =>
+ (SignalEnabled && !string.IsNullOrWhiteSpace(SignalHost) && SignalPort > 0) ||
+ (HttpEnabled && !string.IsNullOrWhiteSpace(HttpUrl)) ||
+ (EmailEnabled && !string.IsNullOrWhiteSpace(EmailTo)) ||
+ (ProgramEnabled && !string.IsNullOrWhiteSpace(ProgramPath));
+}
diff --git a/src/OpenIPC.Viewer.Core/Notifications/IEventActionDispatcher.cs b/src/OpenIPC.Viewer.Core/Notifications/IEventActionDispatcher.cs
new file mode 100644
index 0000000..fc769c1
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Notifications/IEventActionDispatcher.cs
@@ -0,0 +1,41 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Core.Notifications;
+
+public enum EventActionChannel
+{
+ Signal,
+ Http,
+ Email,
+ Program,
+}
+
+// Runs the external actions configured in EventActionSettings. The coordinator
+// decides WHEN (thresholds, cooldown, quiet hours); this decides HOW. The
+// network/mail/process implementation lives in Infrastructure.
+public interface IEventActionDispatcher
+{
+ // Runs every enabled channel concurrently. A failing channel is logged and
+ // does not stop the others; never throws.
+ Task DispatchAsync(EventActionContext context, EventActionSettings settings, CancellationToken ct);
+
+ // Runs a single channel regardless of its Enabled flag — the Settings "Test"
+ // button. Throws on failure so the UI can show the reason.
+ Task TestAsync(EventActionChannel channel, EventActionContext context, EventActionSettings settings, CancellationToken ct);
+}
+
+// Login/password for the SMTP transport. Kept out of usersettings.json: on
+// Windows it lives in Credential Manager (editable there), elsewhere in the
+// platform secrets store.
+public sealed record MailCredential(string UserName, string Password);
+
+public interface IMailCredentialStore
+{
+ // Human-readable name of where the secret is kept, for the Settings page.
+ string StorageName { get; }
+
+ Task GetAsync(CancellationToken ct);
+ Task SetAsync(MailCredential credential, CancellationToken ct);
+ Task ClearAsync(CancellationToken ct);
+}
diff --git a/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs b/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs
index 61db654..5f47b41 100644
--- a/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs
+++ b/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs
@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
using OpenIPC.Viewer.Core.Entities;
using OpenIPC.Viewer.Core.Events;
using OpenIPC.Viewer.Core.Settings;
@@ -7,19 +10,35 @@
namespace OpenIPC.Viewer.Core.Notifications;
// The notification brain (Phase 19.3): subscribes to the camera-event stream and
-// decides what actually surfaces — master switch, per-kind toggles, per-camera
-// cooldown (anti-spam, their dashboard's lesson) and optional quiet hours. The
-// policy (ShouldNotify) is pure + unit-tested; delivery goes to the native
-// INotificationService. Lives in Core (no package deps), takes IObservable so
-// tests can feed a Subject instead of the real ingestion service.
+// decides what actually surfaces — master switch, per-kind toggles, thresholds,
+// per-camera cooldown (anti-spam, their dashboard's lesson) and optional quiet
+// hours. Delivery goes to the native INotificationService and, separately, to
+// the external actions (IEventActionDispatcher). Lives in Core (no package
+// deps), takes IObservable so tests can feed a Subject instead of the real
+// ingestion service.
+//
+// Each event episode is delivered at most once per channel. The ingestion
+// stream publishes an episode several times (open, peak updates, close), so the
+// coordinator tracks open episodes by EventId and fires the first time one
+// clears every threshold:
+// - confidence / object count (detections): an update that raises the peak
+// re-evaluates the episode;
+// - duration: a timer re-checks the episode once it is old enough, so a long
+// event notifies while it is still going on, not when it ends.
public sealed class NotificationCoordinator : IDisposable
{
private readonly IObservable _events;
private readonly INotificationService _sink;
private readonly IUserSettingsAccessor _settings;
+ private readonly IEventActionDispatcher? _actions;
+ private readonly Func>? _cameraName;
private readonly Func _now;
+ private readonly Func _schedule;
private readonly object _gate = new();
private readonly Dictionary _lastShown = new();
+ private readonly Dictionary _lastAction = new();
+ private readonly Dictionary _episodes = new();
+ private readonly CancellationTokenSource _cts = new();
private IDisposable? _sub;
@@ -27,46 +46,80 @@ public NotificationCoordinator(
IObservable events,
INotificationService sink,
IUserSettingsAccessor settings,
- Func? clock = null)
+ Func? clock = null,
+ IEventActionDispatcher? actions = null,
+ Func>? cameraName = null,
+ Func? scheduler = null)
{
_events = events;
_sink = sink;
_settings = settings;
+ _actions = actions;
+ _cameraName = cameraName;
_now = clock ?? (() => DateTime.Now); // quiet hours are local-time
+ _schedule = scheduler ?? DefaultSchedule;
}
public void Start()
{
- if (!_sink.IsAvailable) return;
+ // Nothing to deliver to: no native sink and no action dispatcher.
+ if (!_sink.IsAvailable && _actions is null) return;
_sub ??= _events.Subscribe(new Observer(this));
}
- // Pure decision (per-camera cooldown uses _lastShown). Does NOT mutate state.
+ // Pure decision for the on-screen channel at the moment of delivery: master
+ // switch, kind toggle, quiet hours and the per-camera cooldown. Thresholds are
+ // checked separately (PassesThresholds). Does NOT mutate state.
public bool ShouldNotify(CameraEvent e, DateTime nowLocal)
{
- if (!_settings.NotificationsEnabled) return false;
+ lock (_gate) return ShouldNotifyLocked(e, nowLocal);
+ }
+
+ // Same idea for the external actions: their own switch and cooldown, and
+ // quiet hours only when the user asked actions to honour them.
+ public bool ShouldRunActions(CameraEvent e, DateTime nowLocal)
+ {
+ lock (_gate) return ShouldRunActionsLocked(e, nowLocal);
+ }
- var kindEnabled = e.Kind switch
+ public enum ThresholdResult
+ {
+ Pass,
+ // Not yet — a later update (higher peak) may still pass.
+ WaitForPeak,
+ // Not yet — the episode is still too short.
+ WaitForDuration,
+ // Can no longer pass (the episode ended below a threshold).
+ Fail,
+ }
+
+ // Confidence and object count apply to detections only (a plain motion tick
+ // carries neither); duration applies to every event.
+ public ThresholdResult PassesThresholds(CameraEvent e, DateTime nowUtc)
+ {
+ var closed = e.EndedAt is not null;
+
+ if (e.Kind == EventKind.Detection)
{
- EventKind.Motion => _settings.NotifyOnMotion,
- EventKind.Detection => _settings.NotifyOnDetection,
- _ => false, // Connection / Snapshot not surfaced in v1
- };
- if (!kindEnabled) return false;
+ var minConfidence = Math.Max(0, Math.Min(100, _settings.NotifyMinConfidencePercent));
+ if (minConfidence > 0 && (e.PeakConfidence ?? 0f) * 100f + 0.0001f < minConfidence)
+ return closed ? ThresholdResult.Fail : ThresholdResult.WaitForPeak;
- if (_settings.QuietHoursEnabled &&
- InQuietHours(nowLocal.Hour, _settings.QuietHoursStartHour, _settings.QuietHoursEndHour))
- return false;
+ var minObjects = Math.Max(1, _settings.NotifyMinObjects);
+ if (minObjects > 1 && e.PeakObjects < minObjects)
+ return closed ? ThresholdResult.Fail : ThresholdResult.WaitForPeak;
+ }
- lock (_gate)
+ var minDuration = Math.Max(0, _settings.NotifyMinDurationSeconds);
+ if (minDuration > 0)
{
- if (_lastShown.TryGetValue(e.CameraId, out var last))
- {
- var cooldown = TimeSpan.FromSeconds(Math.Max(0, _settings.NotificationCooldownSeconds));
- if (nowLocal - last < cooldown) return false;
- }
+ var end = e.EndedAt ?? nowUtc;
+ var lasted = end.ToUniversalTime() - e.OccurredAt.ToUniversalTime();
+ if (lasted < TimeSpan.FromSeconds(minDuration))
+ return closed ? ThresholdResult.Fail : ThresholdResult.WaitForDuration;
}
- return true;
+
+ return ThresholdResult.Pass;
}
// Inclusive-start, exclusive-end hour window; wraps past midnight when
@@ -77,27 +130,197 @@ public static bool InQuietHours(int hour, int start, int end)
return start < end ? hour >= start && hour < end : hour >= start || hour < end;
}
+ private bool KindEnabled(EventKind kind) => kind switch
+ {
+ EventKind.Motion => _settings.NotifyOnMotion,
+ EventKind.Detection => _settings.NotifyOnDetection,
+ _ => false, // Connection / Snapshot not surfaced in v1
+ };
+
+ private static bool CooldownElapsed(Dictionary last, CameraId camera, DateTime now, int seconds)
+ {
+ if (!last.TryGetValue(camera, out var at)) return true;
+ var cooldown = TimeSpan.FromSeconds(Math.Max(0, seconds));
+ return now - at >= cooldown;
+ }
+
private void OnEvent(CameraEvent e)
{
- var now = _now();
- if (!ShouldNotify(e, now)) return;
- lock (_gate) _lastShown[e.CameraId] = now;
+ bool notify = false, act = false;
+ lock (_gate)
+ {
+ if (!_episodes.TryGetValue(e.Id, out var ep))
+ {
+ ep = new Episode();
+ _episodes[e.Id] = ep;
+ }
+ ep.Latest = e;
+ Evaluate(ep, out notify, out act);
+ if (e.EndedAt is not null)
+ {
+ ep.Recheck?.Dispose();
+ _episodes.Remove(e.Id);
+ }
+ }
+
+ if (notify || act)
+ _ = DeliverAsync(e, notify, act);
+ }
+
+ // Caller holds _gate. Decides which channels fire now and arms the duration
+ // re-check when that is the only thing still missing.
+ private void Evaluate(Episode ep, out bool notify, out bool act)
+ {
+ notify = act = false;
+ if (ep.NotifyDone && ep.ActionsDone) return;
- var (title, body) = Describe(e);
- _sink.Show(new NotificationRequest(title, body, e.Kind, e.CameraId));
+ var e = ep.Latest!;
+ var nowLocal = _now();
+ switch (PassesThresholds(e, nowLocal.ToUniversalTime()))
+ {
+ case ThresholdResult.Pass:
+ break;
+ case ThresholdResult.WaitForDuration:
+ ArmRecheck(ep, e);
+ return;
+ default:
+ return;
+ }
+
+ if (!ep.NotifyDone)
+ {
+ ep.NotifyDone = true;
+ if (ShouldNotifyLocked(e, nowLocal))
+ {
+ _lastShown[e.CameraId] = nowLocal;
+ notify = true;
+ }
+ }
+ if (!ep.ActionsDone)
+ {
+ ep.ActionsDone = true;
+ if (ShouldRunActionsLocked(e, nowLocal))
+ {
+ _lastAction[e.CameraId] = nowLocal;
+ act = true;
+ }
+ }
}
- private static (string title, string body) Describe(CameraEvent e)
+ // Caller holds _gate (the cooldown maps are guarded by it).
+ private bool ShouldNotifyLocked(CameraEvent e, DateTime nowLocal)
+ {
+ if (!_settings.NotificationsEnabled || !_sink.IsAvailable || !KindEnabled(e.Kind)) return false;
+ if (_settings.QuietHoursEnabled &&
+ InQuietHours(nowLocal.Hour, _settings.QuietHoursStartHour, _settings.QuietHoursEndHour))
+ return false;
+ return CooldownElapsed(_lastShown, e.CameraId, nowLocal, _settings.NotificationCooldownSeconds);
+ }
+
+ private bool ShouldRunActionsLocked(CameraEvent e, DateTime nowLocal)
+ {
+ if (_actions is null) return false;
+ var a = _settings.EventActions ?? EventActionSettings.Default;
+ if (!a.Enabled || !a.AnyChannelEnabled || !KindEnabled(e.Kind)) return false;
+ if (!a.IgnoreQuietHours && _settings.QuietHoursEnabled &&
+ InQuietHours(nowLocal.Hour, _settings.QuietHoursStartHour, _settings.QuietHoursEndHour))
+ return false;
+ return CooldownElapsed(_lastAction, e.CameraId, nowLocal, a.CooldownSeconds);
+ }
+
+ private void ArmRecheck(Episode ep, CameraEvent e)
+ {
+ if (ep.Recheck is not null) return;
+ var due = e.OccurredAt.ToUniversalTime()
+ + TimeSpan.FromSeconds(Math.Max(0, _settings.NotifyMinDurationSeconds))
+ - _now().ToUniversalTime();
+ if (due < TimeSpan.Zero) due = TimeSpan.Zero;
+ var id = e.Id;
+ ep.Recheck = _schedule(due + TimeSpan.FromMilliseconds(50), () => OnRecheck(id));
+ }
+
+ private void OnRecheck(EventId id)
+ {
+ bool notify = false, act = false;
+ CameraEvent? e;
+ lock (_gate)
+ {
+ if (!_episodes.TryGetValue(id, out var ep)) return;
+ ep.Recheck?.Dispose();
+ ep.Recheck = null;
+ e = ep.Latest;
+ Evaluate(ep, out notify, out act);
+ }
+ if (e is not null && (notify || act))
+ _ = DeliverAsync(e, notify, act);
+ }
+
+ private async Task DeliverAsync(CameraEvent e, bool notify, bool act)
+ {
+ try
+ {
+ string? name = null;
+ if (_cameraName is not null)
+ {
+ try { name = await _cameraName(e.CameraId, _cts.Token).ConfigureAwait(false); }
+ catch (Exception) { /* fall back to the id */ }
+ }
+ var cameraName = string.IsNullOrWhiteSpace(name) ? e.CameraId.ToString() : name!;
+ var title = TitleFor(e.Kind);
+
+ if (notify)
+ {
+ var hasName = !string.IsNullOrWhiteSpace(name);
+ _sink.Show(new NotificationRequest(title, Describe(e, hasName ? name : null), e.Kind, e.CameraId));
+ }
+
+ if (act && _actions is not null)
+ {
+ var ctx = new EventActionContext(e, cameraName, title, _now().ToUniversalTime());
+ await _actions.DispatchAsync(ctx, _settings.EventActions ?? EventActionSettings.Default, _cts.Token)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (Exception)
+ {
+ // A sink or dispatcher must never take the event stream down.
+ }
+ }
+
+ public static string TitleFor(EventKind kind) =>
+ kind == EventKind.Detection ? "Object detected" : "Motion detected";
+
+ // "Front door · person ×2 (87%)"; the camera prefix is left out when unknown.
+ public static string Describe(CameraEvent e, string? cameraName)
{
- var title = e.Kind == EventKind.Detection ? "Object detected" : "Motion detected";
var body = string.IsNullOrWhiteSpace(e.Summary) ? "Camera event" : e.Summary!;
- return (title, body);
+ if (e.Kind == EventKind.Detection && e.PeakConfidence is { } c)
+ body += string.Format(CultureInfo.InvariantCulture, " ({0}%)", (int)Math.Round(c * 100));
+ return string.IsNullOrWhiteSpace(cameraName) ? body : cameraName + " · " + body;
}
+ private static IDisposable DefaultSchedule(TimeSpan due, Action callback) =>
+ new Timer(_ => callback(), null, due, Timeout.InfiniteTimeSpan);
+
public void Dispose()
{
_sub?.Dispose();
_sub = null;
+ _cts.Cancel();
+ lock (_gate)
+ {
+ foreach (var ep in _episodes.Values) ep.Recheck?.Dispose();
+ _episodes.Clear();
+ }
+ _cts.Dispose();
+ }
+
+ private sealed class Episode
+ {
+ public CameraEvent? Latest { get; set; }
+ public bool NotifyDone { get; set; }
+ public bool ActionsDone { get; set; }
+ public IDisposable? Recheck { get; set; }
}
private sealed class Observer : IObserver
diff --git a/src/OpenIPC.Viewer.Core/Notifications/NotificationSoundPlayer.cs b/src/OpenIPC.Viewer.Core/Notifications/NotificationSoundPlayer.cs
new file mode 100644
index 0000000..94b8e18
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Notifications/NotificationSoundPlayer.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenIPC.Viewer.Core.Platform;
+
+namespace OpenIPC.Viewer.Core.Notifications;
+
+// Plays a PcmClip through a PRIVATE IAudioOutput instance (the factory makes a
+// fresh one), so a notification sound never fights the live camera audio for
+// the shared sink — WASAPI shared mode / ALSA / CoreAudio all mix streams.
+//
+// IAudioOutput is a live-monitor sink with a ~0.5 s drop-oldest ring, so the
+// clip is fed in real time with a small lead instead of in one write. A new
+// Play cancels the one in progress; the device is released shortly after the
+// clip ends.
+public sealed class NotificationSoundPlayer : IDisposable
+{
+ private static readonly TimeSpan Lead = TimeSpan.FromMilliseconds(200);
+ private static readonly TimeSpan Chunk = TimeSpan.FromMilliseconds(50);
+ private static readonly TimeSpan Tail = TimeSpan.FromMilliseconds(350);
+
+ private readonly Func _factory;
+ private readonly object _gate = new();
+ private IAudioOutput? _output;
+ private CancellationTokenSource? _current;
+ private bool _disposed;
+
+ public NotificationSoundPlayer(Func factory) => _factory = factory;
+
+ public void Play(PcmClip clip, double volume)
+ {
+ if (clip.Pcm16.Length == 0 || volume <= 0) return;
+ CancellationTokenSource cts;
+ lock (_gate)
+ {
+ if (_disposed) return;
+ // Cancel, don't dispose: the superseded run may still be waiting on
+ // its token's handle.
+ _current?.Cancel();
+ _current = cts = new CancellationTokenSource();
+ }
+ var pcm = clip.WithVolume(volume);
+ _ = Task.Run(() => Run(clip, pcm, cts.Token));
+ }
+
+ private void Run(PcmClip clip, byte[] pcm, CancellationToken ct)
+ {
+ IAudioOutput? output;
+ try
+ {
+ lock (_gate)
+ {
+ if (ct.IsCancellationRequested) return;
+ output = _output ??= _factory();
+ }
+ if (output is null || !output.IsAvailable) return;
+
+ output.Start(clip.SampleRate, clip.Channels);
+
+ var block = clip.Channels * 2;
+ var bytesPerSecond = clip.BytesPerSecond;
+ var chunkBytes = Align((int)(bytesPerSecond * Chunk.TotalSeconds), block);
+ var leadBytes = Align((int)(bytesPerSecond * Lead.TotalSeconds), block);
+
+ var sw = Stopwatch.StartNew();
+ var written = 0;
+ while (written < pcm.Length && !ct.IsCancellationRequested)
+ {
+ // Stay at most `Lead` ahead of the playback clock.
+ var played = (long)(sw.Elapsed.TotalSeconds * bytesPerSecond);
+ if (written - played < leadBytes)
+ {
+ var n = Math.Min(chunkBytes, pcm.Length - written);
+ output.Write(new ReadOnlySpan(pcm, written, n));
+ written += n;
+ continue;
+ }
+ Thread.Sleep(Chunk.Milliseconds / 2);
+ }
+
+ // Let the buffered tail drain, then free the device unless a newer
+ // Play has taken over in the meantime.
+ var remaining = TimeSpan.FromSeconds(pcm.Length / (double)bytesPerSecond) - sw.Elapsed + Tail;
+ if (remaining > TimeSpan.Zero && ct.WaitHandle.WaitOne(remaining)) return;
+ lock (_gate)
+ {
+ if (!ct.IsCancellationRequested) output.Stop();
+ }
+ }
+ catch (Exception)
+ {
+ // A sound is a nicety — never let it take anything down.
+ }
+ }
+
+ private static int Align(int bytes, int block) => Math.Max(block, bytes - bytes % block);
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _current?.Cancel();
+ _current?.Dispose();
+ _current = null;
+ try { _output?.Dispose(); } catch (Exception) { /* tearing down */ }
+ _output = null;
+ }
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Notifications/PcmClip.cs b/src/OpenIPC.Viewer.Core/Notifications/PcmClip.cs
new file mode 100644
index 0000000..8a2ea87
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Notifications/PcmClip.cs
@@ -0,0 +1,267 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Core.Notifications;
+
+// A short, fully decoded sound: interleaved signed-16-bit PCM — the format every
+// IAudioOutput consumes. Used for the notification chime and the user's own
+// sound file.
+public sealed class PcmClip
+{
+ public PcmClip(byte[] pcm16, int sampleRate, int channels)
+ {
+ if (sampleRate <= 0) throw new ArgumentOutOfRangeException(nameof(sampleRate));
+ if (channels is < 1 or > 2) throw new ArgumentOutOfRangeException(nameof(channels));
+ Pcm16 = pcm16 ?? throw new ArgumentNullException(nameof(pcm16));
+ SampleRate = sampleRate;
+ Channels = channels;
+ }
+
+ public byte[] Pcm16 { get; }
+ public int SampleRate { get; }
+ public int Channels { get; }
+
+ public int BytesPerSecond => SampleRate * Channels * 2;
+ public TimeSpan Duration => TimeSpan.FromSeconds(Pcm16.Length / (double)BytesPerSecond);
+
+ // A copy with every sample multiplied by volume (0..1). The gain is applied
+ // on top of the system volume, never above unity, so it can't clip.
+ public byte[] WithVolume(double volume)
+ {
+ var gain = volume < 0 ? 0 : volume > 1 ? 1 : volume;
+ var output = new byte[Pcm16.Length];
+ if (gain <= 0) return output;
+ if (gain >= 1)
+ {
+ Buffer.BlockCopy(Pcm16, 0, output, 0, Pcm16.Length);
+ return output;
+ }
+ for (var i = 0; i + 1 < Pcm16.Length; i += 2)
+ {
+ var s = (short)(Pcm16[i] | (Pcm16[i + 1] << 8));
+ var scaled = (short)Math.Round(s * gain);
+ output[i] = (byte)scaled;
+ output[i + 1] = (byte)(scaled >> 8);
+ }
+ return output;
+ }
+
+ // Keeps at most maxLength of audio (a 10-minute song is not a notification).
+ public PcmClip Truncate(TimeSpan maxLength)
+ {
+ var block = Channels * 2;
+ var maxBytes = (long)(maxLength.TotalSeconds * SampleRate) * block;
+ if (Pcm16.Length <= maxBytes) return this;
+ var cut = new byte[maxBytes];
+ Buffer.BlockCopy(Pcm16, 0, cut, 0, (int)maxBytes);
+ return new PcmClip(cut, SampleRate, Channels);
+ }
+}
+
+// Decodes any audio file (mp3, ogg, m4a, flac, wav, …) to a PcmClip. The
+// FFmpeg-backed implementation lives in the Video project.
+public interface IAudioClipDecoder
+{
+ Task DecodeAsync(string path, TimeSpan maxLength, CancellationToken ct);
+}
+
+// Minimal RIFF/WAVE reader and writer. Reads PCM 8/16/24/32-bit and IEEE float
+// 32-bit, mono or multichannel (downmixed to stereo); writes PCM 16-bit. Enough
+// for notification sounds without dragging in a media framework.
+public static class WavCodec
+{
+ private const ushort FormatPcm = 1;
+ private const ushort FormatFloat = 3;
+ private const ushort FormatExtensible = 0xFFFE;
+
+ public static PcmClip Read(Stream stream)
+ {
+ using var r = new BinaryReader(stream, Encoding.ASCII, leaveOpen: true);
+ if (ReadTag(r) != "RIFF") throw new InvalidDataException("Not a RIFF file");
+ r.ReadUInt32();
+ if (ReadTag(r) != "WAVE") throw new InvalidDataException("Not a WAVE file");
+
+ ushort format = 0, channels = 0, bits = 0;
+ uint rate = 0;
+ byte[]? data = null;
+
+ while (stream.Position + 8 <= stream.Length)
+ {
+ var tag = ReadTag(r);
+ var size = r.ReadUInt32();
+ var next = stream.Position + size + (size & 1);
+ if (tag == "fmt ")
+ {
+ format = r.ReadUInt16();
+ channels = r.ReadUInt16();
+ rate = r.ReadUInt32();
+ r.ReadUInt32(); // byte rate
+ r.ReadUInt16(); // block align
+ bits = r.ReadUInt16();
+ if (format == FormatExtensible && size >= 40)
+ {
+ r.ReadUInt16(); // cbSize
+ r.ReadUInt16(); // valid bits
+ r.ReadUInt32(); // channel mask
+ format = r.ReadUInt16(); // first two bytes of the sub-format GUID
+ }
+ }
+ else if (tag == "data")
+ {
+ var available = (int)Math.Min(size, stream.Length - stream.Position);
+ data = r.ReadBytes(available);
+ }
+ if (next > stream.Length) break;
+ stream.Position = next;
+ }
+
+ if (data is null || channels == 0 || rate == 0)
+ throw new InvalidDataException("WAV file has no audio data");
+ if (format != FormatPcm && format != FormatFloat)
+ throw new InvalidDataException($"Unsupported WAV encoding ({format}); use PCM or float");
+
+ return ToClip(data, format, channels, bits, (int)rate);
+ }
+
+ public static void Write(Stream stream, PcmClip clip)
+ {
+ using var w = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true);
+ var blockAlign = (ushort)(clip.Channels * 2);
+ w.Write(Encoding.ASCII.GetBytes("RIFF"));
+ w.Write(36 + clip.Pcm16.Length);
+ w.Write(Encoding.ASCII.GetBytes("WAVE"));
+ w.Write(Encoding.ASCII.GetBytes("fmt "));
+ w.Write(16);
+ w.Write(FormatPcm);
+ w.Write((ushort)clip.Channels);
+ w.Write(clip.SampleRate);
+ w.Write(clip.SampleRate * blockAlign);
+ w.Write(blockAlign);
+ w.Write((ushort)16);
+ w.Write(Encoding.ASCII.GetBytes("data"));
+ w.Write(clip.Pcm16.Length);
+ w.Write(clip.Pcm16);
+ }
+
+ private static string ReadTag(BinaryReader r) => Encoding.ASCII.GetString(r.ReadBytes(4));
+
+ private static PcmClip ToClip(byte[] data, ushort format, int channels, int bits, int rate)
+ {
+ var bytesPerSample = bits / 8;
+ if (bytesPerSample is < 1 or > 4 || (format == FormatFloat && bits != 32))
+ throw new InvalidDataException($"Unsupported WAV sample size ({bits} bit)");
+
+ var frames = data.Length / (bytesPerSample * channels);
+ var outChannels = channels == 1 ? 1 : 2;
+
+ // Already the target format — copy verbatim (bit-exact, and fast).
+ if (format == FormatPcm && bits == 16 && channels <= 2)
+ {
+ var exact = new byte[frames * channels * 2];
+ Buffer.BlockCopy(data, 0, exact, 0, exact.Length);
+ return new PcmClip(exact, rate, channels);
+ }
+ var pcm = new byte[frames * outChannels * 2];
+ var o = 0;
+ for (var f = 0; f < frames; f++)
+ {
+ var frameBase = f * bytesPerSample * channels;
+ if (outChannels == 1)
+ {
+ o = Put(pcm, o, Sample(data, frameBase, format, bytesPerSample));
+ continue;
+ }
+ // Stereo out: channel 0 → L, channel 1 → R; extra channels are
+ // averaged into both so a 5.1 file still sounds complete.
+ double left = Sample(data, frameBase, format, bytesPerSample);
+ double right = Sample(data, frameBase + bytesPerSample, format, bytesPerSample);
+ if (channels > 2)
+ {
+ double extra = 0;
+ for (var c = 2; c < channels; c++)
+ extra += Sample(data, frameBase + c * bytesPerSample, format, bytesPerSample);
+ extra /= channels - 2;
+ left = (left + extra) / 2;
+ right = (right + extra) / 2;
+ }
+ o = Put(pcm, o, left);
+ o = Put(pcm, o, right);
+ }
+ return new PcmClip(pcm, rate, outChannels);
+ }
+
+ // One sample normalized to -1..1.
+ private static double Sample(byte[] d, int i, ushort format, int bytes)
+ {
+ if (format == FormatFloat)
+ return BitConverter.ToSingle(d, i);
+ return bytes switch
+ {
+ 1 => (d[i] - 128) / 128.0,
+ 2 => (short)(d[i] | (d[i + 1] << 8)) / 32768.0,
+ 3 => ((d[i] << 8 | d[i + 1] << 16 | d[i + 2] << 24) >> 8) / 8388608.0,
+ _ => (d[i] | d[i + 1] << 8 | d[i + 2] << 16 | d[i + 3] << 24) / 2147483648.0,
+ };
+ }
+
+ // Same 32768 scale as Sample(), so full-scale values map back exactly.
+ private static int Put(byte[] pcm, int o, double sample)
+ {
+ var scaled = Math.Round(sample * 32768);
+ var s = (short)(scaled < short.MinValue ? short.MinValue : scaled > short.MaxValue ? short.MaxValue : scaled);
+ pcm[o] = (byte)s;
+ pcm[o + 1] = (byte)(s >> 8);
+ return o + 2;
+ }
+}
+
+// The built-in notification sound: a short two-note chime (E6 → A6) with a soft
+// attack and exponential decay, synthesized at runtime so no audio asset has to
+// ship with every head.
+public static class NotificationChime
+{
+ public const int SampleRate = 48000;
+
+ public static PcmClip Generate()
+ {
+ var notes = new (double freq, double start, double length)[]
+ {
+ (1318.51, 0.00, 0.45),
+ (1760.00, 0.16, 0.60),
+ };
+ const double total = 0.80;
+ var frames = (int)(total * SampleRate);
+ var mix = new double[frames];
+
+ foreach (var (freq, start, length) in notes)
+ {
+ var from = (int)(start * SampleRate);
+ var count = (int)(length * SampleRate);
+ for (var n = 0; n < count && from + n < frames; n++)
+ {
+ var t = n / (double)SampleRate;
+ var attack = Math.Min(1.0, t / 0.008);
+ var decay = Math.Exp(-t * 6.5);
+ // Fundamental plus a quiet octave for a bell-like timbre.
+ var wave = Math.Sin(2 * Math.PI * freq * t) + 0.25 * Math.Sin(4 * Math.PI * freq * t);
+ mix[from + n] += 0.32 * attack * decay * wave;
+ }
+ }
+
+ var pcm = new byte[frames * 4]; // stereo S16
+ for (var i = 0; i < frames; i++)
+ {
+ var v = mix[i] < -1 ? -1 : mix[i] > 1 ? 1 : mix[i];
+ var s = (short)Math.Round(v * 32767);
+ var o = i * 4;
+ pcm[o] = (byte)s;
+ pcm[o + 1] = (byte)(s >> 8);
+ pcm[o + 2] = (byte)s;
+ pcm[o + 3] = (byte)(s >> 8);
+ }
+ return new PcmClip(pcm, SampleRate, 2);
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Notifications/SignalPayload.cs b/src/OpenIPC.Viewer.Core/Notifications/SignalPayload.cs
new file mode 100644
index 0000000..a50ea12
--- /dev/null
+++ b/src/OpenIPC.Viewer.Core/Notifications/SignalPayload.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+namespace OpenIPC.Viewer.Core.Notifications;
+
+// Turns the (already rendered) signal payload into bytes for the TCP/UDP
+// channel. Two forms, so both text protocols and binary alarm panels / PLCs
+// can be driven:
+// "hex: 01 A0 FF" — raw bytes; spaces, ':' and '-' between pairs are ignored;
+// anything else — UTF-8 text with C-style escapes \r \n \t \0 \\ \xNN.
+public static class SignalPayload
+{
+ public const string HexPrefix = "hex:";
+
+ public static byte[] Encode(string payload)
+ {
+ if (string.IsNullOrEmpty(payload)) return Array.Empty();
+ return payload.StartsWith(HexPrefix, StringComparison.OrdinalIgnoreCase)
+ ? ParseHex(payload.Substring(HexPrefix.Length))
+ : Unescape(payload);
+ }
+
+ private static byte[] ParseHex(string text)
+ {
+ var digits = new StringBuilder(text.Length);
+ foreach (var ch in text)
+ {
+ if (char.IsWhiteSpace(ch) || ch == ':' || ch == '-' || ch == ',') continue;
+ if (!Uri.IsHexDigit(ch))
+ throw new FormatException($"'{ch}' is not a hex digit in the signal payload");
+ digits.Append(ch);
+ }
+ if (digits.Length % 2 != 0)
+ throw new FormatException("Hex signal payload has an odd number of digits");
+
+ var bytes = new byte[digits.Length / 2];
+ for (var i = 0; i < bytes.Length; i++)
+ bytes[i] = byte.Parse(digits.ToString(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ return bytes;
+ }
+
+ private static byte[] Unescape(string text)
+ {
+ var output = new MemoryStream(text.Length);
+ var pending = new StringBuilder();
+
+ void FlushText()
+ {
+ if (pending.Length == 0) return;
+ var b = Encoding.UTF8.GetBytes(pending.ToString());
+ output.Write(b, 0, b.Length);
+ pending.Clear();
+ }
+
+ for (var i = 0; i < text.Length; i++)
+ {
+ var ch = text[i];
+ if (ch != '\\' || i + 1 >= text.Length)
+ {
+ pending.Append(ch);
+ continue;
+ }
+
+ var next = text[++i];
+ switch (next)
+ {
+ case 'r': pending.Append('\r'); break;
+ case 'n': pending.Append('\n'); break;
+ case 't': pending.Append('\t'); break;
+ case '0': pending.Append('\0'); break;
+ case '\\': pending.Append('\\'); break;
+ case 'x' when i + 2 < text.Length && Uri.IsHexDigit(text[i + 1]) && Uri.IsHexDigit(text[i + 2]):
+ // A raw byte, not a character — keep it out of the UTF-8 encoder.
+ FlushText();
+ output.WriteByte(byte.Parse(text.Substring(i + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture));
+ i += 2;
+ break;
+ default:
+ pending.Append('\\').Append(next);
+ break;
+ }
+ }
+ FlushText();
+ return output.ToArray();
+ }
+}
diff --git a/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs b/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
index 6a50d29..c6cb6d9 100644
--- a/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
+++ b/src/OpenIPC.Viewer.Core/Settings/IUserSettingsAccessor.cs
@@ -42,4 +42,14 @@ public interface IUserSettingsAccessor
bool QuietHoursEnabled { get; }
int QuietHoursStartHour { get; }
int QuietHoursEndHour { get; }
+
+ // Notification thresholds. An event surfaces (and fires its external
+ // actions) only once it clears all of them: AI confidence in percent and
+ // object count apply to detections, duration to every event. 0 / 1 = off.
+ int NotifyMinConfidencePercent { get; }
+ int NotifyMinDurationSeconds { get; }
+ int NotifyMinObjects { get; }
+
+ // External actions (TCP/UDP signal, webhook, e-mail, program).
+ OpenIPC.Viewer.Core.Notifications.EventActionSettings EventActions { get; }
}
diff --git a/src/OpenIPC.Viewer.Desktop/Composition.cs b/src/OpenIPC.Viewer.Desktop/Composition.cs
index 414e57a..6bbd258 100644
--- a/src/OpenIPC.Viewer.Desktop/Composition.cs
+++ b/src/OpenIPC.Viewer.Desktop/Composition.cs
@@ -59,8 +59,21 @@ public static ServiceProvider Build()
cfg["Recording:FfmpegPath"]);
});
- // Notifications (Phase 19.3) — Telegram-style corner toasts. Registered
- // before AddSharedServices so it wins over the TryAdd Null fallback.
+ // Notifications (Phase 19.3) — Telegram-style corner toasts with the app's
+ // own sound, or native Windows toasts (Settings → Notification style).
+ // Registered before AddSharedServices so it wins over the TryAdd Null fallback.
+ services.AddSingleton(sp => new OpenIPC.Viewer.Core.Notifications.NotificationSoundPlayer(
+ () => CreateNotificationAudioOutput(sp.GetRequiredService())));
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 10240))
+ {
+ services.AddSingleton(sp =>
+ {
+ if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 10240)) throw new PlatformNotSupportedException();
+ return new Notifications.WindowsToastNotifier(
+ sp.GetRequiredService().AppDataDir.FullName,
+ sp.GetRequiredService>());
+ });
+ }
services.AddSingleton();
services.AddSharedServices();
@@ -160,6 +173,19 @@ internal static void AddPlatformServices(IServiceCollection services)
}
}
+ // A fresh output per call (not the shared IAudioOutput singleton): the
+ // notification sound gets its own stream, mixed by the OS with live audio.
+ private static IAudioOutput? CreateNotificationAudioOutput(ILoggerFactory loggers)
+ {
+ if (OperatingSystem.IsWindows())
+ return new Audio.WasapiAudioOutput(loggers.CreateLogger());
+ if (OperatingSystem.IsLinux())
+ return new Audio.AlsaAudioOutput(loggers.CreateLogger());
+ if (OperatingSystem.IsMacOS())
+ return new Audio.CoreAudioOutput(loggers.CreateLogger());
+ return null;
+ }
+
private static IConfiguration BuildConfiguration()
{
var baseDir = AppContext.BaseDirectory;
diff --git a/src/OpenIPC.Viewer.Desktop/Notifications/WindowsToastNotifier.cs b/src/OpenIPC.Viewer.Desktop/Notifications/WindowsToastNotifier.cs
new file mode 100644
index 0000000..f1aff58
--- /dev/null
+++ b/src/OpenIPC.Viewer.Desktop/Notifications/WindowsToastNotifier.cs
@@ -0,0 +1,322 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Security;
+using System.Threading;
+using Microsoft.Extensions.Logging;
+using Microsoft.Win32;
+using OpenIPC.Viewer.App.Services;
+using OpenIPC.Viewer.Core.Notifications;
+
+namespace OpenIPC.Viewer.Desktop.Notifications;
+
+// Native Windows toast notifications (Action Center) for the "Windows" notification
+// style. Talks to the WinRT ToastNotificationManager through raw vtable calls, so
+// the desktop head stays a plain net9.0 build with no Windows SDK projection.
+//
+// An unpackaged app needs an AppUserModelID that Windows can resolve to a name
+// and icon: we register it under HKCU\Software\Classes\AppUserModelId (the same
+// mechanism the Windows App SDK uses for unpackaged apps). After the first toast
+// the app is listed in Settings → System → Notifications, where the user tunes
+// banners, sound, priority and Do Not Disturb. The process's own AUMID is left
+// alone so pinned taskbar shortcuts keep grouping with the window.
+//
+// All WinRT work runs on one dedicated MTA thread: it owns the COM init and the
+// cached notifier, and keeps the UI thread out of it.
+[SupportedOSPlatform("windows10.0.10240")]
+public sealed unsafe class WindowsToastNotifier : ISystemNotificationSink, IDisposable
+{
+ public const string AppUserModelId = "OpenIPC.Viewer";
+ private const string DisplayName = "OpenIPC Viewer";
+
+ private static readonly Guid IID_IToastNotificationManagerStatics = new("50AC103F-D235-4598-BBEF-98FE4D1A3AD4");
+ private static readonly Guid IID_IToastNotificationFactory = new("04124B20-82C6-4229-B109-FD9ED4662B53");
+ private static readonly Guid IID_IXmlDocumentIO = new("6CD0E74E-EE65-4489-9EBF-CA43E87BA637");
+ private static readonly Guid IID_IXmlDocument = new("F7F3A506-1E87-42D6-BCFB-B8C809FA5494");
+
+ private readonly ILogger _log;
+ private readonly string _iconPath;
+ private readonly BlockingCollection _work = new();
+ private readonly Thread _thread;
+ private IntPtr _notifier; // IToastNotifier*, worker thread only
+ private bool _registered;
+
+ public WindowsToastNotifier(string appDataDir, ILogger log)
+ {
+ _log = log;
+ _iconPath = Path.Combine(appDataDir, "notification-icon.png");
+ _thread = new Thread(WorkerLoop) { IsBackground = true, Name = "win-toast" };
+ _thread.SetApartmentState(ApartmentState.MTA);
+ _thread.Start();
+ }
+
+ public bool IsSupported => true;
+
+ public SystemNotificationState State
+ {
+ get
+ {
+ var result = SystemNotificationState.Unavailable;
+ RunAndWait(() =>
+ {
+ var notifier = EnsureNotifier();
+ // IToastNotifier::get_Setting (slot 8).
+ int setting;
+ var hr = ((delegate* unmanaged[Stdcall])VTable(notifier, 8))(notifier, &setting);
+ if (hr < 0)
+ {
+ _log.LogDebug("ToastNotifier.Setting failed: 0x{Hr:X8}", hr);
+ return;
+ }
+ result = setting switch
+ {
+ 0 => SystemNotificationState.Enabled,
+ 1 => SystemNotificationState.DisabledForApp,
+ 2 => SystemNotificationState.DisabledForUser,
+ _ => SystemNotificationState.DisabledByPolicy,
+ };
+ });
+ return result;
+ }
+ }
+
+ public void Show(NotificationRequest request)
+ {
+ var xml =
+ "" +
+ $"{SecurityElement.Escape(request.Title)}" +
+ $"{SecurityElement.Escape(request.Body)}" +
+ "";
+ _work.Add(() =>
+ {
+ try
+ {
+ ShowCore(xml);
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Windows toast failed");
+ }
+ });
+ }
+
+ public void OpenSettings()
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo("ms-settings:notifications") { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Could not open Windows notification settings");
+ }
+ }
+
+ // Writes the AUMID display name + icon so Windows can show who the toast is
+ // from. Idempotent; cheap enough to run once per process.
+ private void EnsureRegistered()
+ {
+ if (_registered) return;
+ try
+ {
+ if (!File.Exists(_iconPath))
+ {
+ using var src = Avalonia.Platform.AssetLoader.Open(new Uri("avares://OpenIPC.Viewer.App/Assets/openipc-logo.png"));
+ using var dst = File.Create(_iconPath);
+ src.CopyTo(dst);
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug(ex, "Could not write the toast icon; toasts will show without it");
+ }
+
+ using (var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\AppUserModelId\{AppUserModelId}"))
+ {
+ key.SetValue("DisplayName", DisplayName);
+ if (File.Exists(_iconPath)) key.SetValue("IconUri", _iconPath);
+ key.SetValue("IconBackgroundColor", "FF1E1E1E");
+ }
+ _registered = true;
+ }
+
+ private IntPtr EnsureNotifier()
+ {
+ if (_notifier != IntPtr.Zero) return _notifier;
+ EnsureRegistered();
+
+ var statics = GetActivationFactory("Windows.UI.Notifications.ToastNotificationManager", IID_IToastNotificationManagerStatics);
+ try
+ {
+ var aumid = CreateHString(AppUserModelId);
+ try
+ {
+ IntPtr notifier;
+ // IToastNotificationManagerStatics::CreateToastNotifierWithId (slot 7).
+ var hr = ((delegate* unmanaged[Stdcall])VTable(statics, 7))(statics, aumid, ¬ifier);
+ Check(hr, "CreateToastNotifierWithId");
+ _notifier = notifier;
+ }
+ finally
+ {
+ WindowsDeleteString(aumid);
+ }
+ }
+ finally
+ {
+ Release(statics);
+ }
+ return _notifier;
+ }
+
+ private void ShowCore(string xml)
+ {
+ var notifier = EnsureNotifier();
+
+ // XmlDocument → LoadXml(xml).
+ var docClass = CreateHString("Windows.Data.Xml.Dom.XmlDocument");
+ IntPtr docInspectable;
+ try
+ {
+ Check(RoActivateInstance(docClass, out docInspectable), "RoActivateInstance(XmlDocument)");
+ }
+ finally
+ {
+ WindowsDeleteString(docClass);
+ }
+
+ IntPtr docIo = IntPtr.Zero, doc = IntPtr.Zero, factory = IntPtr.Zero, toast = IntPtr.Zero;
+ try
+ {
+ docIo = QueryInterface(docInspectable, IID_IXmlDocumentIO);
+ var content = CreateHString(xml);
+ try
+ {
+ // IXmlDocumentIO::LoadXml (slot 6).
+ var hr = ((delegate* unmanaged[Stdcall])VTable(docIo, 6))(docIo, content);
+ Check(hr, "XmlDocument.LoadXml");
+ }
+ finally
+ {
+ WindowsDeleteString(content);
+ }
+
+ doc = QueryInterface(docInspectable, IID_IXmlDocument);
+ factory = GetActivationFactory("Windows.UI.Notifications.ToastNotification", IID_IToastNotificationFactory);
+
+ // IToastNotificationFactory::CreateToastNotification (slot 6).
+ IntPtr created;
+ var hr2 = ((delegate* unmanaged[Stdcall])VTable(factory, 6))(factory, doc, &created);
+ Check(hr2, "CreateToastNotification");
+ toast = created;
+
+ // IToastNotifier::Show (slot 6).
+ var hr3 = ((delegate* unmanaged[Stdcall])VTable(notifier, 6))(notifier, toast);
+ Check(hr3, "ToastNotifier.Show");
+ }
+ finally
+ {
+ Release(toast);
+ Release(factory);
+ Release(doc);
+ Release(docIo);
+ Release(docInspectable);
+ }
+ }
+
+ // --- worker thread -----------------------------------------------------
+
+ private void WorkerLoop()
+ {
+ // RO_INIT_MULTITHREADED. S_FALSE (already initialized) is fine; this
+ // thread lives as long as the process, so there is no matching uninit.
+ _ = RoInitialize(1);
+ foreach (var action in _work.GetConsumingEnumerable())
+ {
+ try { action(); }
+ catch (Exception ex) { _log.LogDebug(ex, "Toast worker action failed"); }
+ }
+ Release(_notifier);
+ _notifier = IntPtr.Zero;
+ }
+
+ private void RunAndWait(Action action)
+ {
+ using var done = new ManualResetEventSlim();
+ try
+ {
+ _work.Add(() =>
+ {
+ try { action(); }
+ catch (Exception ex) { _log.LogDebug(ex, "Toast state query failed"); }
+ finally { done.Set(); }
+ });
+ }
+ catch (InvalidOperationException)
+ {
+ return; // disposed
+ }
+ done.Wait(TimeSpan.FromSeconds(3));
+ }
+
+ public void Dispose() => _work.CompleteAdding();
+
+ // --- WinRT plumbing ----------------------------------------------------
+
+ private static void* VTable(IntPtr obj, int slot) => (*(void***)obj)[slot];
+
+ private static IntPtr QueryInterface(IntPtr obj, Guid iid)
+ {
+ var hr = Marshal.QueryInterface(obj, in iid, out var result);
+ Check(hr, $"QueryInterface({iid})");
+ return result;
+ }
+
+ private static void Release(IntPtr obj)
+ {
+ if (obj != IntPtr.Zero) Marshal.Release(obj);
+ }
+
+ private static IntPtr GetActivationFactory(string className, Guid iid)
+ {
+ var hs = CreateHString(className);
+ try
+ {
+ Check(RoGetActivationFactory(hs, in iid, out var factory), $"RoGetActivationFactory({className})");
+ return factory;
+ }
+ finally
+ {
+ WindowsDeleteString(hs);
+ }
+ }
+
+ private static IntPtr CreateHString(string value)
+ {
+ Check(WindowsCreateString(value, value.Length, out var hs), "WindowsCreateString");
+ return hs;
+ }
+
+ private static void Check(int hr, string what)
+ {
+ if (hr < 0) throw new COMException($"{what} failed: 0x{hr:X8}", hr);
+ }
+
+ [DllImport("combase.dll", ExactSpelling = true)]
+ private static extern int RoInitialize(int initType);
+
+ [DllImport("combase.dll", ExactSpelling = true)]
+ private static extern int RoGetActivationFactory(IntPtr activatableClassId, in Guid iid, out IntPtr factory);
+
+ [DllImport("combase.dll", ExactSpelling = true)]
+ private static extern int RoActivateInstance(IntPtr activatableClassId, out IntPtr instance);
+
+ [DllImport("combase.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
+ private static extern int WindowsCreateString(string sourceString, int length, out IntPtr hstring);
+
+ [DllImport("combase.dll", ExactSpelling = true)]
+ private static extern int WindowsDeleteString(IntPtr hstring);
+}
diff --git a/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj b/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj
index 4abc1f8..d333952 100644
--- a/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj
+++ b/src/OpenIPC.Viewer.Desktop/OpenIPC.Viewer.Desktop.csproj
@@ -7,6 +7,8 @@
app.manifest
openipc.ico
true
+
+ true
diff --git a/src/OpenIPC.Viewer.Infrastructure/Notifications/EventActionDispatcher.cs b/src/OpenIPC.Viewer.Infrastructure/Notifications/EventActionDispatcher.cs
new file mode 100644
index 0000000..034969f
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Notifications/EventActionDispatcher.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Notifications;
+
+namespace OpenIPC.Viewer.Infrastructure.Notifications;
+
+// Executes the external actions for one camera event (see EventActionSettings):
+// a raw TCP/UDP signal, an HTTP webhook, an e-mail and a local program. Every
+// enabled channel runs concurrently with its own timeout; one failing channel
+// never blocks or cancels the others.
+public sealed class EventActionDispatcher : IEventActionDispatcher, IDisposable
+{
+ private static readonly TimeSpan ChannelTimeout = TimeSpan.FromSeconds(20);
+ private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
+
+ private readonly IMailCredentialStore _credentials;
+ private readonly ILogger _log;
+ private readonly HttpClient _http;
+
+ public EventActionDispatcher(IMailCredentialStore credentials, ILogger log)
+ {
+ _credentials = credentials;
+ _log = log;
+ _http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ _http.DefaultRequestHeaders.UserAgent.ParseAdd("OpenIPC-Viewer");
+ }
+
+ public async Task DispatchAsync(EventActionContext context, EventActionSettings settings, CancellationToken ct)
+ {
+ var runs = new List();
+ if (settings.SignalEnabled && !string.IsNullOrWhiteSpace(settings.SignalHost) && settings.SignalPort > 0)
+ runs.Add(RunLoggedAsync(EventActionChannel.Signal, context, settings, ct));
+ if (settings.HttpEnabled && !string.IsNullOrWhiteSpace(settings.HttpUrl))
+ runs.Add(RunLoggedAsync(EventActionChannel.Http, context, settings, ct));
+ if (settings.EmailEnabled && !string.IsNullOrWhiteSpace(settings.EmailTo))
+ runs.Add(RunLoggedAsync(EventActionChannel.Email, context, settings, ct));
+ if (settings.ProgramEnabled && !string.IsNullOrWhiteSpace(settings.ProgramPath))
+ runs.Add(RunLoggedAsync(EventActionChannel.Program, context, settings, ct));
+
+ await Task.WhenAll(runs).ConfigureAwait(false);
+ }
+
+ public Task TestAsync(EventActionChannel channel, EventActionContext context, EventActionSettings settings, CancellationToken ct)
+ => RunAsync(channel, context, settings, ct);
+
+ private async Task RunLoggedAsync(EventActionChannel channel, EventActionContext context, EventActionSettings settings, CancellationToken ct)
+ {
+ try
+ {
+ await RunAsync(channel, context, settings, ct).ConfigureAwait(false);
+ _log.LogInformation("Event action {Channel} done for camera {Camera}", channel, context.CameraName);
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Event action {Channel} failed for camera {Camera}", channel, context.CameraName);
+ }
+ }
+
+ private async Task RunAsync(EventActionChannel channel, EventActionContext context, EventActionSettings settings, CancellationToken ct)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(ChannelTimeout);
+ switch (channel)
+ {
+ case EventActionChannel.Signal:
+ await SendSignalAsync(context, settings, cts.Token).ConfigureAwait(false);
+ break;
+ case EventActionChannel.Http:
+ await SendHttpAsync(context, settings, cts.Token).ConfigureAwait(false);
+ break;
+ case EventActionChannel.Email:
+ await SendEmailAsync(context, settings, cts.Token).ConfigureAwait(false);
+ break;
+ case EventActionChannel.Program:
+ StartProgram(context, settings);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(channel), channel, null);
+ }
+ }
+
+ // --- TCP / UDP ---------------------------------------------------------
+
+ private static async Task SendSignalAsync(EventActionContext context, EventActionSettings s, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(s.SignalHost) || s.SignalPort is <= 0 or > 65535)
+ throw new InvalidOperationException("Signal host and port (1–65535) are required");
+
+ var payload = SignalPayload.Encode(EventActionTemplate.Render(s.SignalPayload, context));
+ var host = s.SignalHost.Trim();
+
+ if (string.Equals(s.SignalProtocol, EventActionSettings.ProtocolUdp, StringComparison.OrdinalIgnoreCase))
+ {
+ using var udp = new UdpClient();
+ await udp.SendAsync(payload, host, s.SignalPort, ct).ConfigureAwait(false);
+ return;
+ }
+
+ using var tcp = new TcpClient { NoDelay = true };
+ using (var connect = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ connect.CancelAfter(ConnectTimeout);
+ await tcp.ConnectAsync(host, s.SignalPort, connect.Token).ConfigureAwait(false);
+ }
+ var stream = tcp.GetStream();
+ await using (stream.ConfigureAwait(false))
+ {
+ if (payload.Length > 0)
+ await stream.WriteAsync(payload, ct).ConfigureAwait(false);
+ await stream.FlushAsync(ct).ConfigureAwait(false);
+ }
+ }
+
+ // --- HTTP webhook ------------------------------------------------------
+
+ private async Task SendHttpAsync(EventActionContext context, EventActionSettings s, CancellationToken ct)
+ {
+ var url = EventActionTemplate.Render(s.HttpUrl.Trim(), context, Uri.EscapeDataString);
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ throw new InvalidOperationException($"Not an http(s) URL: {url}");
+
+ var method = (s.HttpMethod ?? "POST").Trim().ToUpperInvariant() switch
+ {
+ "GET" => HttpMethod.Get,
+ "PUT" => HttpMethod.Put,
+ _ => HttpMethod.Post,
+ };
+
+ using var request = new HttpRequestMessage(method, uri);
+ if (method != HttpMethod.Get)
+ {
+ var body = EventActionTemplate.Render(s.HttpBody ?? "", context, EventActionTemplate.JsonEscape);
+ request.Content = new StringContent(body, Encoding.UTF8, "application/json");
+ }
+
+ using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"Webhook answered {(int)response.StatusCode} {response.ReasonPhrase}");
+ }
+
+ // --- E-mail ------------------------------------------------------------
+
+ private async Task SendEmailAsync(EventActionContext context, EventActionSettings s, CancellationToken ct)
+ {
+ var to = EventActionTemplate.SplitAddresses(s.EmailTo);
+ if (to.Count == 0) throw new InvalidOperationException("No e-mail recipient configured");
+
+ var message = new OutgoingMail(
+ to,
+ EventActionTemplate.Render(s.EmailSubject ?? "", context),
+ EventActionTemplate.Render(s.EmailBody ?? "", context));
+
+ switch ((s.EmailTransport ?? "").Trim().ToLowerInvariant())
+ {
+ case EventActionSettings.TransportMapi:
+ if (!OperatingSystem.IsWindows())
+ throw new PlatformNotSupportedException("The default mail client (MAPI) is only available on Windows");
+ await MapiMailSender.SendAsync(message, ct).ConfigureAwait(false);
+ break;
+ case EventActionSettings.TransportOutlook:
+ if (!OperatingSystem.IsWindows())
+ throw new PlatformNotSupportedException("Outlook automation is only available on Windows");
+ await OutlookMailSender.SendAsync(message, ct).ConfigureAwait(false);
+ break;
+ default:
+ var credential = await _credentials.GetAsync(ct).ConfigureAwait(false);
+ await SmtpMailSender.SendAsync(message, s, credential, ct).ConfigureAwait(false);
+ break;
+ }
+ }
+
+ // --- Program -----------------------------------------------------------
+
+ private static void StartProgram(EventActionContext context, EventActionSettings s)
+ {
+ var path = Environment.ExpandEnvironmentVariables(s.ProgramPath.Trim().Trim('"'));
+ if (!File.Exists(path))
+ throw new FileNotFoundException("Program not found", path);
+
+ var args = EventActionTemplate.Render(s.ProgramArguments ?? "", context);
+ var ext = Path.GetExtension(path).ToLowerInvariant();
+
+ ProcessStartInfo psi;
+ if (ext == ".ps1")
+ {
+ // A script the user picked themselves; run it without the profile and
+ // without tripping over the machine's default execution policy.
+ psi = new ProcessStartInfo("powershell.exe",
+ $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{path}\" {args}");
+ }
+ else if (ext is ".exe" or ".bat" or ".cmd" or ".com" || !OperatingSystem.IsWindows())
+ {
+ psi = new ProcessStartInfo(path, args);
+ }
+ else
+ {
+ // .vbs, .py, … — let the shell pick the associated interpreter.
+ psi = new ProcessStartInfo(path, args) { UseShellExecute = true };
+ }
+
+ if (!psi.UseShellExecute)
+ {
+ psi.CreateNoWindow = true;
+ psi.WorkingDirectory = Path.GetDirectoryName(path) ?? "";
+ // The same facts as environment variables, so a script needn't parse
+ // its command line.
+ var e = context.Event;
+ psi.Environment["OPENIPC_EVENT_KIND"] = context.KindToken;
+ psi.Environment["OPENIPC_EVENT_TITLE"] = context.Title;
+ psi.Environment["OPENIPC_EVENT_ID"] = e.Id.ToString();
+ psi.Environment["OPENIPC_CAMERA"] = context.CameraName;
+ psi.Environment["OPENIPC_CAMERA_ID"] = e.CameraId.ToString();
+ psi.Environment["OPENIPC_SUMMARY"] = e.Summary ?? "";
+ psi.Environment["OPENIPC_CONFIDENCE"] = EventActionTemplate.Render("{confidence}", context);
+ psi.Environment["OPENIPC_OBJECTS"] = EventActionTemplate.Render("{objects}", context);
+ psi.Environment["OPENIPC_TIME_UTC"] = EventActionTemplate.Render("{timeUtc}", context);
+ }
+
+ using var process = Process.Start(psi)
+ ?? throw new InvalidOperationException($"Could not start {path}");
+ }
+
+ public void Dispose() => _http.Dispose();
+}
+
+// One rendered message, transport-neutral.
+internal sealed record OutgoingMail(IReadOnlyList To, string Subject, string Body);
diff --git a/src/OpenIPC.Viewer.Infrastructure/Notifications/MailCredentialStores.cs b/src/OpenIPC.Viewer.Infrastructure/Notifications/MailCredentialStores.cs
new file mode 100644
index 0000000..e5b0697
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Notifications/MailCredentialStores.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenIPC.Viewer.Core.Notifications;
+using OpenIPC.Viewer.Core.Platform;
+
+namespace OpenIPC.Viewer.Infrastructure.Notifications;
+
+// SMTP login/password as a generic credential in Windows Credential Manager
+// (Control Panel → Credential Manager → Windows Credentials → "OpenIPC Viewer
+// SMTP"), so the user can see, edit or remove it with the standard Windows tool.
+// The blob is encrypted by Windows for the current user.
+[SupportedOSPlatform("windows")]
+public sealed class WindowsCredentialMailStore : IMailCredentialStore
+{
+ public const string TargetName = "OpenIPC Viewer SMTP";
+
+ private const uint CredTypeGeneric = 1;
+ private const uint CredPersistLocalMachine = 2;
+ private const int ErrorNotFound = 1168;
+
+ public string StorageName => "Windows Credential Manager";
+
+ public Task GetAsync(CancellationToken ct)
+ {
+ if (!CredReadW(TargetName, CredTypeGeneric, 0, out var ptr))
+ {
+ var err = Marshal.GetLastWin32Error();
+ if (err == ErrorNotFound) return Task.FromResult(null);
+ throw new InvalidOperationException($"CredRead failed: {err}");
+ }
+ try
+ {
+ var cred = Marshal.PtrToStructure(ptr);
+ var user = cred.UserName == IntPtr.Zero ? "" : Marshal.PtrToStringUni(cred.UserName) ?? "";
+ var password = "";
+ if (cred.CredentialBlob != IntPtr.Zero && cred.CredentialBlobSize > 0)
+ {
+ var bytes = new byte[cred.CredentialBlobSize];
+ Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length);
+ password = Encoding.Unicode.GetString(bytes);
+ }
+ return Task.FromResult(new MailCredential(user, password));
+ }
+ finally
+ {
+ CredFree(ptr);
+ }
+ }
+
+ public Task SetAsync(MailCredential credential, CancellationToken ct)
+ {
+ var blob = Encoding.Unicode.GetBytes(credential.Password ?? "");
+ var target = Marshal.StringToHGlobalUni(TargetName);
+ var user = Marshal.StringToHGlobalUni(credential.UserName ?? "");
+ var comment = Marshal.StringToHGlobalUni("SMTP login used by OpenIPC Viewer to e-mail camera events");
+ var blobPtr = Marshal.AllocHGlobal(Math.Max(1, blob.Length));
+ try
+ {
+ Marshal.Copy(blob, 0, blobPtr, blob.Length);
+ var cred = new CREDENTIALW
+ {
+ Type = CredTypeGeneric,
+ TargetName = target,
+ Comment = comment,
+ CredentialBlobSize = (uint)blob.Length,
+ CredentialBlob = blobPtr,
+ Persist = CredPersistLocalMachine,
+ UserName = user,
+ };
+ if (!CredWriteW(ref cred, 0))
+ throw new InvalidOperationException($"CredWrite failed: {Marshal.GetLastWin32Error()}");
+ }
+ finally
+ {
+ // Scrub the plaintext copy before releasing it.
+ if (blob.Length > 0) Marshal.Copy(new byte[blob.Length], 0, blobPtr, blob.Length);
+ Marshal.FreeHGlobal(blobPtr);
+ Marshal.FreeHGlobal(comment);
+ Marshal.FreeHGlobal(user);
+ Marshal.FreeHGlobal(target);
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task ClearAsync(CancellationToken ct)
+ {
+ if (!CredDeleteW(TargetName, CredTypeGeneric, 0))
+ {
+ var err = Marshal.GetLastWin32Error();
+ if (err != ErrorNotFound)
+ throw new InvalidOperationException($"CredDelete failed: {err}");
+ }
+ return Task.CompletedTask;
+ }
+
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
+ private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential);
+
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
+ private static extern bool CredWriteW(ref CREDENTIALW credential, uint flags);
+
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
+ private static extern bool CredDeleteW(string target, uint type, uint flags);
+
+ [DllImport("advapi32.dll", ExactSpelling = true)]
+ private static extern void CredFree(IntPtr buffer);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CREDENTIALW
+ {
+ public uint Flags;
+ public uint Type;
+ public IntPtr TargetName;
+ public IntPtr Comment;
+ public long LastWritten; // FILETIME
+ public uint CredentialBlobSize;
+ public IntPtr CredentialBlob;
+ public uint Persist;
+ public uint AttributeCount;
+ public IntPtr Attributes;
+ public IntPtr TargetAlias;
+ public IntPtr UserName;
+ }
+}
+
+// Non-Windows heads: the same login/password in the platform secrets store
+// (libsecret / Keychain / Android Keystore-backed file).
+public sealed class SecretsStoreMailCredentialStore : IMailCredentialStore
+{
+ private const string UserKey = "notifications.smtp.user";
+ private const string PasswordKey = "notifications.smtp.password";
+
+ private readonly ISecretsStore _secrets;
+
+ public SecretsStoreMailCredentialStore(ISecretsStore secrets) => _secrets = secrets;
+
+ public string StorageName => "system secure storage";
+
+ public async Task GetAsync(CancellationToken ct)
+ {
+ var user = await _secrets.GetAsync(UserKey, ct).ConfigureAwait(false);
+ var password = await _secrets.GetAsync(PasswordKey, ct).ConfigureAwait(false);
+ return user is null && password is null ? null : new MailCredential(user ?? "", password ?? "");
+ }
+
+ public async Task SetAsync(MailCredential credential, CancellationToken ct)
+ {
+ await _secrets.SetAsync(UserKey, credential.UserName ?? "", ct).ConfigureAwait(false);
+ await _secrets.SetAsync(PasswordKey, credential.Password ?? "", ct).ConfigureAwait(false);
+ }
+
+ public async Task ClearAsync(CancellationToken ct)
+ {
+ await _secrets.RemoveAsync(UserKey, ct).ConfigureAwait(false);
+ await _secrets.RemoveAsync(PasswordKey, ct).ConfigureAwait(false);
+ }
+}
diff --git a/src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs b/src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs
new file mode 100644
index 0000000..9db591d
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Infrastructure.Notifications;
+
+// E-mail through the user's default Windows mail client via Simple MAPI
+// (MAPISendMailW in mapi32.dll) — the message goes out through whatever account
+// is already set up there, so the app never needs SMTP settings or a password.
+// Works with classic Outlook, Thunderbird, The Bat!, eM Client, …; the new
+// Outlook and the built-in Mail app do not implement MAPI.
+//
+// Sent without a dialog (no MAPI_DIALOG). Some clients still show a compose
+// window or a security prompt for programmatic sending — that is their policy.
+// MAPI clients expect a single-threaded apartment, so each send gets its own
+// STA thread.
+[SupportedOSPlatform("windows")]
+internal static class MapiMailSender
+{
+ private const uint MapiTo = 1;
+ private const uint SuccessSuccess = 0;
+
+ public static Task SendAsync(OutgoingMail mail, CancellationToken ct)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ Send(mail);
+ tcs.TrySetResult();
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(ex);
+ }
+ })
+ {
+ IsBackground = true,
+ Name = "mapi-send",
+ };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ // The MAPI call itself can't be cancelled; ct only stops the wait.
+ return tcs.Task.WaitAsync(ct);
+ }
+
+ private static void Send(OutgoingMail mail)
+ {
+ var recipSize = Marshal.SizeOf();
+ var allocated = new System.Collections.Generic.List();
+ IntPtr Str(string s)
+ {
+ var p = Marshal.StringToHGlobalUni(s);
+ allocated.Add(p);
+ return p;
+ }
+
+ var recips = Marshal.AllocHGlobal(recipSize * mail.To.Count);
+ try
+ {
+ for (var i = 0; i < mail.To.Count; i++)
+ {
+ var r = new MapiRecipDescW
+ {
+ ulRecipClass = MapiTo,
+ lpszName = Str(mail.To[i]),
+ lpszAddress = Str("SMTP:" + mail.To[i]),
+ };
+ Marshal.StructureToPtr(r, recips + i * recipSize, false);
+ }
+
+ var msg = new MapiMessageW
+ {
+ lpszSubject = Str(mail.Subject),
+ lpszNoteText = Str(mail.Body),
+ nRecipCount = (uint)mail.To.Count,
+ lpRecips = recips,
+ };
+
+ var rc = MAPISendMailW(IntPtr.Zero, IntPtr.Zero, ref msg, 0, 0);
+ if (rc != SuccessSuccess)
+ throw new InvalidOperationException(Describe(rc));
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(recips);
+ foreach (var p in allocated) Marshal.FreeHGlobal(p);
+ }
+ }
+
+ private static string Describe(uint rc) => rc switch
+ {
+ 1 => "The mail client cancelled sending (MAPI_USER_ABORT)",
+ 2 => "The mail client reported a failure (MAPI_E_FAILURE). Is a default mail program set and configured?",
+ 3 => "The mail client could not log on (MAPI_E_LOGON_FAILURE). Open it once and set up an account",
+ 14 => "A recipient address is unknown (MAPI_E_UNKNOWN_RECIPIENT)",
+ 21 => "A recipient address is ambiguous (MAPI_E_AMBIGUOUS_RECIPIENT)",
+ 23 => "The mail client reported a network failure (MAPI_E_NETWORK_FAILURE)",
+ 25 => "A recipient address is invalid (MAPI_E_INVALID_RECIPS)",
+ 26 => "The default mail client does not support MAPI (MAPI_E_NOT_SUPPORTED)",
+ _ => $"Simple MAPI error {rc}",
+ };
+
+ [DllImport("mapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
+ private static extern uint MAPISendMailW(IntPtr lhSession, IntPtr ulUIParam, ref MapiMessageW lpMessage, uint flFlags, uint ulReserved);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MapiMessageW
+ {
+ public uint ulReserved;
+ public IntPtr lpszSubject;
+ public IntPtr lpszNoteText;
+ public IntPtr lpszMessageType;
+ public IntPtr lpszDateReceived;
+ public IntPtr lpszConversationID;
+ public uint flFlags;
+ public IntPtr lpOriginator;
+ public uint nRecipCount;
+ public IntPtr lpRecips;
+ public uint nFileCount;
+ public IntPtr lpFiles;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MapiRecipDescW
+ {
+ public uint ulReserved;
+ public uint ulRecipClass;
+ public IntPtr lpszName;
+ public IntPtr lpszAddress;
+ public uint ulEIDSize;
+ public IntPtr lpEntryID;
+ }
+}
diff --git a/src/OpenIPC.Viewer.Infrastructure/Notifications/OutlookMailSender.cs b/src/OpenIPC.Viewer.Infrastructure/Notifications/OutlookMailSender.cs
new file mode 100644
index 0000000..a0a5ebd
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Notifications/OutlookMailSender.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenIPC.Viewer.Infrastructure.Notifications;
+
+// E-mail by automating an installed classic Outlook ("Outlook.Application" COM
+// server): attaches to the running Outlook or starts it, creates a MailItem and
+// sends it from the default account. Late-bound through IDispatch so there is
+// no build-time dependency on the Office interop assemblies.
+//
+// Outlook's object-model guard may ask the user to allow programmatic sending
+// when no up-to-date antivirus is registered — that prompt is Outlook's. The
+// new Outlook (olk.exe) has no COM automation.
+[SupportedOSPlatform("windows")]
+internal static class OutlookMailSender
+{
+ private const int OlMailItem = 0;
+
+ public static Task SendAsync(OutgoingMail mail, CancellationToken ct)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ Send(mail);
+ tcs.TrySetResult();
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException is not null)
+ {
+ tcs.TrySetException(ex.InnerException);
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(ex);
+ }
+ })
+ {
+ IsBackground = true,
+ Name = "outlook-send",
+ };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return tcs.Task.WaitAsync(ct);
+ }
+
+ private static void Send(OutgoingMail mail)
+ {
+ var type = Type.GetTypeFromProgID("Outlook.Application")
+ ?? throw new InvalidOperationException("Classic Outlook is not installed (no Outlook.Application COM server)");
+
+ object? app = null, item = null;
+ try
+ {
+ app = Activator.CreateInstance(type)
+ ?? throw new InvalidOperationException("Could not start Outlook");
+ item = Invoke(app, "CreateItem", OlMailItem)
+ ?? throw new InvalidOperationException("Outlook did not create a mail item");
+ Set(item, "To", string.Join("; ", mail.To));
+ Set(item, "Subject", mail.Subject);
+ Set(item, "Body", mail.Body);
+ Invoke(item, "Send");
+ }
+ finally
+ {
+ if (item is not null && Marshal.IsComObject(item)) Marshal.ReleaseComObject(item);
+ if (app is not null && Marshal.IsComObject(app)) Marshal.ReleaseComObject(app);
+ }
+ }
+
+ private static object? Invoke(object target, string name, params object[] args) =>
+ target.GetType().InvokeMember(name, BindingFlags.InvokeMethod, null, target, args,
+ System.Globalization.CultureInfo.InvariantCulture);
+
+ private static void Set(object target, string name, object value) =>
+ target.GetType().InvokeMember(name, BindingFlags.SetProperty, null, target, new[] { value },
+ System.Globalization.CultureInfo.InvariantCulture);
+}
diff --git a/src/OpenIPC.Viewer.Infrastructure/Notifications/SmtpMailSender.cs b/src/OpenIPC.Viewer.Infrastructure/Notifications/SmtpMailSender.cs
new file mode 100644
index 0000000..4fa04f7
--- /dev/null
+++ b/src/OpenIPC.Viewer.Infrastructure/Notifications/SmtpMailSender.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MailKit.Security;
+using MimeKit;
+using OpenIPC.Viewer.Core.Notifications;
+
+namespace OpenIPC.Viewer.Infrastructure.Notifications;
+
+// SMTP transport for the e-mail action. Security "auto" follows the common
+// convention: port 465 → TLS from the first byte, anything else → STARTTLS when
+// the server offers it. The login/password come from IMailCredentialStore
+// (Windows Credential Manager on Windows), never from usersettings.json.
+internal static class SmtpMailSender
+{
+ public static async Task SendAsync(OutgoingMail mail, EventActionSettings s, MailCredential? credential, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(s.SmtpHost))
+ throw new InvalidOperationException("SMTP server is not set");
+
+ var port = s.SmtpPort is > 0 and <= 65535 ? s.SmtpPort : 587;
+ var options = (s.SmtpSecurity ?? "auto").Trim().ToLowerInvariant() switch
+ {
+ "ssl" => SecureSocketOptions.SslOnConnect,
+ "starttls" => SecureSocketOptions.StartTls,
+ "none" => SecureSocketOptions.None,
+ _ => port == 465 ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable,
+ };
+
+ var hasLogin = credential is not null && !string.IsNullOrWhiteSpace(credential.UserName);
+ var from = !string.IsNullOrWhiteSpace(s.SmtpFrom) ? s.SmtpFrom.Trim()
+ : hasLogin && credential!.UserName.Contains('@') ? credential.UserName.Trim()
+ : throw new InvalidOperationException("Set the sender address (From) or use an e-mail address as the login");
+
+ var message = new MimeMessage();
+ message.From.Add(MailboxAddress.Parse(from));
+ foreach (var to in mail.To)
+ message.To.Add(MailboxAddress.Parse(to));
+ message.Subject = mail.Subject;
+ message.Body = new TextPart("plain") { Text = mail.Body };
+
+ using var client = new MailKit.Net.Smtp.SmtpClient { Timeout = 15000 };
+ await client.ConnectAsync(s.SmtpHost.Trim(), port, options, ct).ConfigureAwait(false);
+ if (hasLogin)
+ await client.AuthenticateAsync(credential!.UserName.Trim(), credential.Password, ct).ConfigureAwait(false);
+ await client.SendAsync(message, ct).ConfigureAwait(false);
+ await client.DisconnectAsync(true, ct).ConfigureAwait(false);
+ }
+}
diff --git a/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj b/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj
index acb74a2..29f972e 100644
--- a/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj
+++ b/src/OpenIPC.Viewer.Infrastructure/OpenIPC.Viewer.Infrastructure.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegAudioClipDecoder.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegAudioClipDecoder.cs
new file mode 100644
index 0000000..d316466
--- /dev/null
+++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegAudioClipDecoder.cs
@@ -0,0 +1,76 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using FFmpeg.AutoGen.Abstractions;
+using Microsoft.Extensions.Logging;
+using OpenIPC.Viewer.Core.Notifications;
+
+namespace OpenIPC.Viewer.Video.Pipeline;
+
+// Decodes a whole (short) audio file to PCM for the custom notification sound —
+// mp3, ogg, m4a, flac, wav, anything this FFmpeg build reads. Reuses the live
+// pipeline's FfmpegAudioDecoder, so the result is 48 kHz stereo S16. Stops at
+// maxLength so picking a song doesn't load ten minutes of audio.
+public sealed class FfmpegAudioClipDecoder : IAudioClipDecoder
+{
+ private readonly ILogger _logger;
+
+ public FfmpegAudioClipDecoder(ILogger logger) => _logger = logger;
+
+ public Task DecodeAsync(string path, TimeSpan maxLength, CancellationToken ct) =>
+ Task.Run(() => Decode(path, maxLength, ct), ct);
+
+ private unsafe PcmClip Decode(string path, TimeSpan maxLength, CancellationToken ct)
+ {
+ FfmpegRuntime.EnsureInitialized();
+
+ var maxBytes = (long)(maxLength.TotalSeconds * FfmpegAudioDecoder.OutSampleRate)
+ * FfmpegAudioDecoder.OutChannels * 2;
+ using var pcm = new MemoryStream();
+
+ AVFormatContext* fmtCtx = null;
+ AVPacket* packet = null;
+ FfmpegAudioDecoder? decoder = null;
+ try
+ {
+ var ret = ffmpeg.avformat_open_input(&fmtCtx, path, null, null);
+ FfmpegError.ThrowIfError(ret, "avformat_open_input");
+ ret = ffmpeg.avformat_find_stream_info(fmtCtx, null);
+ FfmpegError.ThrowIfError(ret, "avformat_find_stream_info");
+
+ decoder = FfmpegAudioDecoder.TryOpen(fmtCtx, _logger)
+ ?? throw new InvalidDataException("The file has no audio this build can decode");
+
+ void Collect(Core.Video.AudioFrame f)
+ {
+ var room = maxBytes - pcm.Length;
+ if (room <= 0) return;
+ pcm.Write(f.Pcm16, 0, (int)Math.Min(room, f.Pcm16.Length));
+ }
+
+ packet = ffmpeg.av_packet_alloc();
+ while (!ct.IsCancellationRequested && pcm.Length < maxBytes)
+ {
+ ret = ffmpeg.av_read_frame(fmtCtx, packet);
+ if (ret < 0) break; // EOF or read error — keep what we have
+ if (packet->stream_index == decoder.StreamIndex)
+ decoder.Decode(packet, Collect, ct);
+ ffmpeg.av_packet_unref(packet);
+ }
+ // Drain the decoder's buffered tail.
+ if (pcm.Length < maxBytes)
+ decoder.Decode(null, Collect, ct);
+ }
+ finally
+ {
+ decoder?.Dispose();
+ if (packet != null) ffmpeg.av_packet_free(&packet);
+ if (fmtCtx != null) ffmpeg.avformat_close_input(&fmtCtx);
+ }
+
+ ct.ThrowIfCancellationRequested();
+ if (pcm.Length == 0) throw new InvalidDataException("No audio could be decoded from the file");
+ return new PcmClip(pcm.ToArray(), FfmpegAudioDecoder.OutSampleRate, FfmpegAudioDecoder.OutChannels);
+ }
+}
diff --git a/src/OpenIPC.Viewer.Web/Backend/ServerSettings.cs b/src/OpenIPC.Viewer.Web/Backend/ServerSettings.cs
index 0cd93b4..8702040 100644
--- a/src/OpenIPC.Viewer.Web/Backend/ServerSettings.cs
+++ b/src/OpenIPC.Viewer.Web/Backend/ServerSettings.cs
@@ -32,4 +32,9 @@ internal sealed class ServerSettings : IUserSettingsAccessor
public bool QuietHoursEnabled => false;
public int QuietHoursStartHour => 0;
public int QuietHoursEndHour => 0;
+ public int NotifyMinConfidencePercent => 0;
+ public int NotifyMinDurationSeconds => 0;
+ public int NotifyMinObjects => 1;
+ public OpenIPC.Viewer.Core.Notifications.EventActionSettings EventActions =>
+ OpenIPC.Viewer.Core.Notifications.EventActionSettings.Default;
}
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventActionTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventActionTests.cs
new file mode 100644
index 0000000..7b55122
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventActionTests.cs
@@ -0,0 +1,200 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Events;
+using OpenIPC.Viewer.Core.Notifications;
+using Xunit;
+
+namespace OpenIPC.Viewer.Core.Tests.Notifications;
+
+public class EventActionTemplateTests
+{
+ private static EventActionContext Ctx(string? summary = "person ×2", float? confidence = 0.874f, int objects = 2) => new(
+ new CameraEvent(new EventId(Guid.Parse("11111111-1111-1111-1111-111111111111")),
+ new CameraId(Guid.Parse("22222222-2222-2222-2222-222222222222")), EventKind.Detection, EventSeverity.Info,
+ new DateTime(2026, 10, 17, 9, 30, 0, DateTimeKind.Utc), null, "analytics", summary, confidence, objects),
+ "Gate \"A\"", "Object detected", new DateTime(2026, 10, 17, 9, 30, 12, DateTimeKind.Utc));
+
+ [Fact]
+ public void Render_ExpandsKnownPlaceholders()
+ {
+ var text = EventActionTemplate.Render("{title}: {camera} {kind} {summary} {confidence}% x{objects} {duration}s {timeUtc}", Ctx());
+ Assert.Equal("Object detected: Gate \"A\" detection person ×2 87% x2 12s 2026-10-17T09:30:00Z", text);
+ }
+
+ [Fact]
+ public void Render_LeavesUnknownBracesAlone()
+ {
+ Assert.Equal("{nope} {} {", EventActionTemplate.Render("{nope} {} {", Ctx()));
+ }
+
+ [Fact]
+ public void Render_JsonBody_StaysValidJson()
+ {
+ var body = EventActionTemplate.Render(EventActionSettings.DefaultHttpBody, Ctx(), EventActionTemplate.JsonEscape);
+ using var doc = System.Text.Json.JsonDocument.Parse(body);
+ Assert.Equal("detection", doc.RootElement.GetProperty("event").GetString());
+ Assert.Equal("Gate \"A\"", doc.RootElement.GetProperty("camera").GetString());
+ Assert.Equal("87", doc.RootElement.GetProperty("confidence").GetString());
+ }
+
+ [Fact]
+ public void Render_MissingConfidence_IsEmpty()
+ {
+ Assert.Equal("[]", EventActionTemplate.Render("[{confidence}]", Ctx(confidence: null)));
+ }
+
+ [Fact]
+ public void SplitAddresses_HandlesBothSeparators()
+ {
+ Assert.Equal(new[] { "a@x.ru", "b@y.com", "c@z.org" },
+ EventActionTemplate.SplitAddresses(" a@x.ru; b@y.com ,c@z.org;; "));
+ }
+
+ [Fact]
+ public void AnyChannelEnabled_NeedsConfiguration()
+ {
+ Assert.False(new EventActionSettings(SignalEnabled: true).AnyChannelEnabled); // no host/port
+ Assert.True(new EventActionSettings(SignalEnabled: true, SignalHost: "10.0.0.5", SignalPort: 4000).AnyChannelEnabled);
+ Assert.False(new EventActionSettings(HttpUrl: "http://x").AnyChannelEnabled); // not enabled
+ }
+}
+
+public class SignalPayloadTests
+{
+ [Fact]
+ public void Text_WithEscapes()
+ {
+ Assert.Equal(Encoding.UTF8.GetBytes("ALARM 1\r\n"), SignalPayload.Encode("ALARM 1\\r\\n"));
+ }
+
+ [Fact]
+ public void Text_RawByteEscape_IsNotUtf8Encoded()
+ {
+ Assert.Equal(new byte[] { (byte)'A', 0xFF, (byte)'B' }, SignalPayload.Encode("A\\xFFB"));
+ }
+
+ [Fact]
+ public void Text_Cyrillic_IsUtf8()
+ {
+ Assert.Equal(Encoding.UTF8.GetBytes("Тревога"), SignalPayload.Encode("Тревога"));
+ }
+
+ [Fact]
+ public void Hex_IgnoresSeparators()
+ {
+ Assert.Equal(new byte[] { 0xA0, 0x01, 0x01, 0xA2 }, SignalPayload.Encode("hex: A0 01-01:a2"));
+ }
+
+ [Fact]
+ public void Hex_OddDigits_Throws()
+ {
+ Assert.Throws(() => SignalPayload.Encode("hex:ABC"));
+ }
+}
+
+public class PcmClipTests
+{
+ [Fact]
+ public void Wav_RoundTrip()
+ {
+ var pcm = new byte[] { 0x00, 0x10, 0xFF, 0x7F, 0x00, 0x80, 0x34, 0x12 };
+ var clip = new PcmClip(pcm, 22050, 2);
+ using var ms = new MemoryStream();
+ WavCodec.Write(ms, clip);
+ ms.Position = 0;
+
+ var back = WavCodec.Read(ms);
+ Assert.Equal(22050, back.SampleRate);
+ Assert.Equal(2, back.Channels);
+ Assert.Equal(pcm, back.Pcm16);
+ }
+
+ [Fact]
+ public void Wav_8BitMono_IsConvertedTo16Bit()
+ {
+ var bytes = BuildWav(format: 1, channels: 1, rate: 8000, bits: 8, data: new byte[] { 128, 255, 0 });
+ var clip = WavCodec.Read(new MemoryStream(bytes));
+ Assert.Equal(1, clip.Channels);
+ var samples = Samples(clip);
+ Assert.Equal(0, samples[0]);
+ Assert.Equal(32512, samples[1]); // 127/128 of full scale
+ Assert.Equal(-32768, samples[2]);
+ }
+
+ [Fact]
+ public void Wav_FloatSurround_DownmixesToStereo()
+ {
+ var data = new byte[4 * 4]; // one frame, 4 float channels
+ BitConverter.GetBytes(0.5f).CopyTo(data, 0);
+ BitConverter.GetBytes(-0.5f).CopyTo(data, 4);
+ var clip = WavCodec.Read(new MemoryStream(BuildWav(3, 4, 48000, 32, data)));
+ Assert.Equal(2, clip.Channels);
+ var s = Samples(clip);
+ Assert.True(s[0] > 0 && s[1] < 0);
+ }
+
+ [Fact]
+ public void Wav_NotRiff_Throws()
+ {
+ Assert.Throws(() => WavCodec.Read(new MemoryStream(Encoding.ASCII.GetBytes("ID3 not a wav file at all"))));
+ }
+
+ [Fact]
+ public void Volume_ScalesAndClamps()
+ {
+ var clip = new PcmClip(new byte[] { 0x00, 0x40, 0x00, 0xC0 }, 48000, 1); // +16384, -16384
+ var half = Samples(new PcmClip(clip.WithVolume(0.5), 48000, 1));
+ Assert.Equal(new short[] { 8192, -8192 }, half);
+ Assert.Equal(clip.Pcm16, clip.WithVolume(3));
+ Assert.All(clip.WithVolume(0), b => Assert.Equal(0, b));
+ }
+
+ [Fact]
+ public void Truncate_KeepsWholeFrames()
+ {
+ var clip = new PcmClip(new byte[48000 * 4 * 3], 48000, 2); // 3 s stereo
+ var cut = clip.Truncate(TimeSpan.FromSeconds(1));
+ Assert.Equal(48000 * 4, cut.Pcm16.Length);
+ Assert.Same(clip, clip.Truncate(TimeSpan.FromSeconds(10)));
+ }
+
+ [Fact]
+ public void Chime_IsShortStereoAndNotSilent()
+ {
+ var chime = NotificationChime.Generate();
+ Assert.Equal(2, chime.Channels);
+ Assert.InRange(chime.Duration.TotalSeconds, 0.5, 1.5);
+ Assert.Contains(Samples(chime), s => Math.Abs((int)s) > 3000);
+ }
+
+ private static short[] Samples(PcmClip c) =>
+ Enumerable.Range(0, c.Pcm16.Length / 2).Select(i => (short)(c.Pcm16[2 * i] | (c.Pcm16[2 * i + 1] << 8))).ToArray();
+
+ private static byte[] BuildWav(ushort format, ushort channels, int rate, ushort bits, byte[] data)
+ {
+ using var ms = new MemoryStream();
+ using var w = new BinaryWriter(ms);
+ var block = (ushort)(channels * bits / 8);
+ w.Write(Encoding.ASCII.GetBytes("RIFF"));
+ w.Write(36 + data.Length + 8); // + a LIST chunk before data, to test chunk skipping
+ w.Write(Encoding.ASCII.GetBytes("WAVE"));
+ w.Write(Encoding.ASCII.GetBytes("fmt "));
+ w.Write(16);
+ w.Write(format);
+ w.Write(channels);
+ w.Write(rate);
+ w.Write(rate * block);
+ w.Write(block);
+ w.Write(bits);
+ w.Write(Encoding.ASCII.GetBytes("LIST"));
+ w.Write(0);
+ w.Write(Encoding.ASCII.GetBytes("data"));
+ w.Write(data.Length);
+ w.Write(data);
+ w.Flush();
+ return ms.ToArray();
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs
new file mode 100644
index 0000000..4404127
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Events;
+using OpenIPC.Viewer.Core.Services;
+using OpenIPC.Viewer.Core.Tests.Fakes;
+using Xunit;
+
+namespace OpenIPC.Viewer.Core.Tests.Notifications;
+
+// The ingestion service re-publishes an open episode when its peak confidence
+// or object count rises — that is what lets a confidence threshold fire on a
+// detection that firms up after the first frame.
+public class EventIngestionPeakTests
+{
+ [Fact]
+ public async Task PeakRise_RepublishesOpenEpisode()
+ {
+ var (ingestion, source, camera, seen) = await StartAsync();
+
+ source.Emit(new MotionTick(camera, DateTime.UtcNow, "test", EventKind.Detection, "person ×1", 0.5f, 1));
+ await WaitForAsync(() => seen.Count >= 1);
+
+ source.Emit(new MotionTick(camera, DateTime.UtcNow, "test", EventKind.Detection, "person ×1", 0.4f, 1)); // lower → no update
+ source.Emit(new MotionTick(camera, DateTime.UtcNow, "test", EventKind.Detection, "person ×2", 0.9f, 2)); // higher → update
+ await WaitForAsync(() => seen.Count >= 2);
+
+ lock (seen)
+ {
+ Assert.Equal(2, seen.Count);
+ Assert.All(seen, e => Assert.Null(e.EndedAt));
+ Assert.Equal(seen[0].Id, seen[1].Id);
+ Assert.Equal(0.9f, seen[1].PeakConfidence);
+ Assert.Equal(2, seen[1].PeakObjects);
+ }
+ await ingestion.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task PlainMotion_HasNoPeaks()
+ {
+ var (ingestion, source, camera, seen) = await StartAsync();
+ source.Emit(new MotionTick(camera, DateTime.UtcNow, "test"));
+ await WaitForAsync(() => seen.Count >= 1);
+ lock (seen)
+ {
+ Assert.Null(seen[0].PeakConfidence);
+ Assert.Equal(0, seen[0].PeakObjects);
+ }
+ await ingestion.DisposeAsync();
+ }
+
+ private static async Task<(EventIngestionService, TestSource, CameraId, List)> StartAsync()
+ {
+ var cameras = new CameraDirectoryService(new InMemoryCameraRepository(), new InMemoryGroupRepository(), new InMemorySecretsStore());
+ var id = await cameras.AddAsync(new NewCameraRequest(
+ "Gate", "192.168.1.10", 80, null, new Uri("rtsp://192.168.1.10/"), null,
+ new CameraCredentials("admin", "x")), CancellationToken.None);
+
+ var source = new TestSource();
+ var ingestion = new EventIngestionService(new[] { source }, new NullRepo(), cameras);
+ var seen = new List();
+ ingestion.Events.Subscribe(new Collector(seen));
+ await ingestion.StartAsync(CancellationToken.None);
+ return (ingestion, source, id, seen);
+ }
+
+ private static async Task WaitForAsync(Func condition)
+ {
+ for (var i = 0; i < 100; i++)
+ {
+ if (condition()) return;
+ await Task.Delay(20);
+ }
+ }
+
+ private sealed class TestSource : IMotionEventSource
+ {
+ private readonly List> _observers = new();
+ public string Name => "test";
+ public IDisposable Watch(CameraId cameraId, IObserver observer, CancellationToken ct)
+ {
+ _observers.Add(observer);
+ return new Noop();
+ }
+ public void Emit(MotionTick tick) { foreach (var o in _observers.ToArray()) o.OnNext(tick); }
+ private sealed class Noop : IDisposable { public void Dispose() { } }
+ }
+
+ private sealed class Collector : IObserver
+ {
+ private readonly List _seen;
+ public Collector(List seen) => _seen = seen;
+ public void OnNext(CameraEvent value) { lock (_seen) _seen.Add(value); }
+ public void OnError(Exception error) { }
+ public void OnCompleted() { }
+ }
+
+ private sealed class NullRepo : IEventRepository
+ {
+ public Task> ListAsync(CameraId? cameraId, EventKind? kind, DateTime? since, int limit, CancellationToken ct)
+ => Task.FromResult>(Array.Empty());
+ public Task AddAsync(CameraEvent ev, CancellationToken ct) => Task.CompletedTask;
+ public Task UpdateAsync(CameraEvent ev, CancellationToken ct) => Task.CompletedTask;
+ public Task DeleteOlderThanAsync(DateTime cutoff, CancellationToken ct) => Task.FromResult(0);
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs
index 02ae01d..9cf2ed7 100644
--- a/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs
+++ b/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
using OpenIPC.Viewer.Core.Analytics;
using OpenIPC.Viewer.Core.Entities;
using OpenIPC.Viewer.Core.Events;
@@ -27,50 +29,50 @@ public void QuietHours_WrapsAcrossMidnight(int hour, int start, int end, bool ex
[Fact]
public void Delivers_MotionWhenEnabled()
{
- var (src, sink, coord) = Make(new Settings());
- src.Emit(Event(EventKind.Motion));
- Assert.Single(sink.Shown);
+ var h = Make(new Settings());
+ h.Src.Emit(Event(EventKind.Motion));
+ Assert.Single(h.Sink.Shown);
}
[Fact]
public void Suppresses_WhenMasterOff()
{
- var (src, sink, _) = Make(new Settings { NotificationsEnabled = false });
- src.Emit(Event(EventKind.Motion));
- Assert.Empty(sink.Shown);
+ var h = Make(new Settings { NotificationsEnabled = false });
+ h.Src.Emit(Event(EventKind.Motion));
+ Assert.Empty(h.Sink.Shown);
}
[Fact]
public void Suppresses_DetectionWhenTypeOff()
{
- var (src, sink, _) = Make(new Settings { NotifyOnDetection = false });
- src.Emit(Event(EventKind.Detection));
- Assert.Empty(sink.Shown);
+ var h = Make(new Settings { NotifyOnDetection = false });
+ h.Src.Emit(Event(EventKind.Detection));
+ Assert.Empty(h.Sink.Shown);
}
[Fact]
public void Cooldown_SuppressesSecondWithinWindow()
{
var now = Noon;
- var (src, sink, _) = Make(new Settings { NotificationCooldownSeconds = 30 }, () => now);
+ var h = Make(new Settings { NotificationCooldownSeconds = 30 }, () => now);
var cam = new CameraId(Guid.NewGuid());
- src.Emit(Event(EventKind.Motion, cam));
+ h.Src.Emit(Event(EventKind.Motion, cam));
now = now.AddSeconds(10);
- src.Emit(Event(EventKind.Motion, cam)); // within cooldown → dropped
+ h.Src.Emit(Event(EventKind.Motion, cam)); // within cooldown → dropped
now = now.AddSeconds(40);
- src.Emit(Event(EventKind.Motion, cam)); // cooldown elapsed → shown
+ h.Src.Emit(Event(EventKind.Motion, cam)); // cooldown elapsed → shown
- Assert.Equal(2, sink.Shown.Count);
+ Assert.Equal(2, h.Sink.Shown.Count);
}
[Fact]
public void Cooldown_IsPerCamera()
{
- var (src, sink, _) = Make(new Settings { NotificationCooldownSeconds = 30 }, () => Noon);
- src.Emit(Event(EventKind.Motion, new CameraId(Guid.NewGuid())));
- src.Emit(Event(EventKind.Motion, new CameraId(Guid.NewGuid())));
- Assert.Equal(2, sink.Shown.Count); // different cameras, both fire
+ var h = Make(new Settings { NotificationCooldownSeconds = 30 });
+ h.Src.Emit(Event(EventKind.Motion, new CameraId(Guid.NewGuid())));
+ h.Src.Emit(Event(EventKind.Motion, new CameraId(Guid.NewGuid())));
+ Assert.Equal(2, h.Sink.Shown.Count); // different cameras, both fire
}
[Fact]
@@ -84,18 +86,223 @@ public void UnavailableSink_NeverSubscribes()
Assert.Empty(sink.Shown);
}
- private static (Subject src, FakeSink sink, NotificationCoordinator coord) Make(Settings s, Func? clock = null)
+ // The ingestion stream publishes one episode several times (open, peak
+ // updates, close) — that must stay one notification, even with no cooldown.
+ [Fact]
+ public void SameEpisode_NotifiesOnce()
+ {
+ var h = Make(new Settings { NotificationCooldownSeconds = 0 });
+ var open = Event(EventKind.Detection, confidence: 0.9f, objects: 1);
+ h.Src.Emit(open);
+ h.Src.Emit(open with { PeakObjects = 2 });
+ h.Src.Emit(open with { EndedAt = open.OccurredAt.AddSeconds(40) });
+ Assert.Single(h.Sink.Shown);
+ }
+
+ [Fact]
+ public void ConfidenceThreshold_WaitsForPeakUpdate()
+ {
+ var h = Make(new Settings { NotifyMinConfidencePercent = 70 });
+ var open = Event(EventKind.Detection, confidence: 0.55f, objects: 1);
+
+ h.Src.Emit(open);
+ Assert.Empty(h.Sink.Shown);
+
+ h.Src.Emit(open with { PeakConfidence = 0.82f });
+ Assert.Single(h.Sink.Shown);
+ Assert.Contains("82%", h.Sink.Shown[0].Body);
+ }
+
+ [Fact]
+ public void ConfidenceThreshold_ClosedBelow_NeverNotifies()
+ {
+ var h = Make(new Settings { NotifyMinConfidencePercent = 70 });
+ var open = Event(EventKind.Detection, confidence: 0.6f, objects: 1);
+ h.Src.Emit(open);
+ h.Src.Emit(open with { EndedAt = open.OccurredAt.AddSeconds(3) });
+ Assert.Empty(h.Sink.Shown);
+ }
+
+ [Fact]
+ public void ConfidenceThreshold_DoesNotApplyToMotion()
+ {
+ var h = Make(new Settings { NotifyMinConfidencePercent = 90, NotifyMinObjects = 3 });
+ h.Src.Emit(Event(EventKind.Motion));
+ Assert.Single(h.Sink.Shown);
+ }
+
+ [Fact]
+ public void ObjectThreshold_NeedsEnoughObjects()
+ {
+ var h = Make(new Settings { NotifyMinObjects = 3 });
+ var open = Event(EventKind.Detection, confidence: 0.9f, objects: 2);
+ h.Src.Emit(open);
+ Assert.Empty(h.Sink.Shown);
+ h.Src.Emit(open with { PeakObjects = 3 });
+ Assert.Single(h.Sink.Shown);
+ }
+
+ [Fact]
+ public void DurationThreshold_NotifiesWhenEpisodeIsOldEnough()
+ {
+ var now = Noon;
+ var timers = new FakeScheduler();
+ var h = Make(new Settings { NotifyMinDurationSeconds = 5 }, () => now, scheduler: timers);
+ var open = Event(EventKind.Motion, occurredAt: Noon.ToUniversalTime());
+
+ h.Src.Emit(open);
+ Assert.Empty(h.Sink.Shown);
+ var timer = Assert.Single(timers.Pending);
+ Assert.InRange(timer.Due, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(6));
+
+ now = Noon.AddSeconds(6);
+ timer.Fire();
+ Assert.Single(h.Sink.Shown);
+ }
+
+ [Fact]
+ public void DurationThreshold_ShortEpisode_NeverNotifies()
+ {
+ var timers = new FakeScheduler();
+ var h = Make(new Settings { NotifyMinDurationSeconds = 5 }, scheduler: timers);
+ var open = Event(EventKind.Motion, occurredAt: Noon.ToUniversalTime());
+
+ h.Src.Emit(open);
+ h.Src.Emit(open with { EndedAt = open.OccurredAt.AddSeconds(2) });
+
+ Assert.Empty(h.Sink.Shown);
+ Assert.True(Assert.Single(timers.Pending).Disposed);
+ }
+
+ [Fact]
+ public void DurationThreshold_ClosedLongEnough_NotifiesAtClose()
+ {
+ var h = Make(new Settings { NotifyMinDurationSeconds = 5 }, scheduler: new FakeScheduler());
+ var open = Event(EventKind.Motion, occurredAt: Noon.ToUniversalTime());
+ h.Src.Emit(open);
+ h.Src.Emit(open with { EndedAt = open.OccurredAt.AddSeconds(8) });
+ Assert.Single(h.Sink.Shown);
+ }
+
+ [Fact]
+ public void Actions_RunWhenEnabled_WithCameraName()
+ {
+ var actions = new FakeDispatcher();
+ var h = Make(new Settings { EventActions = ActionsOn() }, actions: actions,
+ cameraName: (_, _) => Task.FromResult("Gate"));
+ h.Src.Emit(Event(EventKind.Detection, confidence: 0.9f, objects: 1));
+
+ var ctx = Assert.Single(actions.Dispatched);
+ Assert.Equal("Gate", ctx.CameraName);
+ Assert.StartsWith("Gate · ", Assert.Single(h.Sink.Shown).Body);
+ }
+
+ [Fact]
+ public void Actions_OffByDefault()
+ {
+ var actions = new FakeDispatcher();
+ var h = Make(new Settings(), actions: actions);
+ h.Src.Emit(Event(EventKind.Motion));
+ Assert.Empty(actions.Dispatched);
+ }
+
+ [Fact]
+ public void Actions_FireDuringQuietHours_ByDefault()
+ {
+ var night = new DateTime(2026, 6, 21, 2, 0, 0, DateTimeKind.Local);
+ var actions = new FakeDispatcher();
+ var h = Make(new Settings { QuietHoursEnabled = true, EventActions = ActionsOn() }, () => night, actions);
+
+ h.Src.Emit(Event(EventKind.Motion));
+
+ Assert.Empty(h.Sink.Shown); // the toast is silenced…
+ Assert.Single(actions.Dispatched); // …the alarm action is not
+ }
+
+ [Fact]
+ public void Actions_HonourQuietHours_WhenAsked()
+ {
+ var night = new DateTime(2026, 6, 21, 2, 0, 0, DateTimeKind.Local);
+ var actions = new FakeDispatcher();
+ var h = Make(new Settings
+ {
+ QuietHoursEnabled = true,
+ EventActions = ActionsOn() with { IgnoreQuietHours = false },
+ }, () => night, actions);
+
+ h.Src.Emit(Event(EventKind.Motion));
+ Assert.Empty(actions.Dispatched);
+ }
+
+ [Fact]
+ public void Actions_RespectThresholds()
+ {
+ var actions = new FakeDispatcher();
+ var h = Make(new Settings { NotifyMinConfidencePercent = 80, EventActions = ActionsOn() }, actions: actions);
+ h.Src.Emit(Event(EventKind.Detection, confidence: 0.5f, objects: 1));
+ Assert.Empty(actions.Dispatched);
+ }
+
+ [Fact]
+ public void Actions_HaveTheirOwnCooldown()
+ {
+ var now = Noon;
+ var actions = new FakeDispatcher();
+ var cam = new CameraId(Guid.NewGuid());
+ var h = Make(new Settings
+ {
+ NotificationCooldownSeconds = 0,
+ EventActions = ActionsOn() with { CooldownSeconds = 60 },
+ }, () => now, actions);
+
+ h.Src.Emit(Event(EventKind.Motion, cam));
+ now = now.AddSeconds(20);
+ h.Src.Emit(Event(EventKind.Motion, cam));
+
+ Assert.Equal(2, h.Sink.Shown.Count);
+ Assert.Single(actions.Dispatched);
+ }
+
+ [Fact]
+ public void Actions_WorkWithoutANativeSink()
+ {
+ var src = new Subject();
+ var sink = new FakeSink { Available = false };
+ var actions = new FakeDispatcher();
+ using var coord = new NotificationCoordinator(src, sink, new Settings { EventActions = ActionsOn() },
+ () => Noon, actions);
+ coord.Start();
+
+ src.Emit(Event(EventKind.Motion));
+
+ Assert.Empty(sink.Shown);
+ Assert.Single(actions.Dispatched);
+ }
+
+ private static EventActionSettings ActionsOn() => new(
+ Enabled: true, HttpEnabled: true, HttpUrl: "http://127.0.0.1/hook");
+
+ private sealed record Harness(Subject Src, FakeSink Sink, NotificationCoordinator Coord);
+
+ private static Harness Make(
+ Settings s,
+ Func? clock = null,
+ IEventActionDispatcher? actions = null,
+ Func>? cameraName = null,
+ FakeScheduler? scheduler = null)
{
var src = new Subject();
var sink = new FakeSink();
- var coord = new NotificationCoordinator(src, sink, s, clock ?? (() => Noon));
+ var coord = new NotificationCoordinator(src, sink, s, clock ?? (() => Noon), actions, cameraName,
+ scheduler is null ? null : scheduler.Schedule);
coord.Start();
- return (src, sink, coord);
+ return new Harness(src, sink, coord);
}
- private static CameraEvent Event(EventKind kind, CameraId? cam = null) => new(
+ private static CameraEvent Event(
+ EventKind kind, CameraId? cam = null, float? confidence = null, int objects = 0, DateTime? occurredAt = null) => new(
new EventId(Guid.NewGuid()), cam ?? new CameraId(Guid.NewGuid()), kind,
- EventSeverity.Info, DateTime.UtcNow, null, "test", "summary");
+ EventSeverity.Info, occurredAt ?? Noon.ToUniversalTime(), null, "test", "summary", confidence, objects);
private sealed class FakeSink : INotificationService
{
@@ -105,6 +312,42 @@ private sealed class FakeSink : INotificationService
public void Show(NotificationRequest request) => Shown.Add(request);
}
+ private sealed class FakeDispatcher : IEventActionDispatcher
+ {
+ public List Dispatched { get; } = new();
+
+ public Task DispatchAsync(EventActionContext context, EventActionSettings settings, CancellationToken ct)
+ {
+ Dispatched.Add(context);
+ return Task.CompletedTask;
+ }
+
+ public Task TestAsync(EventActionChannel channel, EventActionContext context, EventActionSettings settings, CancellationToken ct)
+ => Task.CompletedTask;
+ }
+
+ private sealed class FakeScheduler
+ {
+ public List Pending { get; } = new();
+
+ public IDisposable Schedule(TimeSpan due, Action callback)
+ {
+ var t = new Timer(due, callback);
+ Pending.Add(t);
+ return t;
+ }
+
+ public sealed class Timer : IDisposable
+ {
+ private readonly Action _callback;
+ public Timer(TimeSpan due, Action callback) { Due = due; _callback = callback; }
+ public TimeSpan Due { get; }
+ public bool Disposed { get; private set; }
+ public void Fire() { if (!Disposed) _callback(); }
+ public void Dispose() => Disposed = true;
+ }
+ }
+
private sealed class Subject : IObservable
{
private readonly List> _obs = new();
@@ -139,5 +382,9 @@ private sealed class Settings : IUserSettingsAccessor
public bool QuietHoursEnabled { get; init; }
public int QuietHoursStartHour { get; init; } = 22;
public int QuietHoursEndHour { get; init; } = 7;
+ public int NotifyMinConfidencePercent { get; init; }
+ public int NotifyMinDurationSeconds { get; init; }
+ public int NotifyMinObjects { get; init; } = 1;
+ public EventActionSettings EventActions { get; init; } = EventActionSettings.Default;
}
}
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/EventActionDispatcherTests.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/EventActionDispatcherTests.cs
new file mode 100644
index 0000000..5919c30
--- /dev/null
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/EventActionDispatcherTests.cs
@@ -0,0 +1,201 @@
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using OpenIPC.Viewer.Core.Entities;
+using OpenIPC.Viewer.Core.Events;
+using OpenIPC.Viewer.Core.Notifications;
+using OpenIPC.Viewer.Infrastructure.Notifications;
+
+namespace OpenIPC.Viewer.Infrastructure.Tests;
+
+// Drives each external-action channel against a real loopback listener.
+public sealed class EventActionDispatcherTests : IDisposable
+{
+ private readonly EventActionDispatcher _dispatcher =
+ new(new NoCredentials(), NullLogger.Instance);
+
+ private static EventActionContext Ctx() => new(
+ new CameraEvent(EventId.New(), new CameraId(Guid.NewGuid()), EventKind.Detection, EventSeverity.Info,
+ DateTime.UtcNow.AddSeconds(-2), null, "analytics", "person ×1", 0.91f, 1),
+ "Gate", "Object detected", DateTime.UtcNow);
+
+ [Fact]
+ public async Task Tcp_SendsRenderedPayload()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ var received = ReadAllAsync(listener);
+
+ var s = new EventActionSettings(SignalEnabled: true, SignalHost: "127.0.0.1", SignalPort: port,
+ SignalPayload: "ALARM {camera} {confidence}\\r\\n");
+ await _dispatcher.TestAsync(EventActionChannel.Signal, Ctx(), s, CancellationToken.None);
+
+ Assert.Equal("ALARM Gate 91\r\n", Encoding.UTF8.GetString(await received.WaitAsync(TimeSpan.FromSeconds(5))));
+ }
+
+ [Fact]
+ public async Task Udp_SendsHexDatagram()
+ {
+ using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
+ var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
+ var receive = udp.ReceiveAsync();
+
+ var s = new EventActionSettings(SignalEnabled: true, SignalProtocol: EventActionSettings.ProtocolUdp,
+ SignalHost: "127.0.0.1", SignalPort: port, SignalPayload: "hex: A0 01 01 A2");
+ await _dispatcher.TestAsync(EventActionChannel.Signal, Ctx(), s, CancellationToken.None);
+
+ var result = await receive.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal(new byte[] { 0xA0, 0x01, 0x01, 0xA2 }, result.Buffer);
+ }
+
+ [Fact]
+ public async Task Tcp_Refused_Throws()
+ {
+ // Grab a free port, then close it so nothing listens there.
+ var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+
+ var s = new EventActionSettings(SignalEnabled: true, SignalHost: "127.0.0.1", SignalPort: port);
+ await Assert.ThrowsAnyAsync(() =>
+ _dispatcher.TestAsync(EventActionChannel.Signal, Ctx(), s, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Http_PostsJsonBody_AndEncodesUrl()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ var request = ServeOneHttpAsync(listener, 200);
+
+ var s = new EventActionSettings(HttpEnabled: true,
+ HttpUrl: $"http://127.0.0.1:{port}/hook?cam={{camera}}&what={{summary}}");
+ await _dispatcher.TestAsync(EventActionChannel.Http, Ctx(), s, CancellationToken.None);
+
+ var raw = await request.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.StartsWith("POST /hook?cam=Gate&what=person%20%C3%971 HTTP/1.1", raw);
+ var body = raw[(raw.IndexOf("\r\n\r\n", StringComparison.Ordinal) + 4)..];
+ using var json = System.Text.Json.JsonDocument.Parse(body);
+ Assert.Equal("detection", json.RootElement.GetProperty("event").GetString());
+ Assert.Equal("Gate", json.RootElement.GetProperty("camera").GetString());
+ }
+
+ [Fact]
+ public async Task Http_ErrorStatus_Throws()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ _ = ServeOneHttpAsync(listener, 500);
+
+ var s = new EventActionSettings(HttpEnabled: true, HttpMethod: "GET", HttpUrl: $"http://127.0.0.1:{port}/");
+ var ex = await Assert.ThrowsAsync(() =>
+ _dispatcher.TestAsync(EventActionChannel.Http, Ctx(), s, CancellationToken.None));
+ Assert.Contains("500", ex.Message);
+ }
+
+ [Fact]
+ public async Task Dispatch_OneChannelFailing_DoesNotStopTheOthers()
+ {
+ using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
+ var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
+ var receive = udp.ReceiveAsync();
+
+ var s = new EventActionSettings(
+ Enabled: true,
+ SignalEnabled: true, SignalProtocol: EventActionSettings.ProtocolUdp,
+ SignalHost: "127.0.0.1", SignalPort: port, SignalPayload: "ok",
+ HttpEnabled: true, HttpUrl: "not a url");
+ await _dispatcher.DispatchAsync(Ctx(), s, CancellationToken.None); // must not throw
+
+ Assert.Equal("ok", Encoding.UTF8.GetString((await receive.WaitAsync(TimeSpan.FromSeconds(5))).Buffer));
+ }
+
+ [Fact]
+ public async Task Smtp_WithoutServer_Throws()
+ {
+ var s = new EventActionSettings(EmailEnabled: true, EmailTo: "a@example.com");
+ var ex = await Assert.ThrowsAsync(() =>
+ _dispatcher.TestAsync(EventActionChannel.Email, Ctx(), s, CancellationToken.None));
+ Assert.Contains("SMTP", ex.Message);
+ }
+
+ [SkippableFact]
+ public async Task Program_RunsBatchWithArgumentsAndEnvironment()
+ {
+ Skip.IfNot(OperatingSystem.IsWindows(), "batch files are Windows-only");
+ var dir = Directory.CreateTempSubdirectory("openipc-action-");
+ try
+ {
+ var output = Path.Combine(dir.FullName, "out.txt");
+ var script = Path.Combine(dir.FullName, "hook.bat");
+ await File.WriteAllTextAsync(script, $"@echo %1 %OPENIPC_EVENT_KIND% %OPENIPC_CONFIDENCE%> \"{output}\"\r\n");
+
+ var s = new EventActionSettings(ProgramEnabled: true, ProgramPath: script, ProgramArguments: "{camera}");
+ await _dispatcher.TestAsync(EventActionChannel.Program, Ctx(), s, CancellationToken.None);
+
+ for (var i = 0; i < 50 && !File.Exists(output); i++) await Task.Delay(100);
+ await Task.Delay(200);
+ Assert.Equal("Gate detection 91", (await File.ReadAllTextAsync(output)).Trim());
+ }
+ finally
+ {
+ try { dir.Delete(recursive: true); } catch (IOException) { }
+ }
+ }
+
+ public void Dispose() => _dispatcher.Dispose();
+
+ private static async Task ReadAllAsync(TcpListener listener)
+ {
+ using var client = await listener.AcceptTcpClientAsync();
+ using var ms = new MemoryStream();
+ await client.GetStream().CopyToAsync(ms);
+ return ms.ToArray();
+ }
+
+ // Minimal HTTP/1.1 server: reads one request (headers + Content-Length body),
+ // answers with the given status, returns the raw request text.
+ private static async Task ServeOneHttpAsync(TcpListener listener, int status)
+ {
+ using var client = await listener.AcceptTcpClientAsync();
+ var stream = client.GetStream();
+ var buffer = new List();
+ var chunk = new byte[4096];
+ int headerEnd = -1, contentLength = 0;
+ while (true)
+ {
+ var n = await stream.ReadAsync(chunk);
+ if (n == 0) break;
+ buffer.AddRange(chunk.AsSpan(0, n).ToArray());
+ var text = Encoding.UTF8.GetString(buffer.ToArray());
+ if (headerEnd < 0)
+ {
+ headerEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
+ if (headerEnd >= 0)
+ {
+ foreach (var line in text[..headerEnd].Split("\r\n"))
+ if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
+ contentLength = int.Parse(line["Content-Length:".Length..].Trim(), System.Globalization.CultureInfo.InvariantCulture);
+ }
+ }
+ if (headerEnd >= 0 && Encoding.UTF8.GetByteCount(text) - (headerEnd + 4) >= contentLength) break;
+ }
+ var response = Encoding.ASCII.GetBytes($"HTTP/1.1 {status} X\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+ await stream.WriteAsync(response);
+ return Encoding.UTF8.GetString(buffer.ToArray());
+ }
+
+ private sealed class NoCredentials : IMailCredentialStore
+ {
+ public string StorageName => "none";
+ public Task GetAsync(CancellationToken ct) => Task.FromResult(null);
+ public Task SetAsync(MailCredential credential, CancellationToken ct) => Task.CompletedTask;
+ public Task ClearAsync(CancellationToken ct) => Task.CompletedTask;
+ }
+}
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/SshSessionIntegrationTests.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/SshSessionIntegrationTests.cs
index debbb0c..b6f485c 100644
--- a/tests/OpenIPC.Viewer.Infrastructure.Tests/SshSessionIntegrationTests.cs
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/SshSessionIntegrationTests.cs
@@ -12,7 +12,15 @@ namespace OpenIPC.Viewer.Infrastructure.Tests;
public sealed class SshSessionIntegrationTests
{
private static readonly ISshSessionFactory Factory =
- new SshNetSessionFactory(new InMemoryHostKeyStore(), new FakeUserSettings(), NullLoggerFactory.Instance);
+ new SshNetSessionFactory(new InMemoryHostKeyStore(), new FakeUserSettings(), new RefuseChangedKey(), NullLoggerFactory.Instance);
+
+ // A changed host key is never expected against the test container.
+ private sealed class RefuseChangedKey : ISshHostKeyPrompt
+ {
+ public Task ConfirmChangedKeyAsync(
+ string host, int port, string? knownFingerprint, string presentedFingerprint, CancellationToken ct)
+ => Task.FromResult(false);
+ }
private static async Task ConnectAsync()
{
diff --git a/tests/OpenIPC.Viewer.Infrastructure.Tests/TestDoubles.cs b/tests/OpenIPC.Viewer.Infrastructure.Tests/TestDoubles.cs
index 56a37b6..36c4ca7 100644
--- a/tests/OpenIPC.Viewer.Infrastructure.Tests/TestDoubles.cs
+++ b/tests/OpenIPC.Viewer.Infrastructure.Tests/TestDoubles.cs
@@ -42,4 +42,9 @@ internal sealed class FakeUserSettings : IUserSettingsAccessor
public bool QuietHoursEnabled => false;
public int QuietHoursStartHour => 22;
public int QuietHoursEndHour => 7;
+ public int NotifyMinConfidencePercent => 0;
+ public int NotifyMinDurationSeconds => 0;
+ public int NotifyMinObjects => 1;
+ public OpenIPC.Viewer.Core.Notifications.EventActionSettings EventActions =>
+ OpenIPC.Viewer.Core.Notifications.EventActionSettings.Default;
}
From 07c4897f81893b05e4cd7c2ad2c17edf1b46186e Mon Sep 17 00:00:00 2001
From: NoName <306628470+trueroguan@users.noreply.github.com>
Date: Sat, 17 Oct 2026 19:52:14 +0300
Subject: [PATCH 2/2] Update
---
src/OpenIPC.Viewer.Core/Events/CameraEvent.cs | 8 +-
.../Events/EventIngestionService.cs | 23 +++--
.../Notifications/EventActionContext.cs | 5 +-
.../Notifications/NotificationCoordinator.cs | 97 ++++---------------
.../Notifications/MapiMailSender.cs | 24 +++++
.../Notifications/EventIngestionPeakTests.cs | 26 +++++
.../NotificationCoordinatorTests.cs | 64 +++++-------
7 files changed, 119 insertions(+), 128 deletions(-)
diff --git a/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs b/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs
index ddeb6dd..fc3130e 100644
--- a/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs
+++ b/src/OpenIPC.Viewer.Core/Events/CameraEvent.cs
@@ -21,8 +21,9 @@ public enum EventSeverity
// explicit about scope.
//
// PeakConfidence / PeakObjects are the best values seen so far in the episode
-// (detection events only). They live on the in-memory stream for the
-// notification thresholds and are not persisted.
+// (detection events only); LastSeenAt is the latest tick of a still-open
+// episode, i.e. how long the activity has actually lasted. They live on the
+// in-memory stream for the notification thresholds and are not persisted.
public sealed record CameraEvent(
EventId Id,
CameraId CameraId,
@@ -33,4 +34,5 @@ public sealed record CameraEvent(
string? Source,
string? Summary,
float? PeakConfidence = null,
- int PeakObjects = 0);
+ int PeakObjects = 0,
+ DateTime? LastSeenAt = null);
diff --git a/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs b/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs
index 49f2dc9..1c564e5 100644
--- a/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs
+++ b/src/OpenIPC.Viewer.Core/Events/EventIngestionService.cs
@@ -13,9 +13,11 @@ namespace OpenIPC.Viewer.Core.Events;
// -> 5s of quiet -> close event (set EndedAt, persist)
// Plays back to a single Observed event channel that UI subscribes to once.
// Besides open and close, an open event is re-published (same Id, EndedAt
-// still null) whenever its peak confidence or object count grows, so the
-// notification thresholds see a detection that firms up mid-episode.
-// Subscribers de-dup by Id.
+// still null) whenever its peak confidence or object count grows, and at most
+// once per second while ticks keep coming (LastSeenAt = latest tick). That lets
+// the notification thresholds see a detection that firms up mid-episode and
+// an activity that has really lasted N seconds — not just an episode that is
+// still waiting out its quiet window. Subscribers de-dup by Id.
public sealed class EventIngestionService : IAsyncDisposable
{
private readonly IEnumerable _sources;
@@ -24,6 +26,7 @@ public sealed class EventIngestionService : IAsyncDisposable
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(1);
private static readonly TimeSpan CloseAfterQuiet = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan ProgressInterval = TimeSpan.FromSeconds(1);
private readonly object _gate = new();
private readonly Dictionary _open = new();
@@ -93,9 +96,13 @@ private void OnTick(MotionTick tick)
grew = true;
}
// Until OpenAsync has published, the open event itself carries the
- // latest peaks — no separate update needed.
- if (grew && !isNew && state.Published)
+ // latest peaks — no separate update needed. After that: on a peak
+ // rise, or as throttled progress so LastSeenAt keeps moving.
+ if (!isNew && state.Published && (grew || tick.At - state.LastPublishedTickAt >= ProgressInterval))
+ {
update = Snapshot(state, endedAt: null);
+ state.LastPublishedTickAt = tick.At;
+ }
state.QuietTimer?.Change(CloseAfterQuiet, Timeout.InfiniteTimeSpan);
state.QuietTimer ??= new Timer(CloseQuietState, state, CloseAfterQuiet, Timeout.InfiniteTimeSpan);
@@ -120,6 +127,7 @@ private async Task OpenAsync(OpenEventState state)
{
ev = Snapshot(state, endedAt: null);
state.Published = true;
+ state.LastPublishedTickAt = state.LastTickAt;
}
NotifyObservers(ev);
}
@@ -134,7 +142,8 @@ private async Task OpenAsync(OpenEventState state)
Source: state.Source,
Summary: state.Summary,
PeakConfidence: state.PeakConfidence,
- PeakObjects: state.PeakObjects);
+ PeakObjects: state.PeakObjects,
+ LastSeenAt: state.LastTickAt);
private void CloseQuietState(object? boxed)
{
@@ -206,6 +215,8 @@ private sealed class OpenEventState
public int PeakObjects { get; set; }
// Set once the open event has gone out; later peak rises publish updates.
public bool Published { get; set; }
+ // Tick time of the last publish, for the throttled progress updates.
+ public DateTime LastPublishedTickAt { get; set; }
public Timer? QuietTimer { get; set; }
public OpenEventState(EventId id, CameraId cam, DateTime started, string? source, EventKind kind)
diff --git a/src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs b/src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs
index d0326dc..225fdd0 100644
--- a/src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs
+++ b/src/OpenIPC.Viewer.Core/Notifications/EventActionContext.cs
@@ -22,12 +22,13 @@ public sealed record EventActionContext(
_ => "motion",
};
- // Whole seconds the episode has lasted (to its end, or to now while open).
+ // Whole seconds the activity has lasted (to the episode's end, or to the
+ // latest observed tick while it is still open).
public int DurationSeconds
{
get
{
- var end = Event.EndedAt ?? NowUtc;
+ var end = Event.EndedAt ?? Event.LastSeenAt ?? NowUtc;
var seconds = (end.ToUniversalTime() - Event.OccurredAt.ToUniversalTime()).TotalSeconds;
return seconds > 0 ? (int)seconds : 0;
}
diff --git a/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs b/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs
index 5f47b41..1954eb4 100644
--- a/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs
+++ b/src/OpenIPC.Viewer.Core/Notifications/NotificationCoordinator.cs
@@ -18,13 +18,11 @@ namespace OpenIPC.Viewer.Core.Notifications;
// ingestion service.
//
// Each event episode is delivered at most once per channel. The ingestion
-// stream publishes an episode several times (open, peak updates, close), so the
-// coordinator tracks open episodes by EventId and fires the first time one
-// clears every threshold:
-// - confidence / object count (detections): an update that raises the peak
-// re-evaluates the episode;
-// - duration: a timer re-checks the episode once it is old enough, so a long
-// event notifies while it is still going on, not when it ends.
+// stream publishes an episode several times (open, peak rises, once-a-second
+// progress, close), so the coordinator tracks open episodes by EventId and
+// fires the first time one clears every threshold. Duration is measured to the
+// latest observed activity (LastSeenAt / EndedAt), never to "now": an episode
+// waiting out its quiet window after a single blip has not lasted N seconds.
public sealed class NotificationCoordinator : IDisposable
{
private readonly IObservable _events;
@@ -33,7 +31,6 @@ public sealed class NotificationCoordinator : IDisposable
private readonly IEventActionDispatcher? _actions;
private readonly Func>? _cameraName;
private readonly Func _now;
- private readonly Func _schedule;
private readonly object _gate = new();
private readonly Dictionary _lastShown = new();
private readonly Dictionary _lastAction = new();
@@ -48,8 +45,7 @@ public NotificationCoordinator(
IUserSettingsAccessor settings,
Func? clock = null,
IEventActionDispatcher? actions = null,
- Func>? cameraName = null,
- Func? scheduler = null)
+ Func>? cameraName = null)
{
_events = events;
_sink = sink;
@@ -57,7 +53,6 @@ public NotificationCoordinator(
_actions = actions;
_cameraName = cameraName;
_now = clock ?? (() => DateTime.Now); // quiet hours are local-time
- _schedule = scheduler ?? DefaultSchedule;
}
public void Start()
@@ -85,38 +80,36 @@ public bool ShouldRunActions(CameraEvent e, DateTime nowLocal)
public enum ThresholdResult
{
Pass,
- // Not yet — a later update (higher peak) may still pass.
- WaitForPeak,
- // Not yet — the episode is still too short.
- WaitForDuration,
+ // Not yet — a later update of the open episode may still pass.
+ Wait,
// Can no longer pass (the episode ended below a threshold).
Fail,
}
// Confidence and object count apply to detections only (a plain motion tick
// carries neither); duration applies to every event.
- public ThresholdResult PassesThresholds(CameraEvent e, DateTime nowUtc)
+ public ThresholdResult PassesThresholds(CameraEvent e)
{
- var closed = e.EndedAt is not null;
+ var miss = e.EndedAt is not null ? ThresholdResult.Fail : ThresholdResult.Wait;
if (e.Kind == EventKind.Detection)
{
var minConfidence = Math.Max(0, Math.Min(100, _settings.NotifyMinConfidencePercent));
if (minConfidence > 0 && (e.PeakConfidence ?? 0f) * 100f + 0.0001f < minConfidence)
- return closed ? ThresholdResult.Fail : ThresholdResult.WaitForPeak;
+ return miss;
var minObjects = Math.Max(1, _settings.NotifyMinObjects);
if (minObjects > 1 && e.PeakObjects < minObjects)
- return closed ? ThresholdResult.Fail : ThresholdResult.WaitForPeak;
+ return miss;
}
var minDuration = Math.Max(0, _settings.NotifyMinDurationSeconds);
if (minDuration > 0)
{
- var end = e.EndedAt ?? nowUtc;
+ var end = e.EndedAt ?? e.LastSeenAt ?? e.OccurredAt;
var lasted = end.ToUniversalTime() - e.OccurredAt.ToUniversalTime();
if (lasted < TimeSpan.FromSeconds(minDuration))
- return closed ? ThresholdResult.Fail : ThresholdResult.WaitForDuration;
+ return miss;
}
return ThresholdResult.Pass;
@@ -154,39 +147,23 @@ private void OnEvent(CameraEvent e)
ep = new Episode();
_episodes[e.Id] = ep;
}
- ep.Latest = e;
- Evaluate(ep, out notify, out act);
+ Evaluate(ep, e, out notify, out act);
if (e.EndedAt is not null)
- {
- ep.Recheck?.Dispose();
_episodes.Remove(e.Id);
- }
}
if (notify || act)
_ = DeliverAsync(e, notify, act);
}
- // Caller holds _gate. Decides which channels fire now and arms the duration
- // re-check when that is the only thing still missing.
- private void Evaluate(Episode ep, out bool notify, out bool act)
+ // Caller holds _gate. Decides which channels fire for this publication.
+ private void Evaluate(Episode ep, CameraEvent e, out bool notify, out bool act)
{
notify = act = false;
if (ep.NotifyDone && ep.ActionsDone) return;
+ if (PassesThresholds(e) != ThresholdResult.Pass) return;
- var e = ep.Latest!;
var nowLocal = _now();
- switch (PassesThresholds(e, nowLocal.ToUniversalTime()))
- {
- case ThresholdResult.Pass:
- break;
- case ThresholdResult.WaitForDuration:
- ArmRecheck(ep, e);
- return;
- default:
- return;
- }
-
if (!ep.NotifyDone)
{
ep.NotifyDone = true;
@@ -228,33 +205,6 @@ private bool ShouldRunActionsLocked(CameraEvent e, DateTime nowLocal)
return CooldownElapsed(_lastAction, e.CameraId, nowLocal, a.CooldownSeconds);
}
- private void ArmRecheck(Episode ep, CameraEvent e)
- {
- if (ep.Recheck is not null) return;
- var due = e.OccurredAt.ToUniversalTime()
- + TimeSpan.FromSeconds(Math.Max(0, _settings.NotifyMinDurationSeconds))
- - _now().ToUniversalTime();
- if (due < TimeSpan.Zero) due = TimeSpan.Zero;
- var id = e.Id;
- ep.Recheck = _schedule(due + TimeSpan.FromMilliseconds(50), () => OnRecheck(id));
- }
-
- private void OnRecheck(EventId id)
- {
- bool notify = false, act = false;
- CameraEvent? e;
- lock (_gate)
- {
- if (!_episodes.TryGetValue(id, out var ep)) return;
- ep.Recheck?.Dispose();
- ep.Recheck = null;
- e = ep.Latest;
- Evaluate(ep, out notify, out act);
- }
- if (e is not null && (notify || act))
- _ = DeliverAsync(e, notify, act);
- }
-
private async Task DeliverAsync(CameraEvent e, bool notify, bool act)
{
try
@@ -299,28 +249,19 @@ public static string Describe(CameraEvent e, string? cameraName)
return string.IsNullOrWhiteSpace(cameraName) ? body : cameraName + " · " + body;
}
- private static IDisposable DefaultSchedule(TimeSpan due, Action callback) =>
- new Timer(_ => callback(), null, due, Timeout.InfiniteTimeSpan);
-
public void Dispose()
{
_sub?.Dispose();
_sub = null;
_cts.Cancel();
- lock (_gate)
- {
- foreach (var ep in _episodes.Values) ep.Recheck?.Dispose();
- _episodes.Clear();
- }
+ lock (_gate) _episodes.Clear();
_cts.Dispose();
}
private sealed class Episode
{
- public CameraEvent? Latest { get; set; }
public bool NotifyDone { get; set; }
public bool ActionsDone { get; set; }
- public IDisposable? Recheck { get; set; }
}
private sealed class Observer : IObserver
diff --git a/src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs b/src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs
index 9db591d..1e65b4d 100644
--- a/src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs
+++ b/src/OpenIPC.Viewer.Infrastructure/Notifications/MapiMailSender.cs
@@ -47,8 +47,32 @@ public static Task SendAsync(OutgoingMail mail, CancellationToken ct)
return tcs.Task.WaitAsync(ct);
}
+ // Without a registered MAPI client the system stub (mapi32.dll) pops a modal
+ // "no default mail client" box — on every event. Check the registry first
+ // and fail with a readable error instead.
+ private static void EnsureClientRegistered()
+ {
+ string? name = null;
+ using (var user = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(@"Software\Clients\Mail"))
+ name = user?.GetValue(null) as string;
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ using var machine = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Clients\Mail");
+ name = machine?.GetValue(null) as string;
+ }
+ if (string.IsNullOrWhiteSpace(name))
+ throw new InvalidOperationException(
+ "No default MAPI mail client is set in Windows. Install a MAPI client (classic Outlook, Thunderbird, The Bat!…) and make it the default, or use SMTP.");
+
+ using var client = Microsoft.Win32.Registry.LocalMachine.OpenSubKey($@"SOFTWARE\Clients\Mail\{name}")
+ ?? Microsoft.Win32.Registry.CurrentUser.OpenSubKey($@"Software\Clients\Mail\{name}");
+ if (client?.GetValue("DLLPath") is null && client?.GetValue("DLLPathEx") is null)
+ throw new InvalidOperationException($"The default mail client \"{name}\" does not provide Simple MAPI. Use SMTP or another mail client.");
+ }
+
private static void Send(OutgoingMail mail)
{
+ EnsureClientRegistered();
var recipSize = Marshal.SizeOf();
var allocated = new System.Collections.Generic.List();
IntPtr Str(string s)
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs
index 4404127..34cc72c 100644
--- a/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs
+++ b/tests/OpenIPC.Viewer.Core.Tests/Notifications/EventIngestionPeakTests.cs
@@ -39,6 +39,32 @@ public async Task PeakRise_RepublishesOpenEpisode()
await ingestion.DisposeAsync();
}
+ // Ongoing activity is re-published about once a second with LastSeenAt, so a
+ // duration threshold can fire while the motion is still going on.
+ [Fact]
+ public async Task OngoingActivity_PublishesThrottledProgress()
+ {
+ var (ingestion, source, camera, seen) = await StartAsync();
+ var t0 = DateTime.UtcNow;
+
+ source.Emit(new MotionTick(camera, t0, "test"));
+ await WaitForAsync(() => seen.Count >= 1);
+ source.Emit(new MotionTick(camera, t0.AddSeconds(0.5), "test")); // < 1 s since publish → quiet
+ source.Emit(new MotionTick(camera, t0.AddSeconds(1.2), "test")); // ≥ 1 s → progress
+ source.Emit(new MotionTick(camera, t0.AddSeconds(1.6), "test")); // < 1 s since that → quiet
+ await WaitForAsync(() => seen.Count >= 2);
+ await Task.Delay(100);
+
+ lock (seen)
+ {
+ Assert.Equal(2, seen.Count);
+ Assert.Equal(t0, seen[0].LastSeenAt);
+ Assert.Equal(t0.AddSeconds(1.2), seen[1].LastSeenAt);
+ Assert.Null(seen[1].EndedAt);
+ }
+ await ingestion.DisposeAsync();
+ }
+
[Fact]
public async Task PlainMotion_HasNoPeaks()
{
diff --git a/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs
index 9cf2ed7..eabe34d 100644
--- a/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs
+++ b/tests/OpenIPC.Viewer.Core.Tests/Notifications/NotificationCoordinatorTests.cs
@@ -143,41 +143,51 @@ public void ObjectThreshold_NeedsEnoughObjects()
}
[Fact]
- public void DurationThreshold_NotifiesWhenEpisodeIsOldEnough()
+ public void DurationThreshold_NotifiesOnceActivityHasLastedLongEnough()
{
- var now = Noon;
- var timers = new FakeScheduler();
- var h = Make(new Settings { NotifyMinDurationSeconds = 5 }, () => now, scheduler: timers);
+ var h = Make(new Settings { NotifyMinDurationSeconds = 3 });
var open = Event(EventKind.Motion, occurredAt: Noon.ToUniversalTime());
h.Src.Emit(open);
+ h.Src.Emit(open with { LastSeenAt = open.OccurredAt.AddSeconds(2) });
Assert.Empty(h.Sink.Shown);
- var timer = Assert.Single(timers.Pending);
- Assert.InRange(timer.Due, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(6));
- now = Noon.AddSeconds(6);
- timer.Fire();
- Assert.Single(h.Sink.Shown);
+ h.Src.Emit(open with { LastSeenAt = open.OccurredAt.AddSeconds(3.1) });
+ Assert.Single(h.Sink.Shown); // while the episode is still open
+ }
+
+ // Regression: the episode stays open for its 5 s quiet window after a single
+ // blip; elapsed wall-clock time must not count as "lasted N seconds".
+ [Fact]
+ public void DurationThreshold_TimePassingAlone_DoesNotCount()
+ {
+ var now = Noon;
+ var h = Make(new Settings { NotifyMinDurationSeconds = 3 }, () => now);
+ var open = Event(EventKind.Motion, occurredAt: Noon.ToUniversalTime()) with { LastSeenAt = Noon.ToUniversalTime() };
+
+ h.Src.Emit(open);
+ now = Noon.AddSeconds(4);
+ h.Src.Emit(open with { EndedAt = open.OccurredAt });
+
+ Assert.Empty(h.Sink.Shown);
}
[Fact]
public void DurationThreshold_ShortEpisode_NeverNotifies()
{
- var timers = new FakeScheduler();
- var h = Make(new Settings { NotifyMinDurationSeconds = 5 }, scheduler: timers);
+ var h = Make(new Settings { NotifyMinDurationSeconds = 5 });
var open = Event(EventKind.Motion, occurredAt: Noon.ToUniversalTime());
h.Src.Emit(open);
h.Src.Emit(open with { EndedAt = open.OccurredAt.AddSeconds(2) });
Assert.Empty(h.Sink.Shown);
- Assert.True(Assert.Single(timers.Pending).Disposed);
}
[Fact]
public void DurationThreshold_ClosedLongEnough_NotifiesAtClose()
{
- var h = Make(new Settings { NotifyMinDurationSeconds = 5 }, scheduler: new FakeScheduler());
+ var h = Make(new Settings { NotifyMinDurationSeconds = 5 });
var open = Event(EventKind.Motion, occurredAt: Noon.ToUniversalTime());
h.Src.Emit(open);
h.Src.Emit(open with { EndedAt = open.OccurredAt.AddSeconds(8) });
@@ -288,13 +298,11 @@ private static Harness Make(
Settings s,
Func? clock = null,
IEventActionDispatcher? actions = null,
- Func>? cameraName = null,
- FakeScheduler? scheduler = null)
+ Func>? cameraName = null)
{
var src = new Subject();
var sink = new FakeSink();
- var coord = new NotificationCoordinator(src, sink, s, clock ?? (() => Noon), actions, cameraName,
- scheduler is null ? null : scheduler.Schedule);
+ var coord = new NotificationCoordinator(src, sink, s, clock ?? (() => Noon), actions, cameraName);
coord.Start();
return new Harness(src, sink, coord);
}
@@ -326,28 +334,6 @@ public Task TestAsync(EventActionChannel channel, EventActionContext context, Ev
=> Task.CompletedTask;
}
- private sealed class FakeScheduler
- {
- public List Pending { get; } = new();
-
- public IDisposable Schedule(TimeSpan due, Action callback)
- {
- var t = new Timer(due, callback);
- Pending.Add(t);
- return t;
- }
-
- public sealed class Timer : IDisposable
- {
- private readonly Action _callback;
- public Timer(TimeSpan due, Action callback) { Due = due; _callback = callback; }
- public TimeSpan Due { get; }
- public bool Disposed { get; private set; }
- public void Fire() { if (!Disposed) _callback(); }
- public void Dispose() => Disposed = true;
- }
- }
-
private sealed class Subject : IObservable
{
private readonly List> _obs = new();