From e475386d7d4bd6032d06e9d21b3845562d143be6 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 25 Jun 2026 12:11:37 -0500 Subject: [PATCH] RR1-T102 Windows app base --- Directory.Build.props | 14 + Resgrid.Audio.Core/AudioEvaluator.cs | 6 +- Resgrid.Audio.Core/Radio/RadioBridge.cs | 10 +- Resgrid.Audio.Core/WatcherAudioStorage.cs | 6 +- Resgrid.Audio.Relay/App.xaml | 11 +- Resgrid.Audio.Relay/App.xaml.cs | 140 ++++- Resgrid.Audio.Relay/Controls/LevelMeter.xaml | 21 + .../Controls/LevelMeter.xaml.cs | 45 ++ Resgrid.Audio.Relay/Controls/Sparkline.cs | 124 ++++ Resgrid.Audio.Relay/Controls/StatusPill.xaml | 24 + .../Controls/StatusPill.xaml.cs | 42 ++ .../Converters/BoolToBrushConverter.cs | 27 + .../Converters/BoolToVisibilityConverter.cs | 32 + .../BoolToVisibilityInvertedConverter.cs | 28 + .../ConnectionStateToBrushConverter.cs | 49 ++ .../Converters/CountToVisibilityConverter.cs | 39 ++ .../Converters/LogLevelToBrushConverter.cs | 56 ++ .../RelayServiceStateToBrushConverter.cs | 41 ++ .../Converters/SecretMaskConverter.cs | 29 + .../Converters/SquelchTextConverter.cs | 25 + .../Converters/StatusBrushes.cs | 27 + .../Converters/TuneButtonTextConverter.cs | 25 + Resgrid.Audio.Relay/MainWindow.xaml | 52 -- Resgrid.Audio.Relay/MainWindow.xaml.cs | 116 ---- .../Properties/Resources.Designer.cs | 63 -- Resgrid.Audio.Relay/Properties/Resources.resx | 117 ---- .../Properties/Settings.Designer.cs | 26 - .../Properties/Settings.settings | 7 - .../Resgrid.Audio.Relay.csproj | 25 +- .../Resources/AppDictionary.xaml | 99 --- .../Services/ConfigurationService.cs | 46 ++ .../Services/DeviceEnumerationService.cs | 141 +++++ .../Services/RelayController.cs | 207 ++++++ Resgrid.Audio.Relay/ShellWindow.xaml | 124 ++++ Resgrid.Audio.Relay/ShellWindow.xaml.cs | 135 ++++ Resgrid.Audio.Relay/Skins/MainSkin.xaml | 4 - .../ViewModel/MainWindowViewModel.cs | 190 ------ .../ViewModel/ShuttingDownMessage.cs | 17 - .../ViewModel/ViewModelLocator.cs | 65 -- .../ViewModels/AboutViewModel.cs | 132 ++++ .../ViewModels/ConfigurationViewModel.cs | 587 ++++++++++++++++++ .../ViewModels/DashboardViewModel.cs | 109 ++++ .../ViewModels/DevicesViewModel.cs | 54 ++ .../ViewModels/LogsViewModel.cs | 172 +++++ .../ViewModels/ModeCardViewModel.cs | 128 ++++ .../ViewModels/OperationsViewModel.cs | 181 ++++++ .../ViewModels/ServiceTileViewModel.cs | 102 +++ .../ViewModels/ShellViewModel.cs | 71 +++ Resgrid.Audio.Relay/Views/AboutView.xaml | 49 ++ Resgrid.Audio.Relay/Views/AboutView.xaml.cs | 19 + .../Views/ConfigurationView.xaml | 291 +++++++++ .../Views/ConfigurationView.xaml.cs | 36 ++ Resgrid.Audio.Relay/Views/DashboardView.xaml | 129 ++++ .../Views/DashboardView.xaml.cs | 37 ++ Resgrid.Audio.Relay/Views/DevicesView.xaml | 96 +++ Resgrid.Audio.Relay/Views/DevicesView.xaml.cs | 19 + Resgrid.Audio.Relay/Views/LogsView.xaml | 65 ++ Resgrid.Audio.Relay/Views/LogsView.xaml.cs | 51 ++ Resgrid.Audio.Relay/Views/OperationsView.xaml | 83 +++ .../Views/OperationsView.xaml.cs | 36 ++ .../Configuration/RelayConfiguration.cs | 109 ++++ Resgrid.Relay.Engine/Logging/LogRecord.cs | 10 + .../Logging/UiBusSinkExtensions.cs | 28 + Resgrid.Relay.Engine/Logging/UiLogBus.cs | 33 + Resgrid.Relay.Engine/Logging/UiLogSink.cs | 34 + .../Services/AudioImportService.cs | 2 +- .../Services/DispatchRelayService.cs | 2 +- .../Services/RadioRelayService.cs | 2 +- .../Services/RecordRelayService.cs | 2 +- .../Services/RelayServiceBase.cs | 30 +- .../Services/RelayServiceFactory.cs | 2 +- .../Services/SmtpRelayService.cs | 5 +- Resgrid.Relay.Engine/Voice/AudioImportMode.cs | 10 +- .../Voice/DispatchVoiceMode.cs | 9 +- Resgrid.Relay.Engine/Voice/RadioMode.cs | 6 +- 75 files changed, 4196 insertions(+), 790 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Resgrid.Audio.Relay/Controls/LevelMeter.xaml create mode 100644 Resgrid.Audio.Relay/Controls/LevelMeter.xaml.cs create mode 100644 Resgrid.Audio.Relay/Controls/Sparkline.cs create mode 100644 Resgrid.Audio.Relay/Controls/StatusPill.xaml create mode 100644 Resgrid.Audio.Relay/Controls/StatusPill.xaml.cs create mode 100644 Resgrid.Audio.Relay/Converters/BoolToBrushConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/BoolToVisibilityConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/BoolToVisibilityInvertedConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/ConnectionStateToBrushConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/CountToVisibilityConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/LogLevelToBrushConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/RelayServiceStateToBrushConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/SecretMaskConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/SquelchTextConverter.cs create mode 100644 Resgrid.Audio.Relay/Converters/StatusBrushes.cs create mode 100644 Resgrid.Audio.Relay/Converters/TuneButtonTextConverter.cs delete mode 100644 Resgrid.Audio.Relay/MainWindow.xaml delete mode 100644 Resgrid.Audio.Relay/MainWindow.xaml.cs delete mode 100644 Resgrid.Audio.Relay/Properties/Resources.Designer.cs delete mode 100644 Resgrid.Audio.Relay/Properties/Resources.resx delete mode 100644 Resgrid.Audio.Relay/Properties/Settings.Designer.cs delete mode 100644 Resgrid.Audio.Relay/Properties/Settings.settings delete mode 100644 Resgrid.Audio.Relay/Resources/AppDictionary.xaml create mode 100644 Resgrid.Audio.Relay/Services/ConfigurationService.cs create mode 100644 Resgrid.Audio.Relay/Services/DeviceEnumerationService.cs create mode 100644 Resgrid.Audio.Relay/Services/RelayController.cs create mode 100644 Resgrid.Audio.Relay/ShellWindow.xaml create mode 100644 Resgrid.Audio.Relay/ShellWindow.xaml.cs delete mode 100644 Resgrid.Audio.Relay/Skins/MainSkin.xaml delete mode 100644 Resgrid.Audio.Relay/ViewModel/MainWindowViewModel.cs delete mode 100644 Resgrid.Audio.Relay/ViewModel/ShuttingDownMessage.cs delete mode 100644 Resgrid.Audio.Relay/ViewModel/ViewModelLocator.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/AboutViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/ConfigurationViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/DashboardViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/DevicesViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/LogsViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/ModeCardViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/OperationsViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/ServiceTileViewModel.cs create mode 100644 Resgrid.Audio.Relay/ViewModels/ShellViewModel.cs create mode 100644 Resgrid.Audio.Relay/Views/AboutView.xaml create mode 100644 Resgrid.Audio.Relay/Views/AboutView.xaml.cs create mode 100644 Resgrid.Audio.Relay/Views/ConfigurationView.xaml create mode 100644 Resgrid.Audio.Relay/Views/ConfigurationView.xaml.cs create mode 100644 Resgrid.Audio.Relay/Views/DashboardView.xaml create mode 100644 Resgrid.Audio.Relay/Views/DashboardView.xaml.cs create mode 100644 Resgrid.Audio.Relay/Views/DevicesView.xaml create mode 100644 Resgrid.Audio.Relay/Views/DevicesView.xaml.cs create mode 100644 Resgrid.Audio.Relay/Views/LogsView.xaml create mode 100644 Resgrid.Audio.Relay/Views/LogsView.xaml.cs create mode 100644 Resgrid.Audio.Relay/Views/OperationsView.xaml create mode 100644 Resgrid.Audio.Relay/Views/OperationsView.xaml.cs create mode 100644 Resgrid.Relay.Engine/Configuration/RelayConfiguration.cs create mode 100644 Resgrid.Relay.Engine/Logging/LogRecord.cs create mode 100644 Resgrid.Relay.Engine/Logging/UiBusSinkExtensions.cs create mode 100644 Resgrid.Relay.Engine/Logging/UiLogBus.cs create mode 100644 Resgrid.Relay.Engine/Logging/UiLogSink.cs diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..35e32b4 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + $(DefineConstants);NET10_0_WINDOWS + + diff --git a/Resgrid.Audio.Core/AudioEvaluator.cs b/Resgrid.Audio.Core/AudioEvaluator.cs index 0325a67..46d2836 100644 --- a/Resgrid.Audio.Core/AudioEvaluator.cs +++ b/Resgrid.Audio.Core/AudioEvaluator.cs @@ -8,7 +8,7 @@ using DtmfDetection.NAudio; using NAudio.Wave; using Resgrid.Audio.Core.Events; -using Serilog.Core; +using Serilog; namespace Resgrid.Audio.Core { @@ -29,7 +29,7 @@ public interface IAudioEvaluator public class AudioEvaluator : IAudioEvaluator { private static Object _lock = new Object(); - private readonly Logger _logger; + private readonly ILogger _logger; private Config _config; private List _dtmfTone; @@ -40,7 +40,7 @@ public class AudioEvaluator : IAudioEvaluator public event EventHandler EvaluatorFinished; public event EventHandler WatcherTriggered; - public AudioEvaluator(Logger logger) + public AudioEvaluator(ILogger logger) { _logger = logger; diff --git a/Resgrid.Audio.Core/Radio/RadioBridge.cs b/Resgrid.Audio.Core/Radio/RadioBridge.cs index 1fd373b..9047b33 100644 --- a/Resgrid.Audio.Core/Radio/RadioBridge.cs +++ b/Resgrid.Audio.Core/Radio/RadioBridge.cs @@ -136,10 +136,18 @@ private void OnRadioAudio(object sender, short[] frame) _emergency?.Process(frame); bool open = IsSquelchOpen(frame); - SetReceiving(open); + // Only count as "receiving" when RX audio is actually forwarded to the channel — + // not merely when squelch opens, and not while AntiLoop suppresses forwarding during + // transmit (a squelch opening then is our own TX feedback, not real RX, and counting + // it would make OnChannelAudio's anti-loop gate suppress the in-progress transmit). if (!open || (_settings.AntiLoop && _transmitting)) + { + SetReceiving(false); return; + } + + SetReceiving(true); // Copy + condition the audio before publishing. var outgoing = (short[])frame.Clone(); diff --git a/Resgrid.Audio.Core/WatcherAudioStorage.cs b/Resgrid.Audio.Core/WatcherAudioStorage.cs index d6e5728..5b0b6d0 100644 --- a/Resgrid.Audio.Core/WatcherAudioStorage.cs +++ b/Resgrid.Audio.Core/WatcherAudioStorage.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using Serilog.Core; +using Serilog; namespace Resgrid.Audio.Core { @@ -16,10 +16,10 @@ public interface IWatcherAudioStorage public class WatcherAudioStorage : IWatcherAudioStorage { private static Object _lock = new Object(); - private readonly Logger _logger; + private readonly ILogger _logger; private static Dictionary> _watcherAudio; - public WatcherAudioStorage(Logger logger) + public WatcherAudioStorage(ILogger logger) { _logger = logger; _watcherAudio = new Dictionary>(); diff --git a/Resgrid.Audio.Relay/App.xaml b/Resgrid.Audio.Relay/App.xaml index 1420646..a48f15d 100644 --- a/Resgrid.Audio.Relay/App.xaml +++ b/Resgrid.Audio.Relay/App.xaml @@ -1,6 +1,13 @@ - + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"> + + + + + + + + diff --git a/Resgrid.Audio.Relay/App.xaml.cs b/Resgrid.Audio.Relay/App.xaml.cs index 31b1b2b..9eed182 100644 --- a/Resgrid.Audio.Relay/App.xaml.cs +++ b/Resgrid.Audio.Relay/App.xaml.cs @@ -1,8 +1,146 @@ -using System.Windows; +using System; +using System.Linq; +using System.Threading; +using System.Windows; +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Audio.Relay.Services; +using Resgrid.Audio.Relay.ViewModels; +using Resgrid.Audio.Relay.Views; +using Resgrid.Relay.Engine; +using Resgrid.Relay.Engine.Logging; +using Serilog; +using Wpf.Ui.Appearance; namespace Resgrid.Audio.Relay { + /// + /// Application entry point. Owns the single-instance guard, the DI container, theme + /// application and the start-to-tray vs start-visible decision. The engine is composed via + /// ; logging is fanned out to a + /// for the Logs screen. + /// public partial class App : Application { + private const string SingleInstanceMutexName = "Global\\ResgridRelayDesktop"; + + private Mutex _singleInstanceMutex; + private ServiceProvider _services; + + /// Process-wide service provider, exposed for XAML-created view-models if needed. + public static IServiceProvider Services { get; private set; } + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // (1) Single-instance guard — surface the existing instance instead of starting twice. + _singleInstanceMutex = new Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew); + if (!createdNew) + { + MessageBox.Show( + "Resgrid Relay is already running.", + "Resgrid Relay", + MessageBoxButton.OK, + MessageBoxImage.Information); + Shutdown(); + return; + } + + // (2) Build the DI container. + _services = BuildServiceProvider(); + Services = _services; + + // (3) Apply the WPF-UI theme. + ApplicationThemeManager.Apply(ApplicationTheme.Dark); + + // (4) Resolve the shell. Start hidden to tray when launched with --minimized. + var startMinimized = e.Args.Any(a => + string.Equals(a, "--minimized", StringComparison.OrdinalIgnoreCase) || + string.Equals(a, "/minimized", StringComparison.OrdinalIgnoreCase)); + + var shell = _services.GetRequiredService(); + MainWindow = shell; + + if (startMinimized) + { + // Stay hidden — the tray icon (created with the window) keeps the app alive. + shell.StartHiddenToTray(); + } + else + { + shell.Show(); + } + } + + private static ServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + + // Engine (per-mode service factory delegate). + services.AddRelayEngine(); + + // UI log bus + a Serilog logger fanned out to it (plus Debug for local diagnostics). + var logBus = new UiLogBus(); + services.AddSingleton(logBus); + + ILogger logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.UiBus(logBus) + .CreateLogger(); + services.AddSingleton(logger); + Log.Logger = logger; + + // App services. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // View-models. + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Views. + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Shell window. + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + + protected override async void OnExit(ExitEventArgs e) + { + try + { + if (_services != null) + { + var controller = _services.GetService(); + if (controller != null) + await controller.StopAllAsync().ConfigureAwait(true); + + _services.Dispose(); + } + } + catch + { + // Best-effort shutdown — never block process exit. + } + finally + { + Log.CloseAndFlush(); + _singleInstanceMutex?.Dispose(); + base.OnExit(e); + } + } } } diff --git a/Resgrid.Audio.Relay/Controls/LevelMeter.xaml b/Resgrid.Audio.Relay/Controls/LevelMeter.xaml new file mode 100644 index 0000000..f781fc3 --- /dev/null +++ b/Resgrid.Audio.Relay/Controls/LevelMeter.xaml @@ -0,0 +1,21 @@ + + + + + + diff --git a/Resgrid.Audio.Relay/Controls/LevelMeter.xaml.cs b/Resgrid.Audio.Relay/Controls/LevelMeter.xaml.cs new file mode 100644 index 0000000..2134cb7 --- /dev/null +++ b/Resgrid.Audio.Relay/Controls/LevelMeter.xaml.cs @@ -0,0 +1,45 @@ +using System; +using System.Windows; +using System.Windows.Controls; + +namespace Resgrid.Audio.Relay.Controls +{ + /// + /// A simple input-level meter that maps a dBFS value (typically -80..0) onto a 0..100 + /// using (db + 80) / 80, and shows the numeric value. + /// + public partial class LevelMeter : UserControl + { + public LevelMeter() + { + InitializeComponent(); + UpdateVisual(Dbfs); + } + + public static readonly DependencyProperty DbfsProperty = DependencyProperty.Register( + nameof(Dbfs), + typeof(double), + typeof(LevelMeter), + new FrameworkPropertyMetadata(-80.0, OnDbfsChanged)); + + /// Current input level in dBFS. Clamped to the -80..0 display range. + public double Dbfs + { + get => (double)GetValue(DbfsProperty); + set => SetValue(DbfsProperty, value); + } + + private static void OnDbfsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((LevelMeter)d).UpdateVisual((double)e.NewValue); + } + + private void UpdateVisual(double db) + { + // Map dBFS to a 0..100 percentage: -80 dBFS -> 0, 0 dBFS -> 100. + var percent = Math.Clamp((db + 80.0) / 80.0 * 100.0, 0.0, 100.0); + Bar.Value = percent; + Label.Text = $"{db,5:0.0} dBFS"; + } + } +} diff --git a/Resgrid.Audio.Relay/Controls/Sparkline.cs b/Resgrid.Audio.Relay/Controls/Sparkline.cs new file mode 100644 index 0000000..640670c --- /dev/null +++ b/Resgrid.Audio.Relay/Controls/Sparkline.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace Resgrid.Audio.Relay.Controls +{ + /// + /// A tiny -backed sparkline that plots a rolling series of values + /// (e.g. input dBFS over time) normalized to the control's bounds. Minimal for now — the + /// later UI pass styles and animates it. + /// + public sealed class Sparkline : Control + { + private readonly Polyline _polyline; + + static Sparkline() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(Sparkline), + new FrameworkPropertyMetadata(typeof(Sparkline))); + } + + public Sparkline() + { + _polyline = new Polyline + { + Stroke = Brushes.LimeGreen, + StrokeThickness = 1.5, + StrokeLineJoin = PenLineJoin.Round + }; + + var canvas = new Canvas(); + canvas.Children.Add(_polyline); + AddVisualChild(canvas); + _canvas = canvas; + + SizeChanged += (_, __) => Redraw(); + } + + private readonly Canvas _canvas; + + protected override int VisualChildrenCount => 1; + + protected override Visual GetVisualChild(int index) => _canvas; + + protected override Size MeasureOverride(Size constraint) + { + _canvas.Measure(constraint); + return new Size( + double.IsInfinity(constraint.Width) ? 100 : constraint.Width, + double.IsInfinity(constraint.Height) ? 24 : constraint.Height); + } + + protected override Size ArrangeOverride(Size arrangeBounds) + { + _canvas.Arrange(new Rect(arrangeBounds)); + Redraw(); + return arrangeBounds; + } + + public static readonly DependencyProperty ValuesProperty = DependencyProperty.Register( + nameof(Values), + typeof(IEnumerable), + typeof(Sparkline), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnValuesChanged)); + + /// The series to plot. Min/max are auto-scaled to the control height. + public IEnumerable Values + { + get => (IEnumerable)GetValue(ValuesProperty); + set => SetValue(ValuesProperty, value); + } + + public static readonly DependencyProperty StrokeProperty = DependencyProperty.Register( + nameof(Stroke), + typeof(Brush), + typeof(Sparkline), + new FrameworkPropertyMetadata(Brushes.LimeGreen, OnStrokeChanged)); + + public Brush Stroke + { + get => (Brush)GetValue(StrokeProperty); + set => SetValue(StrokeProperty, value); + } + + private static void OnValuesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((Sparkline)d).Redraw(); + } + + private static void OnStrokeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((Sparkline)d)._polyline.Stroke = e.NewValue as Brush ?? Brushes.LimeGreen; + } + + private void Redraw() + { + var width = ActualWidth; + var height = ActualHeight; + _polyline.Points.Clear(); + + var data = Values?.ToList(); + if (data == null || data.Count < 2 || width <= 0 || height <= 0) + return; + + var min = data.Min(); + var max = data.Max(); + var range = max - min; + if (range <= 0) + range = 1; + + var stepX = width / (data.Count - 1); + for (var i = 0; i < data.Count; i++) + { + var x = i * stepX; + var y = height - ((data[i] - min) / range * height); + _polyline.Points.Add(new Point(x, y)); + } + } + } +} diff --git a/Resgrid.Audio.Relay/Controls/StatusPill.xaml b/Resgrid.Audio.Relay/Controls/StatusPill.xaml new file mode 100644 index 0000000..a2fbec3 --- /dev/null +++ b/Resgrid.Audio.Relay/Controls/StatusPill.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/Resgrid.Audio.Relay/Controls/StatusPill.xaml.cs b/Resgrid.Audio.Relay/Controls/StatusPill.xaml.cs new file mode 100644 index 0000000..dbfd273 --- /dev/null +++ b/Resgrid.Audio.Relay/Controls/StatusPill.xaml.cs @@ -0,0 +1,42 @@ +using System.Windows; +using System.Windows.Controls; +using Resgrid.Relay.Engine; + +namespace Resgrid.Audio.Relay.Controls +{ + /// + /// A small colored pill that reflects a with an optional + /// text label, used for the per-dependency health chips on the Dashboard / status bar. + /// + public partial class StatusPill : UserControl + { + public StatusPill() + { + InitializeComponent(); + } + + public static readonly DependencyProperty StateProperty = DependencyProperty.Register( + nameof(State), + typeof(ConnectionState), + typeof(StatusPill), + new FrameworkPropertyMetadata(ConnectionState.NotApplicable)); + + public ConnectionState State + { + get => (ConnectionState)GetValue(StateProperty); + set => SetValue(StateProperty, value); + } + + public static readonly DependencyProperty LabelProperty = DependencyProperty.Register( + nameof(Label), + typeof(string), + typeof(StatusPill), + new FrameworkPropertyMetadata(string.Empty)); + + public string Label + { + get => (string)GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/BoolToBrushConverter.cs b/Resgrid.Audio.Relay/Converters/BoolToBrushConverter.cs new file mode 100644 index 0000000..4b57cb9 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/BoolToBrushConverter.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Maps a to one of two brushes (e.g. transmitting / squelch-open + /// indicators). True → , false → . + /// + public sealed class BoolToBrushConverter : IValueConverter + { + public Brush TrueBrush { get; set; } = StatusBrushes.Running; + public Brush FalseBrush { get; set; } = StatusBrushes.Inactive; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is bool b && b ? TrueBrush : FalseBrush; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/BoolToVisibilityConverter.cs b/Resgrid.Audio.Relay/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..1218b96 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/BoolToVisibilityConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Maps a to (true → Visible, false → + /// Collapsed). Set to flip the mapping. Exposes a shared + /// usable as {x:Static ...} from XAML. + /// + public sealed class BoolToVisibilityConverter : IValueConverter + { + public static readonly BoolToVisibilityConverter Instance = new BoolToVisibilityConverter(); + + public bool Invert { get; set; } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var flag = value is bool b && b; + if (Invert) + flag = !flag; + return flag ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is Visibility v && v == Visibility.Visible; + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/BoolToVisibilityInvertedConverter.cs b/Resgrid.Audio.Relay/Converters/BoolToVisibilityInvertedConverter.cs new file mode 100644 index 0000000..8d333b4 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/BoolToVisibilityInvertedConverter.cs @@ -0,0 +1,28 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Maps a to inverted (true → Collapsed, false → + /// Visible). Used to swap the masked vs revealed secret editors on the Configuration screen. + /// Exposes a shared for {x:Static} use from XAML. + /// + public sealed class BoolToVisibilityInvertedConverter : IValueConverter + { + public static readonly BoolToVisibilityInvertedConverter Instance = new BoolToVisibilityInvertedConverter(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var flag = value is bool b && b; + return flag ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is Visibility v && v == Visibility.Collapsed; + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/ConnectionStateToBrushConverter.cs b/Resgrid.Audio.Relay/Converters/ConnectionStateToBrushConverter.cs new file mode 100644 index 0000000..9d40db6 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/ConnectionStateToBrushConverter.cs @@ -0,0 +1,49 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using Resgrid.Relay.Engine; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Maps a to a traffic-light : + /// Connected = green, Connecting = amber, Disconnected/Degraded = red, + /// NotApplicable/Unknown = grey. + /// + public sealed class ConnectionStateToBrushConverter : IValueConverter + { + public Brush ConnectedBrush { get; set; } = StatusBrushes.Connected; + public Brush ConnectingBrush { get; set; } = StatusBrushes.Connecting; + public Brush DisconnectedBrush { get; set; } = StatusBrushes.Disconnected; + public Brush DegradedBrush { get; set; } = StatusBrushes.Degraded; + public Brush NotApplicableBrush { get; set; } = StatusBrushes.NotApplicable; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is ConnectionState state) + { + switch (state) + { + case ConnectionState.Connected: + return ConnectedBrush; + case ConnectionState.Connecting: + return ConnectingBrush; + case ConnectionState.Degraded: + return DegradedBrush; + case ConnectionState.Disconnected: + return DisconnectedBrush; + default: + return NotApplicableBrush; + } + } + + return NotApplicableBrush; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/CountToVisibilityConverter.cs b/Resgrid.Audio.Relay/Converters/CountToVisibilityConverter.cs new file mode 100644 index 0000000..41f57cc --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/CountToVisibilityConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Maps an integer count to . By default a count of zero is + /// (so an "empty list" hint can be shown) and any positive + /// count is . Set to flip it (show + /// content only when the count is non-zero). Exposes shared and + /// instances for {x:Static} use from XAML. + /// + public sealed class CountToVisibilityConverter : IValueConverter + { + /// Visible when the count is zero (empty-state hint). + public static readonly CountToVisibilityConverter ZeroVisible = new CountToVisibilityConverter { Invert = false }; + + /// Visible when the count is non-zero. + public static readonly CountToVisibilityConverter NonZeroVisible = new CountToVisibilityConverter { Invert = true }; + + public bool Invert { get; set; } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var count = value is int i ? i : 0; + var isZero = count == 0; + if (Invert) + isZero = !isZero; + return isZero ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/LogLevelToBrushConverter.cs b/Resgrid.Audio.Relay/Converters/LogLevelToBrushConverter.cs new file mode 100644 index 0000000..770c5e4 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/LogLevelToBrushConverter.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using Serilog.Events; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Maps a Serilog to a foreground brush for the Logs list so + /// warnings/errors stand out: Error/Fatal red, Warning amber, Information default, lower + /// levels dimmed. Exposes a shared for {x:Static} use. + /// + public sealed class LogLevelToBrushConverter : IValueConverter + { + public static readonly LogLevelToBrushConverter Instance = new LogLevelToBrushConverter(); + + private static readonly Brush Dim = Make(0x90, 0x90, 0x90); + private static readonly Brush Normal = Make(0xDD, 0xDD, 0xDD); + private static readonly Brush Warn = StatusBrushes.Connecting; + private static readonly Brush Error = StatusBrushes.Disconnected; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is LogEventLevel level) + { + switch (level) + { + case LogEventLevel.Fatal: + case LogEventLevel.Error: + return Error; + case LogEventLevel.Warning: + return Warn; + case LogEventLevel.Information: + return Normal; + default: + return Dim; + } + } + + return Normal; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + + private static Brush Make(byte r, byte g, byte b) + { + var brush = new SolidColorBrush(Color.FromRgb(r, g, b)); + brush.Freeze(); + return brush; + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/RelayServiceStateToBrushConverter.cs b/Resgrid.Audio.Relay/Converters/RelayServiceStateToBrushConverter.cs new file mode 100644 index 0000000..cb33490 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/RelayServiceStateToBrushConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using Resgrid.Relay.Engine; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Maps a to a brush: Running = green, Starting/Stopping + /// = amber, Faulted = red, Stopped = grey. + /// + public sealed class RelayServiceStateToBrushConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is RelayServiceState state) + { + switch (state) + { + case RelayServiceState.Running: + return StatusBrushes.Running; + case RelayServiceState.Starting: + case RelayServiceState.Stopping: + return StatusBrushes.Connecting; + case RelayServiceState.Faulted: + return StatusBrushes.Faulted; + default: + return StatusBrushes.Inactive; + } + } + + return StatusBrushes.Inactive; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/SecretMaskConverter.cs b/Resgrid.Audio.Relay/Converters/SecretMaskConverter.cs new file mode 100644 index 0000000..5329ea7 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/SecretMaskConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// One-way converter that renders a secret string as a fixed run of bullet characters for + /// the masked (not-revealed) display of a secret field. Empty stays empty so the operator + /// can tell an unset secret from a stored one. Exposes a shared . + /// + public sealed class SecretMaskConverter : IValueConverter + { + public static readonly SecretMaskConverter Instance = new SecretMaskConverter(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var s = value as string; + if (string.IsNullOrEmpty(s)) + return ""; + return new string('•', Math.Min(Math.Max(s.Length, 8), 16)); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/SquelchTextConverter.cs b/Resgrid.Audio.Relay/Converters/SquelchTextConverter.cs new file mode 100644 index 0000000..0fe2d94 --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/SquelchTextConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Renders the squelch as a badge label: open → "OPEN", closed → + /// "closed". Exposes a shared for {x:Static} use from XAML. + /// + public sealed class SquelchTextConverter : IValueConverter + { + public static readonly SquelchTextConverter Instance = new SquelchTextConverter(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is bool b && b ? "SQ OPEN" : "SQ closed"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/StatusBrushes.cs b/Resgrid.Audio.Relay/Converters/StatusBrushes.cs new file mode 100644 index 0000000..0a2487b --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/StatusBrushes.cs @@ -0,0 +1,27 @@ +using System.Windows.Media; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Shared, frozen traffic-light brushes used by the status converters and controls so + /// every health indicator across the app uses one consistent palette. + /// + public static class StatusBrushes + { + public static readonly Brush Connected = Freeze(Color.FromRgb(0x2E, 0xCC, 0x71)); // green + public static readonly Brush Connecting = Freeze(Color.FromRgb(0xF1, 0xC4, 0x0F)); // amber + public static readonly Brush Degraded = Freeze(Color.FromRgb(0xE6, 0x7E, 0x22)); // orange + public static readonly Brush Disconnected = Freeze(Color.FromRgb(0xE7, 0x4C, 0x3C)); // red + public static readonly Brush Faulted = Freeze(Color.FromRgb(0xE7, 0x4C, 0x3C)); // red + public static readonly Brush NotApplicable = Freeze(Color.FromRgb(0x7F, 0x8C, 0x8D));// grey + public static readonly Brush Running = Freeze(Color.FromRgb(0x2E, 0xCC, 0x71)); // green + public static readonly Brush Inactive = Freeze(Color.FromRgb(0x95, 0xA5, 0xA6)); // grey + + private static Brush Freeze(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + } +} diff --git a/Resgrid.Audio.Relay/Converters/TuneButtonTextConverter.cs b/Resgrid.Audio.Relay/Converters/TuneButtonTextConverter.cs new file mode 100644 index 0000000..958a46a --- /dev/null +++ b/Resgrid.Audio.Relay/Converters/TuneButtonTextConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace Resgrid.Audio.Relay.Converters +{ + /// + /// Renders the tune-toggle button caption from the IsTuning flag: tuning → "Stop + /// tuning", idle → "Start tuning". Exposes a shared for XAML. + /// + public sealed class TuneButtonTextConverter : IValueConverter + { + public static readonly TuneButtonTextConverter Instance = new TuneButtonTextConverter(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is bool b && b ? "Stop tuning" : "Start tuning"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + } +} diff --git a/Resgrid.Audio.Relay/MainWindow.xaml b/Resgrid.Audio.Relay/MainWindow.xaml deleted file mode 100644 index 16d6fa1..0000000 --- a/Resgrid.Audio.Relay/MainWindow.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - -