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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Resgrid.Audio.Relay/MainWindow.xaml.cs b/Resgrid.Audio.Relay/MainWindow.xaml.cs
deleted file mode 100644
index e4a5009..0000000
--- a/Resgrid.Audio.Relay/MainWindow.xaml.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using NAudio.Wave;
-using Resgrid.Audio.Core;
-using Serilog;
-using Serilog.Core;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Windows;
-
-namespace Resgrid.Audio.Relay
-{
- public partial class MainWindow : Window
- {
- private readonly Logger _logger;
- private readonly WatcherAudioStorage _audioStorage;
- private readonly AudioEvaluator _audioEvaluator;
- private readonly AudioRecorder _audioRecorder;
-
- public MainWindow()
- {
- InitializeComponent();
-
- _logger = new LoggerConfiguration()
- .MinimumLevel.Information()
- .CreateLogger();
-
- _audioStorage = new WatcherAudioStorage(_logger);
- _audioEvaluator = new AudioEvaluator(_logger);
- _audioRecorder = new AudioRecorder(_audioEvaluator, _audioStorage);
- _audioRecorder.SetSampleAggregator(new SampleAggregator());
- _audioRecorder.SampleAggregator.MaximumCalculated += SampleAggregator_MaximumCalculated;
- _audioRecorder.SampleAggregator.WaveformCalculated += SampleAggregator_WaveformCalculated;
-
- LoadDevices();
- Closed += OnClosed;
- }
-
- private void LoadDevices()
- {
- var devices = new List();
- for (var deviceNumber = 0; deviceNumber < WaveIn.DeviceCount; deviceNumber++)
- {
- var capabilities = WaveIn.GetCapabilities(deviceNumber);
- devices.Add(new DeviceOption
- {
- Id = deviceNumber,
- Name = $"{deviceNumber}: {capabilities.ProductName} ({capabilities.Channels} channels)"
- });
- }
-
- DeviceComboBox.ItemsSource = devices;
- DeviceComboBox.SelectedIndex = devices.Count > 0 ? 0 : -1;
- StatusTextBlock.Text = devices.Count > 0 ? "Ready" : "No input devices found";
- }
-
- private void ToggleMonitoring_Click(object sender, RoutedEventArgs e)
- {
- if (_audioRecorder.RecordingState == RecordingState.Stopped)
- {
- var selectedDevice = DeviceComboBox.SelectedItem as DeviceOption;
- if (selectedDevice == null)
- {
- StatusTextBlock.Text = "Select an input device before starting.";
- return;
- }
-
- _audioRecorder.BeginMonitoring(selectedDevice.Id);
- ToggleButton.Content = "Stop Monitoring";
- StatusTextBlock.Text = $"Monitoring {selectedDevice.Name}";
- AppendEvent($"Started monitoring device {selectedDevice.Name}");
- }
- else
- {
- _audioRecorder.Stop();
- ToggleButton.Content = "Start Monitoring";
- StatusTextBlock.Text = "Stopped";
- AppendEvent("Stopped monitoring");
- }
- }
-
- private void SampleAggregator_MaximumCalculated(object sender, MaxSampleEventArgs e)
- {
- Dispatcher.Invoke(() =>
- {
- MinTextBlock.Text = e.MinSample.ToString("F4");
- MaxTextBlock.Text = e.MaxSample.ToString("F4");
- DbTextBlock.Text = e.Db.ToString("F2");
- });
- }
-
- private void SampleAggregator_WaveformCalculated(object sender, WaveformEventArgs e)
- {
- Dispatcher.Invoke(() =>
- {
- FftTextBlock.Text = e.FastFourierTransform.Length.ToString();
- });
- }
-
- private void OnClosed(object sender, EventArgs e)
- {
- _audioRecorder.Stop();
- }
-
- private void AppendEvent(string text)
- {
- EventsTextBox.AppendText($"{DateTime.Now:G}: {text}{Environment.NewLine}");
- EventsTextBox.ScrollToEnd();
- }
-
- private sealed class DeviceOption
- {
- public int Id { get; set; }
- public string Name { get; set; }
- }
- }
-}
diff --git a/Resgrid.Audio.Relay/Properties/Resources.Designer.cs b/Resgrid.Audio.Relay/Properties/Resources.Designer.cs
deleted file mode 100644
index 7828239..0000000
--- a/Resgrid.Audio.Relay/Properties/Resources.Designer.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-//------------------------------------------------------------------------------
-//
-// This code was generated by a tool.
-// Runtime Version:4.0.30319.42000
-//
-// Changes to this file may cause incorrect behavior and will be lost if
-// the code is regenerated.
-//
-//------------------------------------------------------------------------------
-
-namespace Resgrid.Audio.Relay.Properties {
- using System;
-
-
- ///
- /// A strongly-typed resource class, for looking up localized strings, etc.
- ///
- // This class was auto-generated by the StronglyTypedResourceBuilder
- // class via a tool like ResGen or Visual Studio.
- // To add or remove a member, edit your .ResX file then rerun ResGen
- // with the /str option, or rebuild your VS project.
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")]
- [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
- [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- internal class Resources {
-
- private static global::System.Resources.ResourceManager resourceMan;
-
- private static global::System.Globalization.CultureInfo resourceCulture;
-
- [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
- internal Resources() {
- }
-
- ///
- /// Returns the cached ResourceManager instance used by this class.
- ///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- internal static global::System.Resources.ResourceManager ResourceManager {
- get {
- if (object.ReferenceEquals(resourceMan, null)) {
- global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Resgrid.Audio.Relay.Properties.Resources", typeof(Resources).Assembly);
- resourceMan = temp;
- }
- return resourceMan;
- }
- }
-
- ///
- /// Overrides the current thread's CurrentUICulture property for all
- /// resource lookups using this strongly typed resource class.
- ///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- internal static global::System.Globalization.CultureInfo Culture {
- get {
- return resourceCulture;
- }
- set {
- resourceCulture = value;
- }
- }
- }
-}
diff --git a/Resgrid.Audio.Relay/Properties/Resources.resx b/Resgrid.Audio.Relay/Properties/Resources.resx
deleted file mode 100644
index af7dbeb..0000000
--- a/Resgrid.Audio.Relay/Properties/Resources.resx
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- text/microsoft-resx
-
-
- 2.0
-
-
- System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
- System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
\ No newline at end of file
diff --git a/Resgrid.Audio.Relay/Properties/Settings.Designer.cs b/Resgrid.Audio.Relay/Properties/Settings.Designer.cs
deleted file mode 100644
index 60a699e..0000000
--- a/Resgrid.Audio.Relay/Properties/Settings.Designer.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-//------------------------------------------------------------------------------
-//
-// This code was generated by a tool.
-// Runtime Version:4.0.30319.42000
-//
-// Changes to this file may cause incorrect behavior and will be lost if
-// the code is regenerated.
-//
-//------------------------------------------------------------------------------
-
-namespace Resgrid.Audio.Relay.Properties {
-
-
- [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "15.6.0.0")]
- internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
-
- private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
-
- public static Settings Default {
- get {
- return defaultInstance;
- }
- }
- }
-}
diff --git a/Resgrid.Audio.Relay/Properties/Settings.settings b/Resgrid.Audio.Relay/Properties/Settings.settings
deleted file mode 100644
index 033d7a5..0000000
--- a/Resgrid.Audio.Relay/Properties/Settings.settings
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj b/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj
index fcf8cb0..99504c6 100644
--- a/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj
+++ b/Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj
@@ -8,24 +8,31 @@
false
disable
disable
+ true
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
diff --git a/Resgrid.Audio.Relay/Resources/AppDictionary.xaml b/Resgrid.Audio.Relay/Resources/AppDictionary.xaml
deleted file mode 100644
index 4ec25a4..0000000
--- a/Resgrid.Audio.Relay/Resources/AppDictionary.xaml
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
-
-
-
- #303030
- 13
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0.6
-
-
-
-
-
\ No newline at end of file
diff --git a/Resgrid.Audio.Relay/Services/ConfigurationService.cs b/Resgrid.Audio.Relay/Services/ConfigurationService.cs
new file mode 100644
index 0000000..8b90ca5
--- /dev/null
+++ b/Resgrid.Audio.Relay/Services/ConfigurationService.cs
@@ -0,0 +1,46 @@
+using Resgrid.Relay.Engine.Configuration;
+
+namespace Resgrid.Audio.Relay.Services
+{
+ ///
+ /// Thin wrapper over giving the UI a single, mutable
+ /// view of the current plus save / reload helpers and the
+ /// "environment overrides present" flag used to warn the operator.
+ ///
+ public sealed class ConfigurationService
+ {
+ public ConfigurationService()
+ {
+ Current = RelayConfiguration.Load();
+ }
+
+ /// The options last loaded or saved. Bind UI editors against this instance.
+ public RelayHostOptions Current { get; private set; }
+
+ /// Path of the per-user settings file (for display in the UI).
+ public string UserConfigPath => RelayConfiguration.UserConfigPath;
+
+ /// True when one or more RESGRID__RELAY__ environment variables are set.
+ public bool HasEnvOverrides => RelayConfiguration.HasEnvOverrides();
+
+ /// Re-reads the layered configuration and replaces .
+ public RelayHostOptions Reload()
+ {
+ Current = RelayConfiguration.Load();
+ return Current;
+ }
+
+ /// Persists to the per-user settings file.
+ public void Save()
+ {
+ RelayConfiguration.Save(Current);
+ }
+
+ /// Persists and adopts it as .
+ public void Save(RelayHostOptions options)
+ {
+ RelayConfiguration.Save(options);
+ Current = options;
+ }
+ }
+}
diff --git a/Resgrid.Audio.Relay/Services/DeviceEnumerationService.cs b/Resgrid.Audio.Relay/Services/DeviceEnumerationService.cs
new file mode 100644
index 0000000..ec833b5
--- /dev/null
+++ b/Resgrid.Audio.Relay/Services/DeviceEnumerationService.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using HidSharp;
+using NAudio.Wave;
+using SerialPort = System.IO.Ports.SerialPort;
+
+namespace Resgrid.Audio.Relay.Services
+{
+ /// Simple DTO describing an audio capture/render device.
+ public sealed class AudioDeviceInfo
+ {
+ public int Id { get; set; }
+ public string Name { get; set; }
+ public int Channels { get; set; }
+ public bool IsInput { get; set; }
+
+ public string Display => $"{Id}: {Name} ({Channels} ch)";
+ }
+
+ /// Simple DTO describing a serial port available for PTT / carrier detect.
+ public sealed class SerialPortInfo
+ {
+ public string PortName { get; set; }
+ }
+
+ /// Simple DTO describing a CM108-class USB HID device usable for PTT GPIO.
+ public sealed class HidDeviceInfo
+ {
+ public int VendorId { get; set; }
+ public int ProductId { get; set; }
+ public string Name { get; set; }
+
+ public string Display => $"VID=0x{VendorId:X4} PID=0x{ProductId:X4} {Name}";
+ }
+
+ ///
+ /// Enumerates audio devices, serial ports and CM108-class HID devices for the Devices
+ /// screen and the radio configuration UI. Mirrors the console's devices command.
+ /// All methods are defensive — a failing backend yields an empty list rather than throwing.
+ ///
+ public sealed class DeviceEnumerationService
+ {
+ /// NAudio input (radio receive) devices.
+ public IReadOnlyList GetInputDevices()
+ {
+ var devices = new List();
+ try
+ {
+ for (var i = 0; i < WaveIn.DeviceCount; i++)
+ {
+ var caps = WaveIn.GetCapabilities(i);
+ devices.Add(new AudioDeviceInfo
+ {
+ Id = i,
+ Name = caps.ProductName,
+ Channels = caps.Channels,
+ IsInput = true
+ });
+ }
+ }
+ catch
+ {
+ // Audio backend unavailable (e.g. running on a build without WPF runtime).
+ }
+
+ return devices;
+ }
+
+ /// NAudio output (radio transmit) devices. Includes the -1 "system default" entry.
+ public IReadOnlyList GetOutputDevices()
+ {
+ var devices = new List
+ {
+ new AudioDeviceInfo { Id = -1, Name = "System default", Channels = 0, IsInput = false }
+ };
+
+ try
+ {
+ for (var i = 0; i < WaveOut.DeviceCount; i++)
+ {
+ var caps = WaveOut.GetCapabilities(i);
+ devices.Add(new AudioDeviceInfo
+ {
+ Id = i,
+ Name = caps.ProductName,
+ Channels = caps.Channels,
+ IsInput = false
+ });
+ }
+ }
+ catch
+ {
+ }
+
+ return devices;
+ }
+
+ /// Serial ports for PTT keying / carrier detect.
+ public IReadOnlyList GetSerialPorts()
+ {
+ try
+ {
+ return SerialPort.GetPortNames()
+ .OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
+ .Select(p => new SerialPortInfo { PortName = p })
+ .ToList();
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+
+ /// CM108-class USB HID devices (VID 0x0D8C) usable for PTT GPIO.
+ public IReadOnlyList GetCm108Devices()
+ {
+ try
+ {
+ return DeviceList.Local.GetHidDevices(0x0D8C, null)
+ .Select(d => new HidDeviceInfo
+ {
+ VendorId = d.VendorID,
+ ProductId = d.ProductID,
+ Name = SafeName(d)
+ })
+ .ToList();
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static string SafeName(HidDevice device)
+ {
+ try { return device.GetFriendlyName(); }
+ catch { return "(unnamed)"; }
+ }
+ }
+}
diff --git a/Resgrid.Audio.Relay/Services/RelayController.cs b/Resgrid.Audio.Relay/Services/RelayController.cs
new file mode 100644
index 0000000..8dbe52a
--- /dev/null
+++ b/Resgrid.Audio.Relay/Services/RelayController.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Relay.Engine;
+using Resgrid.Relay.Engine.Configuration;
+using Resgrid.Relay.Engine.Services;
+using Serilog;
+
+namespace Resgrid.Audio.Relay.Services
+{
+ ///
+ /// Owns the set of running instances for the desktop app and
+ /// exposes a button-friendly start/stop lifecycle. Each started mode runs its long-lived
+ /// on a tracked background task with its own
+ /// ; stopping cancels, awaits, disposes and removes it.
+ ///
+ /// All mutations are marshalled to the captured UI
+ /// so the collection can be bound directly.
+ ///
+ public sealed class RelayController
+ {
+ private readonly ILogger _logger;
+ private readonly SynchronizationContext _uiContext;
+ private readonly object _gate = new object();
+ private readonly Dictionary _entries = new Dictionary();
+
+ public RelayController(ILogger logger)
+ {
+ _logger = logger ?? Log.Logger;
+ // Captured at construction (App startup) so we can post collection updates to the UI thread.
+ _uiContext = SynchronizationContext.Current;
+ }
+
+ /// The currently running relay services. Bind UI lists against this.
+ public ObservableCollection RunningServices { get; } = new ObservableCollection();
+
+ /// Raised after changes or a service changes state.
+ public event EventHandler OverallStateChanged;
+
+ ///
+ /// Worst-of the running services' states, used to drive the shell traffic-light:
+ /// Faulted > Starting/Stopping > Running > Stopped. Returns
+ /// when nothing is running.
+ ///
+ public RelayServiceState OverallState
+ {
+ get
+ {
+ lock (_gate)
+ {
+ if (_entries.Count == 0)
+ return RelayServiceState.Stopped;
+
+ var states = _entries.Keys.Select(s => s.State).ToList();
+ if (states.Any(s => s == RelayServiceState.Faulted))
+ return RelayServiceState.Faulted;
+ if (states.Any(s => s == RelayServiceState.Starting || s == RelayServiceState.Stopping))
+ return RelayServiceState.Starting;
+ if (states.Any(s => s == RelayServiceState.Running))
+ return RelayServiceState.Running;
+
+ return RelayServiceState.Stopped;
+ }
+ }
+ }
+
+ /// True when at least one service is being tracked.
+ public bool IsRunning
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _entries.Count > 0;
+ }
+ }
+ }
+
+ ///
+ /// Creates the configured mode via , adds it to the
+ /// running set and launches its background run loop. Returns the created service.
+ ///
+ public IRelayService Start(RelayHostOptions options)
+ {
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ var service = RelayServiceFactory.Create(options, _logger);
+ var cts = new CancellationTokenSource();
+ var entry = new RunningEntry(service, cts);
+
+ service.StateChanged += OnServiceStateChanged;
+
+ lock (_gate)
+ {
+ _entries[service] = entry;
+ }
+
+ Post(() => RunningServices.Add(service));
+
+ entry.RunTask = Task.Run(async () =>
+ {
+ try
+ {
+ await service.StartAsync(cts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal on Stop.
+ }
+ catch (Exception ex)
+ {
+ _logger?.Error(ex, "Relay mode '{Mode}' terminated unexpectedly.", service.Mode);
+ }
+ });
+
+ RaiseOverallStateChanged();
+ return service;
+ }
+
+ /// Stops, disposes and removes a single running service.
+ public async Task StopAsync(IRelayService service)
+ {
+ if (service == null)
+ return;
+
+ RunningEntry entry;
+ lock (_gate)
+ {
+ if (!_entries.TryGetValue(service, out entry))
+ return;
+ _entries.Remove(service);
+ }
+
+ service.StateChanged -= OnServiceStateChanged;
+
+ try
+ {
+ entry.Cts.Cancel();
+ try { await service.StopAsync().ConfigureAwait(false); }
+ catch (Exception ex) { _logger?.Warning(ex, "Error stopping relay mode '{Mode}'.", service.Mode); }
+
+ if (entry.RunTask != null)
+ {
+ try { await entry.RunTask.ConfigureAwait(false); }
+ catch { /* already logged in run loop */ }
+ }
+ }
+ finally
+ {
+ try { await service.DisposeAsync().ConfigureAwait(false); }
+ catch (Exception ex) { _logger?.Warning(ex, "Error disposing relay mode '{Mode}'.", service.Mode); }
+
+ entry.Cts.Dispose();
+ Post(() => RunningServices.Remove(service));
+ RaiseOverallStateChanged();
+ }
+ }
+
+ /// Stops every running service. Safe to call on shutdown.
+ public async Task StopAllAsync()
+ {
+ IRelayService[] running;
+ lock (_gate)
+ {
+ running = _entries.Keys.ToArray();
+ }
+
+ foreach (var service in running)
+ await StopAsync(service).ConfigureAwait(false);
+ }
+
+ private void OnServiceStateChanged(object sender, RelayStateChangedEventArgs e)
+ {
+ RaiseOverallStateChanged();
+ }
+
+ private void RaiseOverallStateChanged()
+ {
+ Post(() => OverallStateChanged?.Invoke(this, EventArgs.Empty));
+ }
+
+ private void Post(Action action)
+ {
+ if (_uiContext != null)
+ _uiContext.Post(_ => action(), null);
+ else
+ action();
+ }
+
+ private sealed class RunningEntry
+ {
+ public RunningEntry(IRelayService service, CancellationTokenSource cts)
+ {
+ Service = service;
+ Cts = cts;
+ }
+
+ public IRelayService Service { get; }
+ public CancellationTokenSource Cts { get; }
+ public Task RunTask { get; set; }
+ }
+ }
+}
diff --git a/Resgrid.Audio.Relay/ShellWindow.xaml b/Resgrid.Audio.Relay/ShellWindow.xaml
new file mode 100644
index 0000000..8d3fcfd
--- /dev/null
+++ b/Resgrid.Audio.Relay/ShellWindow.xaml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Resgrid.Audio.Relay/ShellWindow.xaml.cs b/Resgrid.Audio.Relay/ShellWindow.xaml.cs
new file mode 100644
index 0000000..08f63d3
--- /dev/null
+++ b/Resgrid.Audio.Relay/ShellWindow.xaml.cs
@@ -0,0 +1,135 @@
+using System;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Media.Imaging;
+using Microsoft.Extensions.DependencyInjection;
+using Resgrid.Audio.Relay.ViewModels;
+using Resgrid.Audio.Relay.Views;
+using Wpf.Ui.Abstractions;
+using Wpf.Ui.Controls;
+
+namespace Resgrid.Audio.Relay
+{
+ ///
+ /// Main shell: a Fluent window hosting the left navigation rail, the six screens, a footer
+ /// status bar and the system-tray integration. Closing the window hides it to the tray
+ /// while the relay keeps running; a real quit only happens via the tray menu (or explicit
+ /// shutdown), at which point stops the controller.
+ ///
+ public partial class ShellWindow : FluentWindow
+ {
+ private readonly IServiceProvider _services;
+ private bool _isExiting;
+
+ public ShellWindow(ShellViewModel viewModel, IServiceProvider services)
+ {
+ _services = services;
+ InitializeComponent();
+ DataContext = viewModel;
+
+ // Resolve navigated pages from the DI container so each view gets its view-model.
+ RootNavigation.SetPageProviderService(new DiPageProvider(services));
+
+ Loaded += OnLoaded;
+ Closing += OnClosing;
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ TrySetTrayIcon();
+
+ // Navigate to the first screen.
+ RootNavigation.Navigate(typeof(DashboardView));
+ }
+
+ /// Called by when launched with --minimized: stay hidden to tray.
+ public void StartHiddenToTray()
+ {
+ // Ensure the tray icon is realized even though the window is never shown.
+ Visibility = Visibility.Hidden;
+ ShowInTaskbar = false;
+ TrySetTrayIcon();
+ }
+
+ private void TrySetTrayIcon()
+ {
+ try
+ {
+ // Use the bundled logo as the tray icon source.
+ // TODO: health-colored tray icon + balloon notifications (runtime-only; can't verify on Linux).
+ var uri = new Uri("pack://application:,,,/Resources/ResgridLogo.png", UriKind.Absolute);
+ TrayIcon.IconSource = new BitmapImage(uri);
+ }
+ catch
+ {
+ // Asset missing or running headless — leave the default icon.
+ }
+ }
+
+ private void OnClosing(object sender, CancelEventArgs e)
+ {
+ if (_isExiting)
+ return;
+
+ // Intercept close → hide to tray, keep the controller running.
+ e.Cancel = true;
+ Hide();
+ ShowInTaskbar = false;
+ }
+
+ private void RestoreFromTray()
+ {
+ Show();
+ ShowInTaskbar = true;
+ WindowState = WindowState.Normal;
+ Activate();
+ Topmost = true;
+ Topmost = false;
+ }
+
+ private void TrayIcon_OnTrayLeftMouseUp(object sender, RoutedEventArgs e)
+ {
+ RestoreFromTray();
+ }
+
+ private void TrayOpen_OnClick(object sender, RoutedEventArgs e)
+ {
+ RestoreFromTray();
+ }
+
+ private void TrayQuit_OnClick(object sender, RoutedEventArgs e)
+ {
+ var result = System.Windows.MessageBox.Show(
+ "Quit Resgrid Relay? Any running relay modes will be stopped.",
+ "Resgrid Relay",
+ System.Windows.MessageBoxButton.YesNo,
+ System.Windows.MessageBoxImage.Question);
+
+ if (result != System.Windows.MessageBoxResult.Yes)
+ return;
+
+ _isExiting = true;
+ TrayIcon?.Dispose();
+ Application.Current.Shutdown();
+ }
+
+ ///
+ /// Bridges WPF-UI's navigation page resolution to the DI container so navigated views
+ /// are constructed with their view-models.
+ ///
+ private sealed class DiPageProvider : INavigationViewPageProvider
+ {
+ private readonly IServiceProvider _provider;
+
+ public DiPageProvider(IServiceProvider provider)
+ {
+ _provider = provider;
+ }
+
+ public object GetPage(Type pageType)
+ {
+ return _provider.GetService(pageType) ?? Activator.CreateInstance(pageType);
+ }
+ }
+ }
+}
diff --git a/Resgrid.Audio.Relay/Skins/MainSkin.xaml b/Resgrid.Audio.Relay/Skins/MainSkin.xaml
deleted file mode 100644
index 4ccd3a9..0000000
--- a/Resgrid.Audio.Relay/Skins/MainSkin.xaml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/Resgrid.Audio.Relay/ViewModel/MainWindowViewModel.cs b/Resgrid.Audio.Relay/ViewModel/MainWindowViewModel.cs
deleted file mode 100644
index 5b3afb2..0000000
--- a/Resgrid.Audio.Relay/ViewModel/MainWindowViewModel.cs
+++ /dev/null
@@ -1,190 +0,0 @@
-using GalaSoft.MvvmLight;
-using GalaSoft.MvvmLight.Command;
-using GalaSoft.MvvmLight.Messaging;
-using LiveCharts;
-using LiveCharts.Wpf;
-using Resgrid.Audio.Core;
-using System;
-using System.IO;
-using System.Linq;
-using System.Windows.Input;
-
-namespace Resgrid.Audio.Relay.ViewModel
-{
- public class MainWindowViewModel : ViewModelBase
- {
- private readonly IAudioRecorder _audioRecorder;
-
- private readonly RelayCommand toggleCommand;
- private readonly RelayCommand settingsCommand;
-
- private float lastPeak;
- public const string ViewName = "Resgrid Streamer";
-
- public SeriesCollection SeriesCollection { get; set; }
- public SeriesCollection SeriesCollection2 { get; set; }
- public string[] Labels { get; set; }
- public string[] Labels2 { get; set; }
-
- ///
- /// The property's name.
- ///
- public const string WelcomeTitlePropertyName = "WelcomeTitle";
-
- private string _toggleButtonText = string.Empty;
-
- ///
- /// Gets the WelcomeTitle property.
- /// Changes to that property's value raise the PropertyChanged event.
- ///
- public string ToggleButtonText
- {
- get
- {
- return _toggleButtonText;
- }
- set
- {
- Set(ref _toggleButtonText, value);
- }
- }
-
- ///
- /// Initializes a new instance of the MainViewModel class.
- ///
- public MainWindowViewModel(IAudioRecorder audioRecorder)
- {
- _audioRecorder = audioRecorder;
- //_dataService.GetData(
- // (item, error) =>
- // {
- // if (error != null)
- // {
- // // Report error here
- // return;
- // }
-
- // WelcomeTitle = item.Title;
- // });
-
- SeriesCollection = new SeriesCollection
- {
- new LineSeries
- {
- Title = "Frequency",
- LineSmoothness = 1,
- StrokeThickness = 1,
- DataLabels = false,
- PointGeometrySize = 0,
- Fill = System.Windows.Media.Brushes.Transparent,
- Values = new ChartValues { 0 }
- }
- };
-
- SeriesCollection2 = new SeriesCollection
- {
- new LineSeries
- {
- Title = "Frequency",
- LineSmoothness = 1,
- StrokeThickness = 1,
- DataLabels = false,
- PointGeometrySize = 0,
- Fill = System.Windows.Media.Brushes.Transparent,
- Values = new ChartValues { 0 }
- }
- };
-
- toggleCommand = new RelayCommand(ToggleRecording,
- () => this._audioRecorder.RecordingState == RecordingState.Stopped ||
- this._audioRecorder.RecordingState == RecordingState.Monitoring ||
- this._audioRecorder.RecordingState == RecordingState.Recording);
-
- this._audioRecorder.SampleAggregator.MaximumCalculated += OnRecorderMaximumCalculated;
- this._audioRecorder.SampleAggregator.WaveformCalculated += SampleAggregator_WaveformCalculated;
-
- Messenger.Default.Register(this, OnShuttingDown);
-
- this.ToggleButtonText = "Start Monitoring";
- }
-
- private void SampleAggregator_WaveformCalculated(object sender, WaveformEventArgs e)
- {
- SeriesCollection[0].Values.Clear();
- SeriesCollection[0].Values.AddRange(e.PulseCodeModulation.Cast