diff --git a/README.md b/README.md
index e807ea2..e36ccb5 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Most AV kit ships set to DHCP, and on a bench or a fresh site there's usually no
## Features
-**Phase 1 (in progress)**
+**Phase 1**
- **Pick your Ethernet adapter** from a list showing link state, speed and current IP. The app remembers your last choice.
- **Safe adapter changes.** Your adapter's settings are backed up before anything changes and restored on Stop, on exit, at Windows shutdown, and after a crash (offered the next time you open the app).
@@ -26,7 +26,21 @@ Most AV kit ships set to DHCP, and on a bench or a fresh site there's usually no
- **Wi-Fi and VPN overlap warnings**, with a one-click option to turn Wi-Fi off until you press Stop.
- **Dark and light themes**, with large status text you can read from arm's length.
-**Default settings:** server IP `192.168.100.1/24`, addresses `192.168.100.100` to `192.168.100.199`, 60-minute leases. All of these can be changed in **Settings**.
+**Phase 2 (in progress)**
+
+- **Live ping graph for every device.** Each device with a lease is pinged once a second. The device list shows a small latency graph with the latest reply time, and the selected device shows the last two minutes with average, min, max and loss. Missed replies show as red marks. When a device stops answering, or starts again, the session log records it.
+- **Session log you can filter and export.** Filter by level (everything, warnings and problems, problems only), by area (adapter, server, DHCP, device) or by text such as an IP or MAC address. **Export log…** saves what's shown as a text file for a job report, or as CSV for Excel.
+- **Profiles.** Save the subnet, pool, lease time and DHCP options for each job or kind of gear, and pick one from the top bar before pressing Start. Options: gateway (3), DNS (6), domain name (15), NTP (42), vendor-specific (43, hex), TFTP/provisioning server (66), Cisco TFTP servers (150) and any other option as text, IP addresses or hex. Profiles can be duplicated, deleted, exported to a file and imported on another laptop.
+- **Presets** built from each manufacturer's own documentation, with links to the pages they come from:
+ - Default (192.168.100.x), for the gear that asks for DHCP out of the box: Q-SYS, Biamp, Shure, Sennheiser, Dante, Symetrix, Crestron, AMX, Kramer, Atlona and Extron NAV.
+ - 192.168.254.x for Extron switchers (192.168.254.254) and IPCP Pro (192.168.254.250).
+ - 192.168.0.x for Lightware, Christie GS, Sharp/NEC MultiSync M and Bose ESP/PowerMatch/CC-64.
+ - 192.168.1.x for the Kramer and Atlona fallback addresses, ClickShare direct connection and AMX NX static.
+ - Cisco IP phones (option 150), Poly phones (option 66), and a bench test with 2-minute leases.
+
+ Each preset's pool leaves the documented factory addresses free. Presets can't be changed: duplicate one to make your own.
+
+**Default profile:** server IP `192.168.100.1/24`, addresses `192.168.100.100` to `192.168.100.199`, 60-minute leases. Make your own profile under **Profiles** (the gear button) to change any of these.
## Download and run
@@ -51,8 +65,8 @@ Most AV kit ships set to DHCP, and on a bench or a fresh site there's usually no
| Red banner: another DHCP server is on the link | You're plugged into a network that already hands out addresses (office, venue, home router). Unplug from it, or only use **Serve anyway** on a network you own. |
| Step 2 red, "No link" | No cable, a dead port, or the device is off. Steps 2 to 4 recover by themselves when the link comes up. |
| Step 5 amber, no DHCP requests after 30 seconds | The device probably has a static IP or DHCP turned off. |
-| The device gets an offer but never accepts it | Some embedded network stacks ignore their own broadcast flag. Tick **Always broadcast replies** in Settings and try again. |
-| Step 1 amber, mentions Wi-Fi or VPN | Another adapter is on the same subnet. Use **Turn it off until Stop**, or change the server IP in Settings. |
+| The device gets an offer but never accepts it | Some embedded network stacks ignore their own broadcast flag. Tick **Always broadcast replies** in your profile and try again. |
+| Step 1 amber, mentions Wi-Fi or VPN | Another adapter is on the same subnet. Use **Turn it off until Stop**, or use a profile with a different server IP. |
| The app was killed mid-session | Open it again and click **Restore now** on the banner. The backup lives in `C:\ProgramData\ProDHCPServer\adapter-backup.json` until it's restored. |
If something goes wrong, [open an issue](https://github.com/cookingwithsimon/ProDHCPServer/issues/new/choose) using the **Problem on site** form, and include the session log text if you have it.
@@ -61,15 +75,16 @@ If something goes wrong, [open an issue](https://github.com/cookingwithsimon/Pro
Each phase ends with a hardware test pass on real AV equipment before the next one starts.
-- **Phase 1: core loop** (in progress). Everything listed under Features.
-- **Phase 2: visibility.** Continuous ping with a latency graph per device, a full session log you can filter and export (TXT/CSV), and saved profiles for subnet, pool, lease time and DHCP options (router, DNS, NTP, option 43/66/150 and custom options).
+- **Phase 1: core loop** (done, hardware-tested October 2026).
+- **Phase 2: visibility** (in progress). Continuous ping with a latency graph per device, a full session log you can filter and export (TXT/CSV), and saved profiles for subnet, pool, lease time and DHCP options (router, DNS, NTP, option 43/66/150 and custom options).
- **Phase 3: discovery.** Find devices that already have a static IP, move your adapter onto their subnet in one click, and discover devices via mDNS (including Dante), SSDP/UPnP and LLDP/CDP.
## Files it writes
| What | Where |
|---|---|
-| Your settings | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
+| Your settings (last adapter, profile in use, theme) | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
+| Your profiles | `%LOCALAPPDATA%\ProDHCPServer\profiles.json` |
| Adapter backup (only while a session runs, or after a crash) | `%ProgramData%\ProDHCPServer\adapter-backup.json` |
While serving, the app also adds a Windows Firewall rule so it can receive DHCP traffic (UDP 67 and 68, for this exe only), and removes the rule on Stop.
diff --git a/docs/hardware-test-phase2.md b/docs/hardware-test-phase2.md
new file mode 100644
index 0000000..80ada6e
--- /dev/null
+++ b/docs/hardware-test-phase2.md
@@ -0,0 +1,38 @@
+# Phase 2 hardware test
+
+About 20 minutes. You need: the unmanaged laptop, two AV devices on a small unmanaged switch (the NAV E and Desk Pro are ideal), and a USB stick or a second laptop for the profile import step if you have one.
+
+Download `ProDHCPServer.exe` from the latest successful **Build and test** run on the Phase 2 pull request (Actions tab → run → Artifacts → `ProDHCPServer-win-x64`). Click **More info → Run anyway** on the SmartScreen warning.
+
+Tick each box. If something fails, use **Export log…** (section 2) and send me the file.
+
+## 1. Ping graph
+- [ ] Both devices on the switch. Press **Start**. Once each has a lease, its row under **All devices** shows a small graph and a reply time like "1 ms" within a few seconds.
+- [ ] The device shown at the top has a **Ping** line ("Last minute: … avg (… to …), 0% loss") and a wider graph under its details.
+- [ ] Unplug one device's cable for about 10 s, then plug it back in. Its graph shows red marks for the missed pings, its reply time turns red ("No reply"), and the session log has two lines: "… stopped answering ping at …" and "… is answering ping again after … without replies".
+- [ ] The other device's graph stays steady the whole time.
+- [ ] Click the other device in the list: the top panel, ping line and big graph switch to it.
+- [ ] Press **Stop**: the graphs and rows go away.
+
+## 2. Session log filter and export
+- [ ] Open the **Session log**. Set the first box to **Problems only**: only red lines are left, and the count says "N of M entries match".
+- [ ] Set it back to **Everything**, pick **DHCP** in the second box: only DHCP lines are left.
+- [ ] Type the NAV's IP (or part of its MAC) in the search box: only lines mentioning it are left. **Show everything** clears all three.
+- [ ] **Export log…** as **Text**. Open the file in Notepad: it starts with the app version, laptop name, adapter (with driver), profile and start time, then one line per entry with the full date and time.
+- [ ] **Export log…** as **CSV**. Open it in Excel: columns Time, Severity, Category, Message, Detail, one row per entry, nothing garbled.
+
+## 3. Profiles
+- [ ] The top bar has a **Profile** box showing "Default (preset)". Open it: seven presets are listed.
+- [ ] Pick **192.168.254.x: Extron**. The status line says Start will set 192.168.254.1/24. Press **Start**: the devices get 192.168.254.100 and up. Press **Stop**.
+- [ ] Press the gear button. The panel is titled **Profiles**, shows the preset's note and links to the Extron guides it comes from (click one: it opens in your browser), and its fields are greyed out with "This is a preset… Press Duplicate".
+- [ ] Press **Duplicate**: "192.168.254.x: Extron copy" is created and selected, and the fields can now be changed. Rename it (for example "Bench rack") and set the pool to .150 to .160. Press **Save profile**.
+- [ ] Type a bad value (pool end 192.168.1.5) and click into another field: a red box explains the problem. **Discard changes** puts the saved values back.
+- [ ] Open **More DHCP options** and set NTP to 192.168.254.1. **Add an option** with code 66 and click into another field: a red box says option 66 has its own field. Change the code to 160, type Text, value `http://192.168.254.1/`. Save.
+- [ ] Start with this profile. In the log, select the ACK line for a device: the detail shows options 42 and 160 were sent. Stop.
+- [ ] **Export…** the profile to the desktop. **Delete** it: the confirmation names the profile. It's gone and Default is selected.
+- [ ] **Import…** the file you exported: the profile is back with the same settings. (If you have a second laptop, import it there too.)
+- [ ] Close and reopen the app: your profile is still there and still selected.
+- [ ] While the server is running, the top-bar profile box and the panel's fields are greyed out, and the panel says to stop the server to change profiles. The theme can still be changed.
+
+## 4. Phase 1 still works
+- [ ] Quick repeat of the Phase 1 main case: one device straight on the cable, Start, lease, ping and WebUI green, **Open WebUI**, Stop, adapter back to its original settings.
diff --git a/src/ProDHCPServer.App/App.xaml.cs b/src/ProDHCPServer.App/App.xaml.cs
index fa5db08..a6d0f7d 100644
--- a/src/ProDHCPServer.App/App.xaml.cs
+++ b/src/ProDHCPServer.App/App.xaml.cs
@@ -5,6 +5,7 @@
using ProDHCPServer.App.ViewModels;
using ProDHCPServer.Core;
using ProDHCPServer.Core.Adapters;
+using ProDHCPServer.Core.Profiles;
using ProDHCPServer.Windows;
namespace ProDHCPServer.App;
@@ -24,7 +25,8 @@ public partial class App : Application
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
- var settingsStore = new SettingsStore(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ProDHCPServer", "settings.json"));
+ var userFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ProDHCPServer");
+ var settingsStore = new SettingsStore(Path.Combine(userFolder, "settings.json"));
Theme.Apply(settingsStore.Load().Theme);
if (!Elevation.IsAdministrator())
@@ -63,11 +65,13 @@ protected override void OnStartup(StartupEventArgs e)
};
SessionEnding += (_, _) => _controller.EmergencyRestore();
- var vm = new MainViewModel(adapters, sessions, _controller, _log, settingsStore, Dispatcher);
+ var profiles = new ProfileLibrary(Path.Combine(userFolder, "profiles.json"));
+ var vm = new MainViewModel(adapters, sessions, _controller, _log, settingsStore, profiles, Dispatcher);
_controller.DhcpEvent += vm.OnDhcpEvent;
// Used by tools/smoke-test.ps1 to screenshot the settings slide-over.
if (e.Args.Contains("--open-settings")) vm.SettingsOpen = true;
var window = new MainWindow { DataContext = vm };
+ vm.Settings.AttachDialogs(new WindowDialogs(window));
window.Closed += (_, _) => Shutdown();
MainWindow = window;
window.Show();
diff --git a/src/ProDHCPServer.App/MainWindow.xaml b/src/ProDHCPServer.App/MainWindow.xaml
index a0fb4b2..8aeeb11 100644
--- a/src/ProDHCPServer.App/MainWindow.xaml
+++ b/src/ProDHCPServer.App/MainWindow.xaml
@@ -6,6 +6,7 @@
xmlns:local="clr-namespace:ProDHCPServer.App"
xmlns:vm="clr-namespace:ProDHCPServer.App.ViewModels"
xmlns:status="clr-namespace:ProDHCPServer.Core.Status;assembly=ProDHCPServer.Core"
+ xmlns:core="clr-namespace:ProDHCPServer.Core.Profiles;assembly=ProDHCPServer.Core"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"
Title="ProDHCPServer" Width="1320" Height="860" MinWidth="900" MinHeight="600"
@@ -38,7 +39,7 @@
-
+
@@ -46,6 +47,8 @@
+
+
-
+
@@ -319,7 +328,15 @@
Visibility="{Binding VendorClass, Converter={StaticResource VisibleWhen}}" />
+
+
+
+
@@ -362,7 +379,7 @@
-
+
@@ -373,7 +390,14 @@
-
+
+
+
+
@@ -395,13 +419,43 @@
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -420,7 +474,7 @@
-
@@ -432,39 +486,89 @@
+ AutomationProperties.Name="Profiles and settings">
-
+ ToolTip="Close (Esc)" AutomationProperties.Name="Close profiles and settings" />
-
+
-
-
+
+
-
+ Visibility="{Binding IsLocked, Converter={StaticResource VisibleWhen}}">
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -472,7 +576,7 @@
-
+
+ Command="{Binding SuggestPoolCommand}" />
-
+
@@ -496,19 +600,72 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProDHCPServer.App/MainWindow.xaml.cs b/src/ProDHCPServer.App/MainWindow.xaml.cs
index cdacd86..2b33f55 100644
--- a/src/ProDHCPServer.App/MainWindow.xaml.cs
+++ b/src/ProDHCPServer.App/MainWindow.xaml.cs
@@ -76,7 +76,7 @@ private void OnSettingsOpened()
_focusBeforeSettings = Keyboard.FocusedElement;
Dispatcher.BeginInvoke(DispatcherPriority.Input, () =>
{
- if (ServerIpBox.IsEnabled) ServerIpBox.Focus();
+ if (SettingsProfileBox.IsEnabled) SettingsProfileBox.Focus();
else CloseSettingsButton.Focus();
});
if (!SystemParameters.ClientAreaAnimation) return;
diff --git a/src/ProDHCPServer.App/Sparkline.cs b/src/ProDHCPServer.App/Sparkline.cs
new file mode 100644
index 0000000..e7444f1
--- /dev/null
+++ b/src/ProDHCPServer.App/Sparkline.cs
@@ -0,0 +1,88 @@
+using System.Windows;
+using System.Windows.Media;
+using ProDHCPServer.Core.Status;
+
+namespace ProDHCPServer.App;
+
+///
+/// A small latency graph: one point per ping, newest on the right. A missed ping draws a full-height mark in
+/// the loss colour and breaks the line, so drop-outs stand out without reading numbers.
+///
+public sealed class Sparkline : FrameworkElement
+{
+ public static readonly DependencyProperty SamplesProperty = DependencyProperty.Register(
+ nameof(Samples), typeof(IReadOnlyList), typeof(Sparkline),
+ new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty SlotsProperty = DependencyProperty.Register(
+ nameof(Slots), typeof(int), typeof(Sparkline), new FrameworkPropertyMetadata(60, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty LineBrushProperty = DependencyProperty.Register(
+ nameof(LineBrush), typeof(Brush), typeof(Sparkline), new FrameworkPropertyMetadata(Brushes.Gray, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty LossBrushProperty = DependencyProperty.Register(
+ nameof(LossBrush), typeof(Brush), typeof(Sparkline), new FrameworkPropertyMetadata(Brushes.Red, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty BaselineBrushProperty = DependencyProperty.Register(
+ nameof(BaselineBrush), typeof(Brush), typeof(Sparkline), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public IReadOnlyList? Samples { get => (IReadOnlyList?)GetValue(SamplesProperty); set => SetValue(SamplesProperty, value); }
+ /// How many pings fit across the width (one per second, so 60 = the last minute).
+ public int Slots { get => (int)GetValue(SlotsProperty); set => SetValue(SlotsProperty, value); }
+ public Brush LineBrush { get => (Brush)GetValue(LineBrushProperty); set => SetValue(LineBrushProperty, value); }
+ public Brush LossBrush { get => (Brush)GetValue(LossBrushProperty); set => SetValue(LossBrushProperty, value); }
+ public Brush? BaselineBrush { get => (Brush?)GetValue(BaselineBrushProperty); set => SetValue(BaselineBrushProperty, value); }
+
+ protected override void OnRender(DrawingContext dc)
+ {
+ double w = ActualWidth, h = ActualHeight;
+ if (w <= 0 || h <= 0) return;
+ if (BaselineBrush is { } baseline) dc.DrawRectangle(baseline, null, new Rect(0, h - 1, w, 1));
+
+ var samples = Samples;
+ if (samples is null || samples.Count == 0) return;
+ int slots = Math.Max(2, Slots);
+ var shown = samples.Skip(Math.Max(0, samples.Count - slots)).ToList();
+ double step = w / (slots - 1);
+ double x0 = w - (shown.Count - 1) * step;
+
+ // Scale to the slowest reply shown, but never below 10 ms so a quiet 1–2 ms link stays flat.
+ double max = Math.Max(10, shown.Where(s => s.Success).Select(s => s.RoundTripMs ?? 0).DefaultIfEmpty(0).Max());
+ double pad = 2;
+ double Y(double ms) => h - pad - (h - 2 * pad) * Math.Min(ms, max) / max;
+
+ var pen = new Pen(LineBrush, 1.5) { LineJoin = PenLineJoin.Round, StartLineCap = PenLineCap.Round, EndLineCap = PenLineCap.Round };
+ pen.Freeze();
+ double lossWidth = Math.Clamp(step * 0.6, 1.5, 4);
+ var geometry = new StreamGeometry();
+ using (var ctx = geometry.Open())
+ {
+ bool open = false;
+ for (int i = 0; i < shown.Count; i++)
+ {
+ double x = x0 + i * step;
+ var s = shown[i];
+ if (!s.Success || s.RoundTripMs is not { } ms)
+ {
+ dc.DrawRectangle(LossBrush, null, new Rect(x - lossWidth / 2, 0, lossWidth, h));
+ open = false;
+ continue;
+ }
+ var p = new Point(x, Y(ms));
+ if (!open) { ctx.BeginFigure(p, false, false); open = true; }
+ else ctx.LineTo(p, true, true);
+ }
+ }
+ geometry.Freeze();
+ dc.DrawGeometry(null, pen, geometry);
+
+ // A lone reply between misses has no line segment; give it a dot.
+ for (int i = 0; i < shown.Count; i++)
+ {
+ if (!shown[i].Success || shown[i].RoundTripMs is not { } ms) continue;
+ bool before = i > 0 && shown[i - 1].Success;
+ bool after = i < shown.Count - 1 && shown[i + 1].Success;
+ if (!before && !after) dc.DrawEllipse(LineBrush, null, new Point(x0 + i * step, Y(ms)), 1.5, 1.5);
+ }
+ }
+}
diff --git a/src/ProDHCPServer.App/ViewModels/ItemViewModels.cs b/src/ProDHCPServer.App/ViewModels/ItemViewModels.cs
index 84fc3ac..60db62d 100644
--- a/src/ProDHCPServer.App/ViewModels/ItemViewModels.cs
+++ b/src/ProDHCPServer.App/ViewModels/ItemViewModels.cs
@@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using ProDHCPServer.Core;
using ProDHCPServer.Core.Adapters;
+using ProDHCPServer.Core.Devices;
using ProDHCPServer.Core.Dhcp;
using ProDHCPServer.Core.Net;
using ProDHCPServer.Core.Status;
@@ -73,6 +74,33 @@ public sealed partial class DeviceViewModel : ObservableObject
[ObservableProperty] private bool _isFocused;
[ObservableProperty] private string _title = "";
[ObservableProperty] private StepState _stateColour;
+ [ObservableProperty] private IReadOnlyList? _pingSamples;
+ /// Short, for the device list: "3 ms" or "No reply".
+ [ObservableProperty] private string _pingText = "";
+ /// For the focused device: the last minute's latency and loss.
+ [ObservableProperty] private string _pingSummary = "";
+ [ObservableProperty] private StepState _pingState;
+
+ public void UpdatePing(PingHistory? history, DateTimeOffset now)
+ {
+ if (history?.Last is not { } last)
+ {
+ PingSamples = null;
+ PingText = LeaseState == LeaseState.Bound ? "Pinging…" : "";
+ PingSummary = PingText;
+ PingState = StepState.Idle;
+ return;
+ }
+ PingSamples = history.Snapshot();
+ var minute = history.Stats(now, TimeSpan.FromMinutes(1));
+ PingText = last.Success && last.RoundTripMs is { } ms
+ ? minute.Lost > 0 ? $"{PingStats.Ms(ms)}, {minute.LossPercent:0}% loss" : PingStats.Ms(ms)
+ : "No reply";
+ PingSummary = $"Last minute: {minute.Describe()}";
+ PingState = last.Success
+ ? minute.Lost > 0 ? StepState.Warning : StepState.Good
+ : history.ConsecutiveFailures >= PingHistory.DownAfter ? StepState.Bad : StepState.Warning;
+ }
public void Update(Lease lease, DateTimeOffset now)
{
@@ -115,7 +143,7 @@ public sealed class LogItemViewModel(LogEntry entry)
public LogEntry Entry { get; } = entry;
public string Time => Entry.Time.ToString("HH:mm:ss");
public LogSeverity Severity => Entry.Severity;
- public string Category => Entry.Category == LogCategory.Dhcp ? "DHCP" : Entry.Category.ToString();
+ public string Category => LogExport.CategoryName(Entry.Category);
public string Message => Entry.Message;
public string? Detail => Entry.Detail;
public bool HasDetail => !string.IsNullOrEmpty(Entry.Detail);
diff --git a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
index 95c8fd5..fbeaf71 100644
--- a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
+++ b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
+using System.IO;
+using System.Text;
using System.Net;
using System.Windows;
using System.Windows.Threading;
@@ -9,6 +11,7 @@
using ProDHCPServer.Core.Adapters;
using ProDHCPServer.Core.Dhcp;
using ProDHCPServer.Core.Net;
+using ProDHCPServer.Core.Profiles;
using ProDHCPServer.Core.Status;
using ProDHCPServer.Windows;
@@ -27,10 +30,11 @@ public sealed partial class MainViewModel : ObservableObject, IAsyncDisposable
private bool _refreshing;
private IReadOnlyList _allAdapters = [];
private DeviceProber? _prober;
+ private readonly DeviceMonitor _monitor;
private MacAddress? _userFocus;
public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, ServerController controller,
- SessionLog log, SettingsStore settingsStore, Dispatcher dispatcher)
+ SessionLog log, SettingsStore settingsStore, ProfileLibrary profiles, Dispatcher dispatcher)
{
_adapters = adapters;
_sessions = sessions;
@@ -39,16 +43,38 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
_dispatcher = dispatcher;
Log = log;
_settings = settingsStore.Load();
- Settings = new SettingsViewModel(_settings);
+ _monitor = new DeviceMonitor(log);
+ if (profiles.Load(_settings) is { } profileProblem) log.Add(LogSeverity.Warning, LogCategory.App, profileProblem);
+ Settings = new SettingsViewModel(profiles, _settings.ActiveProfile, _settings.Theme);
+ Settings.ActiveProfileChanged += profile =>
+ {
+ if (_settings.ActiveProfile != profile.Name)
+ {
+ _settings = _settings with { ActiveProfile = profile.Name };
+ SaveSettings();
+ }
+ UpdateStatus();
+ };
+ Settings.ThemeChanged += theme =>
+ {
+ _settings = _settings with { Theme = theme };
+ SaveSettings();
+ ApplyTheme();
+ };
Steps = new ObservableCollection(Enum.GetValues().Select(id => new StepViewModel(id)));
- foreach (var entry in log.Snapshot().TakeLast(MaxLogRows)) LogRows.Add(new LogItemViewModel(entry));
- if (LogRows.LastOrDefault() is { } last) LatestLogText = $"{last.Entry.Time:HH:mm:ss} {last.Message}";
+ RebuildLog();
+ if (log.Snapshot().LastOrDefault() is { } last) LatestLogText = $"{last.Time:HH:mm:ss} {last.Message}";
log.Added += entry => _dispatcher.BeginInvoke(() =>
{
- LogRows.Add(new LogItemViewModel(entry));
LatestLogText = $"{entry.Time:HH:mm:ss} {entry.Message}";
- while (LogRows.Count > MaxLogRows) LogRows.RemoveAt(0);
+ _logTotal++;
+ if (CurrentLogFilter.Matches(entry))
+ {
+ LogRows.Add(new LogItemViewModel(entry));
+ while (LogRows.Count > MaxLogRows) LogRows.RemoveAt(0);
+ }
+ UpdateLogCount();
});
PendingRecovery = SafeFindRecovery();
@@ -75,6 +101,98 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
[NotifyCanExecuteChangedFor(nameof(StartStopCommand))]
private bool _isRunning;
+ partial void OnIsRunningChanged(bool value) => Settings.IsLocked = value;
+
+ // ---------- session log filter ----------
+
+ public static IReadOnlyList LogLevels { get; } =
+ [
+ new(LogLevelFilter.All, "Everything"),
+ new(LogLevelFilter.WarningsAndErrors, "Warnings and problems"),
+ new(LogLevelFilter.ErrorsOnly, "Problems only"),
+ ];
+
+ public static IReadOnlyList LogAreas { get; } =
+ [new(null, "All areas"), .. Enum.GetValues().Select(c => new LogAreaOption(c, LogExport.CategoryName(c)))];
+
+ [ObservableProperty] private LogLevelOption _logLevel = LogLevels[0];
+ [ObservableProperty] private LogAreaOption _logArea = LogAreas[0];
+ [ObservableProperty] private string _logSearch = "";
+ [ObservableProperty] private string _logCountText = "";
+ private int _logTotal;
+
+ partial void OnLogLevelChanged(LogLevelOption value) => RebuildLog();
+ partial void OnLogAreaChanged(LogAreaOption value) => RebuildLog();
+ partial void OnLogSearchChanged(string value) => RebuildLog();
+
+ private LogFilter CurrentLogFilter => new(LogLevel.Level, LogArea.Category, LogSearch);
+
+ private void RebuildLog()
+ {
+ var all = Log.Snapshot();
+ _logTotal = all.Count;
+ var filter = CurrentLogFilter;
+ LogRows.Clear();
+ foreach (var entry in all.Where(filter.Matches).TakeLast(MaxLogRows)) LogRows.Add(new LogItemViewModel(entry));
+ UpdateLogCount();
+ }
+
+ private void UpdateLogCount()
+ {
+ if (CurrentLogFilter.IsEmpty) { LogCountText = _logTotal == 1 ? "1 entry" : $"{_logTotal} entries"; return; }
+ var shown = Log.Snapshot().Count(CurrentLogFilter.Matches);
+ LogCountText = $"{shown} of {_logTotal} entries match";
+ }
+
+ [RelayCommand]
+ private void ClearLogFilter()
+ {
+ LogLevel = LogLevels[0];
+ LogArea = LogAreas[0];
+ LogSearch = "";
+ }
+
+ /// Saves the entries the filter shows (all of them, not just the last few hundred on screen) as TXT or CSV.
+ [RelayCommand]
+ private void ExportLog()
+ {
+ var now = DateTimeOffset.Now;
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "Export session log",
+ Filter = "Text for reading or a job report (*.txt)|*.txt|CSV for Excel (*.csv)|*.csv",
+ FileName = LogExport.SuggestedFileName(now, "txt"),
+ AddExtension = true,
+ };
+ if (dialog.ShowDialog(Application.Current.MainWindow) != true) return;
+ var filter = CurrentLogFilter;
+ var entries = Log.Snapshot().Where(filter.Matches).ToList();
+ bool csv = dialog.FilterIndex == 2 || dialog.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase);
+ try
+ {
+ // Excel only reads UTF-8 CSV correctly with a byte order mark.
+ if (csv) File.WriteAllText(dialog.FileName, LogExport.ToCsv(entries), new UTF8Encoding(true));
+ else File.WriteAllText(dialog.FileName, LogExport.ToText(entries, ExportHeader(filter), now), new UTF8Encoding(false));
+ Log.Add(LogSeverity.Info, LogCategory.App, $"Exported {entries.Count} log entries to {dialog.FileName}.");
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ ShowError($"Couldn't save the log to {dialog.FileName}: {ex.Message}", ex.ToString());
+ }
+ }
+
+ private IEnumerable ExportHeader(LogFilter filter)
+ {
+ yield return $"ProDHCPServer {App.Version} on {Environment.MachineName} (Windows {Environment.OSVersion.Version})";
+ if (SelectedAdapter?.Info is { } a)
+ yield return $"Adapter: {a.Name} ({a.Description}){(a.Driver is { } d ? $", driver {d}" : "")}";
+ var p = Settings.ActiveProfile;
+ yield return $"Profile: {p.Name} ({p.Summary}, lease {p.LeaseMinutes} min)";
+ if (_controller.StartedAt is { } started) yield return $"Server started {started:yyyy-MM-dd HH:mm:ss}{(_controller.IsActive ? ", still running" : "")}";
+ if (!filter.IsEmpty)
+ yield return $"Filter: {LogLevel.Label}, {LogArea.Label}{(string.IsNullOrWhiteSpace(LogSearch) ? "" : $", containing \"{LogSearch.Trim()}\"")}";
+ }
+
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StartStopText))]
[NotifyCanExecuteChangedFor(nameof(StartStopCommand))]
@@ -201,6 +319,8 @@ private void UpdateDevices()
foreach (var gone in Devices.Where(d => leases.All(l => l.Mac != d.Mac)).ToList()) Devices.Remove(gone);
var bound = leases.Where(l => l.State == LeaseState.Bound && l.Expires > now).ToList();
+ _monitor.Sync(bound.Select(l => new MonitoredDevice(l.Mac, l.Ip, Devices.First(d => d.Mac == l.Mac).Title)).ToList());
+ foreach (var d in Devices) d.UpdatePing(bound.Any(l => l.Mac == d.Mac) ? _monitor.History(d.Mac) : null, now);
var focusLease = (_userFocus is { } f ? bound.FirstOrDefault(l => l.Mac == f) : null) ?? bound.MaxBy(l => l.BoundAt);
var focusVm = focusLease is null ? null : Devices.First(d => d.Mac == focusLease.Mac);
foreach (var d in Devices) d.IsFocused = d == focusVm;
@@ -233,7 +353,7 @@ private void UpdateDevices()
private void UpdateStatus()
{
var adapter = SelectedAdapter?.Info;
- var (config, _) = _settings.ToConfig();
+ var (config, _) = Settings.BuildActive();
config = _controller.Config ?? config ?? DhcpServerConfig.Default;
var engine = _controller.Engine;
var conflicts = adapter is null ? [] : AdapterRules.OverlappingAdapters(_allAdapters, adapter.Id, config.ServerIp, config.PrefixLength);
@@ -260,8 +380,8 @@ private void UpdateStatus()
Traffic = DhcpTraffic(),
Leases = leases,
FocusLease = focus,
- Ping = _prober?.LastPing,
- ConsecutivePingFailures = _prober?.ConsecutivePingFailures ?? 0,
+ Ping = FocusedPing?.Last,
+ ConsecutivePingFailures = FocusedPing?.ConsecutiveFailures ?? 0,
WebUi = _prober?.LastWebUi,
ConflictingAdapters = conflicts,
ConnectedVpns = vpns,
@@ -281,7 +401,7 @@ private void UpdateStatus()
{
HeadlineState = StepState.Idle;
HeadlineText = adapter is null ? "Pick an adapter to begin" : $"Ready to serve DHCP on {adapter.Name}";
- HeadlineAction = $"Press Start to set {config.ServerIp}/{config.PrefixLength} and hand out {config.PoolStart} to {config.PoolEnd}.";
+ HeadlineAction = $"Press Start to set {config.ServerIp}/{config.PrefixLength} and hand out {config.PoolStart} to {config.PoolEnd} (profile \"{Settings.ActiveProfile.Name}\").";
}
else
{
@@ -307,6 +427,8 @@ private void UpdateStatus()
}
}
+ private Core.Devices.PingHistory? FocusedPing => FocusedDevice is { } d ? _monitor.History(d.Mac) : null;
+
private IReadOnlyList _trafficCache = [];
private int _trafficCount = -1;
@@ -356,15 +478,20 @@ private async Task StartAsync()
ShowError("Pick the adapter your cable is plugged into first.");
return;
}
- var config = Settings.TryBuild(_settings);
+ // Unsaved edits to the profile are saved first, the same as pressing Save.
+ if (!Settings.SaveIfDirty())
+ {
+ SettingsOpen = true;
+ ShowError($"The profile \"{Settings.ActiveProfile.Name}\" has changes that need fixing before starting.", Settings.Errors);
+ return;
+ }
+ var (config, problems) = Settings.BuildActive();
if (config is null)
{
SettingsOpen = true;
- ShowError("The server settings need fixing before starting.", Settings.Errors);
+ ShowError($"The profile \"{Settings.ActiveProfile.Name}\" needs fixing before starting.", string.Join(Environment.NewLine, problems));
return;
}
- _settings = Settings.ApplyTo(_settings);
- SaveSettings();
lock (_engineEvents) _engineEvents.Clear();
Devices.Clear();
@@ -373,6 +500,7 @@ private async Task StartAsync()
try
{
await Task.Run(() => _controller.Start(adapter, config));
+ Log.Add(LogSeverity.Info, LogCategory.Server, $"Using profile \"{Settings.ActiveProfile.Name}\" ({Settings.ActiveProfile.Summary}).");
IsRunning = true;
}
catch (AdapterOperationException ex)
@@ -410,6 +538,7 @@ public async Task StopAsync()
IsRunning = _controller.IsActive;
}
if (_prober is { } p) { _prober = null; await p.DisposeAsync(); }
+ await _monitor.ClearAsync();
await RefreshAsync();
}
@@ -528,29 +657,7 @@ private void CopyText(string? text)
/// Checks the settings form when a field loses focus, so problems show before Save rather than while typing.
[RelayCommand]
- private void ValidateSettings() => Settings.TryBuild(_settings);
-
- [RelayCommand]
- private void SaveSettingsForm()
- {
- if (Settings.TryBuild(_settings) is null) return;
- var themeChanged = Settings.Theme != _settings.Theme;
- _settings = Settings.ApplyTo(_settings);
- SaveSettings();
- if (themeChanged) ApplyTheme();
- SettingsOpen = false;
- UpdateStatus();
- }
-
- [RelayCommand]
- private void SuggestPool() => Settings.SuggestPool();
-
- [RelayCommand]
- private void ResetSettings()
- {
- Settings.Load(new AppSettings { LastAdapterId = _settings.LastAdapterId, Theme = _settings.Theme });
- Settings.Errors = "";
- }
+ private void ValidateSettings() => Settings.Validate();
[RelayCommand]
private void DismissError()
@@ -599,5 +706,16 @@ public async ValueTask DisposeAsync()
{
_timer.Stop();
if (_prober is { } p) await p.DisposeAsync();
+ await _monitor.DisposeAsync();
}
}
+
+public sealed record LogLevelOption(LogLevelFilter Level, string Label)
+{
+ public override string ToString() => Label;
+}
+
+public sealed record LogAreaOption(LogCategory? Category, string Label)
+{
+ public override string ToString() => Label;
+}
diff --git a/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs b/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs
index 8b8e3d7..0d1dcd8 100644
--- a/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs
+++ b/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs
@@ -1,20 +1,102 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
-using ProDHCPServer.Core;
+using CommunityToolkit.Mvvm.Input;
using ProDHCPServer.Core.Dhcp;
using ProDHCPServer.Core.Net;
+using ProDHCPServer.Core.Profiles;
namespace ProDHCPServer.App.ViewModels;
-/// The server settings form. Phase 2 turns this into saved profiles.
+/// Dialogs the profile editor needs; the window supplies them so the view model stays testable.
+public interface ISettingsDialogs
+{
+ bool Confirm(string title, string message, string confirmText);
+ string? OpenFile(string title, string filter);
+ string? SaveFile(string title, string filter, string fileName);
+ void OpenUrl(string url);
+}
+
+public sealed record ProfileItem(string Name, bool IsPreset)
+{
+ public string Display => IsPreset ? $"{Name} (preset)" : Name;
+ public override string ToString() => Display;
+}
+
+public sealed partial class CustomOptionRow : ObservableObject
+{
+ [ObservableProperty] private string _code = "";
+ [ObservableProperty] private CustomOptionFormat _format = CustomOptionFormat.Text;
+ [ObservableProperty] private string _value = "";
+}
+
+///
+/// The settings slide-over: pick a profile (which Start uses), edit your own profiles, and manage them
+/// (new, duplicate, delete, import, export). Presets are shown read-only.
+///
public sealed partial class SettingsViewModel : ObservableObject
{
- public SettingsViewModel(AppSettings s) => Load(s);
+ private static readonly string[] FormFields =
+ [
+ nameof(Name), nameof(Notes), nameof(ServerIp), nameof(Prefix), nameof(PoolStart), nameof(PoolEnd), nameof(LeaseMinutes),
+ nameof(Router), nameof(DnsServers), nameof(DomainName), nameof(NtpServers), nameof(Option66), nameof(Option150),
+ nameof(Option43), nameof(AlwaysBroadcast),
+ ];
+
+ private const string JsonFilter = "ProDHCPServer profiles (*.json)|*.json|All files (*.*)|*.*";
+
+ private readonly ProfileLibrary _library;
+ private ISettingsDialogs? _dialogs;
+ private bool _loading;
+
+ public SettingsViewModel(ProfileLibrary library, string? activeProfile, string theme)
+ {
+ _library = library;
+ _theme = theme;
+ ReloadList();
+ var active = _library.Find(activeProfile) ?? ProfilePresets.Default;
+ _selectedProfile = Profiles.First(p => p.Name == active.Name);
+ ActiveProfile = active;
+ LoadForm(active);
+ }
+
+ public void AttachDialogs(ISettingsDialogs dialogs) => _dialogs = dialogs;
public static IReadOnlyList Prefixes { get; } =
Enumerable.Range(8, 23).Reverse().Select(p => new PrefixOption(p)).ToList();
public static IReadOnlyList Themes { get; } = ["System", "Dark", "Light"];
+ public static IReadOnlyList Formats { get; } = Enum.GetValues();
+ public ObservableCollection Profiles { get; } = [];
+ public ObservableCollection CustomOptions { get; } = [];
+
+ /// The saved profile Start uses: the one picked in the list, without unsaved edits.
+ public Profile ActiveProfile { get; private set; }
+
+ /// Raised when a different profile is picked, or the active one is saved or renamed.
+ public event Action? ActiveProfileChanged;
+ public event Action? ThemeChanged;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPreset), nameof(CanEdit), nameof(CanDelete))]
+ [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(DeleteCommand), nameof(DiscardCommand))]
+ private ProfileItem _selectedProfile;
+
+ /// True while the server runs: the profile in use can't be switched or changed.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanEdit), nameof(CanDelete), nameof(CanManage))]
+ [NotifyCanExecuteChangedFor(nameof(SaveCommand), nameof(DeleteCommand), nameof(NewCommand), nameof(DuplicateCommand), nameof(ImportCommand), nameof(DiscardCommand))]
+ private bool _isLocked;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DiscardCommand))]
+ private bool _isDirty;
+
+ [ObservableProperty] private string _name = "";
+ [ObservableProperty] private string _notes = "";
[ObservableProperty] private string _serverIp = "";
[ObservableProperty] private PrefixOption _prefix = new(24);
[ObservableProperty] private string _poolStart = "";
@@ -22,60 +104,330 @@ public sealed partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _leaseMinutes = "60";
[ObservableProperty] private string _router = "";
[ObservableProperty] private string _dnsServers = "";
+ [ObservableProperty] private string _domainName = "";
+ [ObservableProperty] private string _ntpServers = "";
+ [ObservableProperty] private string _option66 = "";
+ [ObservableProperty] private string _option150 = "";
+ [ObservableProperty] private string _option43 = "";
[ObservableProperty] private bool _alwaysBroadcast;
- [ObservableProperty] private string _theme = "System";
+ [ObservableProperty] private string _theme;
[ObservableProperty] private string _errors = "";
+ /// A one-off message after import/export/delete.
+ [ObservableProperty] private string _notice = "";
+ /// Where the selected profile's values come from (the manufacturers' pages, for presets).
+ [ObservableProperty] private IReadOnlyList _sources = [];
+
+ public bool IsPreset => SelectedProfile.IsPreset;
+ public bool CanEdit => !IsPreset && !IsLocked;
+ public bool CanDelete => !IsPreset && !IsLocked;
+ public bool CanManage => !IsLocked;
+ public bool HasExtraOptions => !string.IsNullOrWhiteSpace(DomainName) || !string.IsNullOrWhiteSpace(NtpServers) || !string.IsNullOrWhiteSpace(Option66)
+ || !string.IsNullOrWhiteSpace(Option150) || !string.IsNullOrWhiteSpace(Option43) || CustomOptions.Count > 0;
+
+ protected override void OnPropertyChanged(PropertyChangedEventArgs e)
+ {
+ base.OnPropertyChanged(e);
+ if (_loading || e.PropertyName is null || !FormFields.Contains(e.PropertyName)) return;
+ // Any edit clears the error text straight away; it is checked again when the field loses focus or on Save.
+ Errors = "";
+ IsDirty = true;
+ }
+
+ partial void OnThemeChanged(string value) => ThemeChanged?.Invoke(value);
+
+ partial void OnSelectedProfileChanged(ProfileItem? oldValue, ProfileItem newValue)
+ {
+ if (_loading || newValue is null) return;
+ if (IsDirty && oldValue is not null && _dialogs is not null &&
+ !_dialogs.Confirm($"Discard your changes to \"{oldValue.Name}\"?",
+ "You changed this profile but didn't save it. Switching to another profile throws those changes away.",
+ "Discard changes"))
+ {
+ // Put the picker back after this change has finished.
+ System.Windows.Application.Current?.Dispatcher.BeginInvoke(() => { _loading = true; SelectedProfile = oldValue; _loading = false; });
+ return;
+ }
+ if (_library.Find(newValue.Name) is not { } profile) return;
+ Notice = "";
+ LoadForm(profile);
+ SetActive(profile);
+ }
+
+ private void SetActive(Profile profile)
+ {
+ ActiveProfile = profile;
+ ActiveProfileChanged?.Invoke(profile);
+ }
+
+ private void ReloadList(string? select = null)
+ {
+ _loading = true;
+ try
+ {
+ Profiles.Clear();
+ foreach (var p in _library.All) Profiles.Add(new ProfileItem(p.Name, p.IsPreset));
+ if (select is not null) SelectedProfile = Profiles.First(p => string.Equals(p.Name, select, StringComparison.OrdinalIgnoreCase));
+ }
+ finally { _loading = false; }
+ }
+
+ private void LoadForm(Profile p)
+ {
+ _loading = true;
+ try
+ {
+ Name = p.Name;
+ Notes = p.Notes ?? "";
+ ServerIp = p.ServerIp;
+ Prefix = Prefixes.FirstOrDefault(x => x.Length == p.PrefixLength) ?? new PrefixOption(24);
+ PoolStart = p.PoolStart;
+ PoolEnd = p.PoolEnd;
+ LeaseMinutes = p.LeaseMinutes.ToString();
+ Router = p.Router ?? "";
+ DnsServers = p.DnsServers ?? "";
+ DomainName = p.DomainName ?? "";
+ NtpServers = p.NtpServers ?? "";
+ Option66 = p.TftpServerName ?? "";
+ Option150 = p.TftpServers ?? "";
+ Option43 = p.VendorSpecificHex ?? "";
+ AlwaysBroadcast = p.AlwaysBroadcast;
+ Sources = p.Sources;
+ foreach (var row in CustomOptions) row.PropertyChanged -= OnRowChanged;
+ CustomOptions.Clear();
+ foreach (var c in p.CustomOptions) AddRow(new CustomOptionRow { Code = c.Code.ToString(), Format = c.Format, Value = c.Value });
+ Errors = "";
+ IsDirty = false;
+ OnPropertyChanged(nameof(HasExtraOptions));
+ }
+ finally { _loading = false; }
+ }
+
+ private void AddRow(CustomOptionRow row)
+ {
+ row.PropertyChanged += OnRowChanged;
+ CustomOptions.Add(row);
+ }
+
+ private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (_loading) return;
+ Errors = "";
+ IsDirty = true;
+ }
- // Any edit clears the error text straight away; it is checked again when the field loses focus or on Save.
- partial void OnServerIpChanged(string value) => Errors = "";
- partial void OnPrefixChanged(PrefixOption value) => Errors = "";
- partial void OnPoolStartChanged(string value) => Errors = "";
- partial void OnPoolEndChanged(string value) => Errors = "";
- partial void OnLeaseMinutesChanged(string value) => Errors = "";
- partial void OnRouterChanged(string value) => Errors = "";
- partial void OnDnsServersChanged(string value) => Errors = "";
-
- public void Load(AppSettings s)
- {
- ServerIp = s.ServerIp;
- Prefix = Prefixes.FirstOrDefault(p => p.Length == s.PrefixLength) ?? new PrefixOption(24);
- PoolStart = s.PoolStart;
- PoolEnd = s.PoolEnd;
- LeaseMinutes = s.LeaseMinutes.ToString();
- Router = s.Router ?? "";
- DnsServers = s.DnsServers ?? "";
- AlwaysBroadcast = s.AlwaysBroadcast;
- Theme = s.Theme;
- }
-
- public AppSettings ApplyTo(AppSettings s) => s with
- {
- ServerIp = ServerIp.Trim(),
- PrefixLength = Prefix.Length,
- PoolStart = PoolStart.Trim(),
- PoolEnd = PoolEnd.Trim(),
- LeaseMinutes = int.TryParse(LeaseMinutes, out var m) ? m : -1,
- Router = string.IsNullOrWhiteSpace(Router) ? null : Router.Trim(),
- DnsServers = string.IsNullOrWhiteSpace(DnsServers) ? null : DnsServers.Trim(),
- AlwaysBroadcast = AlwaysBroadcast,
- Theme = Theme,
- };
-
- /// Validates; on success returns the config and clears the error text.
- public DhcpServerConfig? TryBuild(AppSettings current)
- {
- if (!int.TryParse(LeaseMinutes, out var minutes) || minutes <= 0)
+ /// The form as a profile, or null with set when a number field isn't a number.
+ private Profile? FormProfile()
+ {
+ if (!int.TryParse(LeaseMinutes.Trim(), out var minutes) || minutes <= 0)
{
Errors = "Lease time must be a whole number of minutes.";
return null;
}
- var (config, errors) = ApplyTo(current).ToConfig();
- Errors = string.Join(Environment.NewLine, errors);
- return config;
+ var custom = new List();
+ foreach (var row in CustomOptions)
+ {
+ if (string.IsNullOrWhiteSpace(row.Code) && string.IsNullOrWhiteSpace(row.Value)) continue;
+ if (!byte.TryParse(row.Code.Trim(), out var code) || code is 0 or 255)
+ {
+ Errors = $"Option code \"{row.Code.Trim()}\" isn't valid. Use a number from 1 to 254.";
+ return null;
+ }
+ custom.Add(new CustomOption(code, row.Format, row.Value.Trim()));
+ }
+ return new Profile
+ {
+ Name = Name.Trim(),
+ Notes = Blank(Notes),
+ ServerIp = ServerIp.Trim(),
+ PrefixLength = Prefix.Length,
+ PoolStart = PoolStart.Trim(),
+ PoolEnd = PoolEnd.Trim(),
+ LeaseMinutes = minutes,
+ Router = Blank(Router),
+ DnsServers = Blank(DnsServers),
+ DomainName = Blank(DomainName),
+ NtpServers = Blank(NtpServers),
+ TftpServerName = Blank(Option66),
+ TftpServers = Blank(Option150),
+ VendorSpecificHex = Blank(Option43),
+ CustomOptions = custom,
+ AlwaysBroadcast = AlwaysBroadcast,
+ Sources = Sources,
+ };
+ }
+
+ private static string? Blank(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
+
+ /// Checks the form (when a field loses focus). Presets aren't checked: they can't be changed.
+ public void Validate()
+ {
+ if (!CanEdit) return;
+ if (FormProfile() is not { } p) return;
+ Errors = string.Join(Environment.NewLine, p.ToConfig().Errors);
+ }
+
+ /// Saves the form if it has changes. Returns false (with set) if it can't.
+ public bool SaveIfDirty() => !IsDirty || !CanEdit || TrySave();
+
+ private bool TrySave()
+ {
+ if (FormProfile() is not { } p) return false;
+ var (_, errors) = p.ToConfig();
+ if (errors.Count > 0)
+ {
+ Errors = string.Join(Environment.NewLine, errors);
+ return false;
+ }
+ try
+ {
+ var original = SelectedProfile.Name;
+ _library.Save(p, original);
+ ReloadList(p.Name);
+ LoadForm(_library.Find(p.Name)!);
+ SetActive(_library.Find(p.Name)!);
+ Notice = "";
+ return true;
+ }
+ catch (Exception ex) when (ex is ProfileException or IOException or UnauthorizedAccessException)
+ {
+ Errors = ex is ProfileException ? ex.Message : $"Couldn't save the profile: {ex.Message}";
+ return false;
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanEdit))]
+ private void Save() => TrySave();
+
+ private bool CanDiscard() => IsDirty && !IsLocked;
+
+ [RelayCommand(CanExecute = nameof(CanDiscard))]
+ private void Discard()
+ {
+ if (_library.Find(SelectedProfile.Name) is { } p) LoadForm(p);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanManage))]
+ private void New()
+ {
+ if (!ConfirmLeave()) return;
+ var p = ProfilePresets.Default with { Name = _library.UniqueName("New profile"), Notes = null, IsPreset = false };
+ Select(() => { _library.Save(p, null); return p.Name; });
+ }
+
+ [RelayCommand(CanExecute = nameof(CanManage))]
+ private void Duplicate()
+ {
+ if (!ConfirmLeave()) return;
+ if (_library.Find(SelectedProfile.Name) is not { } source) return;
+ Select(() => _library.Duplicate(source).Name);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanDelete))]
+ private void Delete()
+ {
+ var name = SelectedProfile.Name;
+ if (_dialogs is not null && !_dialogs.Confirm($"Delete the profile \"{name}\"?",
+ $"\"{name}\" will be removed from this laptop. Export it first if you might want it again.", $"Delete \"{name}\""))
+ return;
+ try
+ {
+ _library.Delete(name);
+ }
+ catch (Exception ex) when (ex is ProfileException or IOException or UnauthorizedAccessException)
+ {
+ Errors = ex.Message;
+ return;
+ }
+ ReloadList(ProfilePresets.DefaultName);
+ LoadForm(ProfilePresets.Default);
+ SetActive(ProfilePresets.Default);
+ Notice = $"Deleted \"{name}\". Now using the Default profile.";
+ }
+
+ [RelayCommand(CanExecute = nameof(CanManage))]
+ private void Import()
+ {
+ if (_dialogs?.OpenFile("Import profiles", JsonFilter) is not { } path) return;
+ if (!ConfirmLeave()) return;
+ string json;
+ try
+ {
+ if (new FileInfo(path).Length > ProfileLibrary.MaxImportBytes)
+ {
+ Notice = "That file is too big to be a ProDHCPServer profiles file.";
+ return;
+ }
+ json = File.ReadAllText(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Notice = $"Couldn't read {Path.GetFileName(path)}: {ex.Message}";
+ return;
+ }
+ ProfileImportResult result;
+ try { result = _library.Import(json); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Notice = $"Couldn't save the imported profiles: {ex.Message}";
+ return;
+ }
+ if (result.Added.Count > 0)
+ {
+ ReloadList(result.Added[0].Name);
+ LoadForm(result.Added[0]);
+ SetActive(result.Added[0]);
+ }
+ Notice = result.Describe();
+ }
+
+ [RelayCommand]
+ private void Export()
+ {
+ if (_library.Find(SelectedProfile.Name) is not { } p) return;
+ var fileName = string.Concat(p.Name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)) + ".json";
+ if (_dialogs?.SaveFile("Export profile", JsonFilter, fileName) is not { } path) return;
+ try
+ {
+ File.WriteAllText(path, ProfileLibrary.Export([p]), new UTF8Encoding(false));
+ Notice = IsDirty
+ ? $"Exported the saved version of \"{p.Name}\" to {Path.GetFileName(path)}. Your unsaved changes aren't in it."
+ : $"Exported \"{p.Name}\" to {Path.GetFileName(path)}.";
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Notice = $"Couldn't write {Path.GetFileName(path)}: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private void OpenSource(ProfileSource? source)
+ {
+ // Only web pages: a profile file someone sent could name anything here.
+ if (source is null || !Uri.TryCreate(source.Url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return;
+ _dialogs?.OpenUrl(uri.AbsoluteUri);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanEdit))]
+ private void AddCustomOption()
+ {
+ AddRow(new CustomOptionRow());
+ IsDirty = true;
+ OnPropertyChanged(nameof(HasExtraOptions));
+ }
+
+ [RelayCommand]
+ private void RemoveCustomOption(CustomOptionRow? row)
+ {
+ if (row is null || !CanEdit) return;
+ row.PropertyChanged -= OnRowChanged;
+ CustomOptions.Remove(row);
+ IsDirty = true;
+ Errors = "";
}
/// Fills the pool as .100–.199 (or the nearest fit) of the server's subnet.
- public void SuggestPool()
+ [RelayCommand(CanExecute = nameof(CanEdit))]
+ private void SuggestPool()
{
if (!IPv4.TryParse(ServerIp, out var server)) return;
uint net = IPv4.ToUInt32(IPv4.NetworkAddress(server, Prefix.Length));
@@ -85,6 +437,29 @@ public void SuggestPool()
PoolStart = IPv4.FromUInt32(net + start).ToString();
PoolEnd = IPv4.FromUInt32(net + end).ToString();
}
+
+ /// The config for Start: the active profile as saved.
+ public (DhcpServerConfig? Config, IReadOnlyList Errors) BuildActive() => ActiveProfile.ToConfig();
+
+ private bool ConfirmLeave() =>
+ !IsDirty || _dialogs is null || _dialogs.Confirm($"Discard your changes to \"{SelectedProfile.Name}\"?",
+ "You changed this profile but didn't save it. Carrying on throws those changes away.", "Discard changes");
+
+ private void Select(Func create)
+ {
+ string name;
+ try { name = create(); }
+ catch (Exception ex) when (ex is ProfileException or IOException or UnauthorizedAccessException)
+ {
+ Errors = ex.Message;
+ return;
+ }
+ ReloadList(name);
+ var p = _library.Find(name)!;
+ LoadForm(p);
+ SetActive(p);
+ Notice = "";
+ }
}
public sealed record PrefixOption(int Length)
diff --git a/src/ProDHCPServer.App/WindowDialogs.cs b/src/ProDHCPServer.App/WindowDialogs.cs
new file mode 100644
index 0000000..9067a3d
--- /dev/null
+++ b/src/ProDHCPServer.App/WindowDialogs.cs
@@ -0,0 +1,31 @@
+using System.Diagnostics;
+using System.Windows;
+using Microsoft.Win32;
+using ProDHCPServer.App.ViewModels;
+
+namespace ProDHCPServer.App;
+
+/// The real dialogs behind , owned by the main window.
+public sealed class WindowDialogs(Window owner) : ISettingsDialogs
+{
+ public bool Confirm(string title, string message, string confirmText) => ConfirmDialog.Show(owner, title, message, confirmText);
+
+ public string? OpenFile(string title, string filter)
+ {
+ var dialog = new OpenFileDialog { Title = title, Filter = filter, CheckFileExists = true };
+ return dialog.ShowDialog(owner) == true ? dialog.FileName : null;
+ }
+
+ public string? SaveFile(string title, string filter, string fileName)
+ {
+ var dialog = new SaveFileDialog { Title = title, Filter = filter, FileName = fileName, AddExtension = true, DefaultExt = ".json" };
+ return dialog.ShowDialog(owner) == true ? dialog.FileName : null;
+ }
+
+ /// Opens a web page through Explorer so the browser runs as the normal user, not elevated like this app.
+ public void OpenUrl(string url)
+ {
+ try { Process.Start(new ProcessStartInfo("explorer.exe", $"\"{url}\"") { UseShellExecute = false }); }
+ catch (System.ComponentModel.Win32Exception) { /* nothing useful to add: the link text is on screen */ }
+ }
+}
diff --git a/src/ProDHCPServer.Core/AppSettings.cs b/src/ProDHCPServer.Core/AppSettings.cs
index 3cdcbb2..e27bd3c 100644
--- a/src/ProDHCPServer.Core/AppSettings.cs
+++ b/src/ProDHCPServer.Core/AppSettings.cs
@@ -1,13 +1,16 @@
using System.Text.Json;
-using ProDHCPServer.Core.Dhcp;
-using ProDHCPServer.Core.Net;
namespace ProDHCPServer.Core;
-/// User preferences saved between runs. Phase 2 replaces the server fields with profiles.
+///
+/// User preferences saved between runs. The server fields are from Phase 1, before profiles: they are only
+/// read once, to turn them into a "My settings" profile (see ).
+///
public sealed record AppSettings
{
public string? LastAdapterId { get; init; }
+ /// Name of the profile Start uses.
+ public string? ActiveProfile { get; init; }
public string ServerIp { get; init; } = "192.168.100.1";
public int PrefixLength { get; init; } = 24;
public string PoolStart { get; init; } = "192.168.100.100";
@@ -18,42 +21,6 @@ public sealed record AppSettings
public bool AlwaysBroadcast { get; init; }
/// "System", "Dark" or "Light".
public string Theme { get; init; } = "System";
-
- /// Builds the server config, or returns plain-English problems.
- public (DhcpServerConfig? Config, IReadOnlyList Errors) ToConfig()
- {
- var errors = new List();
- if (!IPv4.TryParse(ServerIp, out var server)) errors.Add($"Server IP \"{ServerIp}\" isn't a valid IPv4 address.");
- if (!IPv4.TryParse(PoolStart, out var start)) errors.Add($"Pool start \"{PoolStart}\" isn't a valid IPv4 address.");
- if (!IPv4.TryParse(PoolEnd, out var end)) errors.Add($"Pool end \"{PoolEnd}\" isn't a valid IPv4 address.");
- System.Net.IPAddress? router = null;
- if (!string.IsNullOrWhiteSpace(Router))
- {
- if (IPv4.TryParse(Router, out var r)) router = r;
- else errors.Add($"Gateway \"{Router}\" isn't a valid IPv4 address.");
- }
- var dns = new List();
- foreach (var part in (DnsServers ?? "").Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries))
- {
- if (IPv4.TryParse(part, out var d)) dns.Add(d);
- else errors.Add($"DNS server \"{part}\" isn't a valid IPv4 address.");
- }
- if (errors.Count > 0) return (null, errors);
-
- var config = new DhcpServerConfig
- {
- ServerIp = server,
- PrefixLength = PrefixLength,
- PoolStart = start,
- PoolEnd = end,
- LeaseTime = TimeSpan.FromMinutes(LeaseMinutes),
- Router = router,
- DnsServers = dns,
- AlwaysBroadcast = AlwaysBroadcast,
- };
- var problems = config.Validate();
- return problems.Count > 0 ? (null, problems) : (config, []);
- }
}
public sealed class SettingsStore(string path)
diff --git a/src/ProDHCPServer.Core/Devices/PingHistory.cs b/src/ProDHCPServer.Core/Devices/PingHistory.cs
new file mode 100644
index 0000000..53fe432
--- /dev/null
+++ b/src/ProDHCPServer.Core/Devices/PingHistory.cs
@@ -0,0 +1,104 @@
+using ProDHCPServer.Core.Status;
+
+namespace ProDHCPServer.Core.Devices;
+
+/// Latency and loss over a window of pings.
+public sealed record PingStats(int Sent, int Lost, double? MinMs, double? AvgMs, double? MaxMs)
+{
+ public int Received => Sent - Lost;
+ public double LossPercent => Sent == 0 ? 0 : 100.0 * Lost / Sent;
+
+ /// Like "4 ms avg (1 to 12 ms), 0% loss" or "no replies".
+ public string Describe()
+ {
+ if (Sent == 0) return "No pings yet";
+ if (AvgMs is not { } avg) return $"No replies to {Sent} pings";
+ return $"{Ms(avg)} avg ({Ms(MinMs!.Value)} to {Ms(MaxMs!.Value)}), {LossPercent:0.#}% loss";
+ }
+
+ public static string Ms(double ms) => ms < 1 ? "<1 ms" : $"{ms:0} ms";
+}
+
+/// A device stopped answering, or started answering again after an outage.
+public sealed record PingTransition(bool Answering, DateTimeOffset At, TimeSpan? OutageLength);
+
+///
+/// The last few minutes of pings to one device, for the latency graph, the loss figure and the
+/// "stopped answering" log lines. Thread-safe: the pinger adds from a worker thread, the UI reads.
+///
+public sealed class PingHistory
+{
+ /// Misses in a row before a device that was answering counts as down.
+ public const int DownAfter = 3;
+
+ private readonly object _gate = new();
+ private readonly Queue _samples = new();
+ private readonly int _capacity;
+ private bool _answering;
+ private bool _everAnswered;
+ private DateTimeOffset? _downSince;
+
+ public PingHistory(int capacity = 300) => _capacity = capacity;
+
+ public ProbeResult? Last { get; private set; }
+ public int ConsecutiveFailures { get; private set; }
+
+ /// Records one ping. Returns a transition when the device goes down or comes back.
+ public PingTransition? Add(ProbeResult result)
+ {
+ lock (_gate)
+ {
+ _samples.Enqueue(result);
+ while (_samples.Count > _capacity) _samples.Dequeue();
+ Last = result;
+ if (result.Success)
+ {
+ ConsecutiveFailures = 0;
+ var wasDown = _everAnswered && !_answering;
+ var outage = wasDown && _downSince is { } since ? result.At - since : (TimeSpan?)null;
+ _answering = true;
+ _everAnswered = true;
+ _downSince = null;
+ return wasDown ? new PingTransition(true, result.At, outage) : null;
+ }
+
+ ConsecutiveFailures++;
+ if (ConsecutiveFailures == 1) _downSince = result.At;
+ if (_answering && ConsecutiveFailures == DownAfter)
+ {
+ _answering = false;
+ return new PingTransition(false, _downSince ?? result.At, null);
+ }
+ return null;
+ }
+ }
+
+ public IReadOnlyList Snapshot()
+ {
+ lock (_gate) return _samples.ToList();
+ }
+
+ /// Stats for the pings sent within of .
+ public PingStats Stats(DateTimeOffset now, TimeSpan window)
+ {
+ lock (_gate) return Compute(_samples.Where(s => now - s.At <= window));
+ }
+
+ public static PingStats Compute(IEnumerable samples)
+ {
+ int sent = 0, lost = 0;
+ double min = double.MaxValue, max = 0, sum = 0;
+ foreach (var s in samples)
+ {
+ sent++;
+ if (!s.Success || s.RoundTripMs is not { } ms) { lost++; continue; }
+ min = Math.Min(min, ms);
+ max = Math.Max(max, ms);
+ sum += ms;
+ }
+ int received = sent - lost;
+ return received == 0
+ ? new PingStats(sent, lost, null, null, null)
+ : new PingStats(sent, lost, min, sum / received, max);
+ }
+}
diff --git a/src/ProDHCPServer.Core/LogExport.cs b/src/ProDHCPServer.Core/LogExport.cs
new file mode 100644
index 0000000..f78ac9b
--- /dev/null
+++ b/src/ProDHCPServer.Core/LogExport.cs
@@ -0,0 +1,91 @@
+using System.Globalization;
+using System.Text;
+
+namespace ProDHCPServer.Core;
+
+public enum LogLevelFilter
+{
+ All,
+ WarningsAndErrors,
+ ErrorsOnly,
+}
+
+/// What the session log view shows. The default shows everything.
+public sealed record LogFilter(LogLevelFilter Level = LogLevelFilter.All, LogCategory? Category = null, string? Text = null)
+{
+ public bool IsEmpty => Level == LogLevelFilter.All && Category is null && string.IsNullOrWhiteSpace(Text);
+
+ public bool Matches(LogEntry entry)
+ {
+ if (Level == LogLevelFilter.WarningsAndErrors && entry.Severity is not (LogSeverity.Warning or LogSeverity.Error)) return false;
+ if (Level == LogLevelFilter.ErrorsOnly && entry.Severity != LogSeverity.Error) return false;
+ if (Category is { } c && entry.Category != c) return false;
+ if (string.IsNullOrWhiteSpace(Text)) return true;
+ var t = Text.Trim();
+ return entry.Message.Contains(t, StringComparison.OrdinalIgnoreCase)
+ || entry.Detail?.Contains(t, StringComparison.OrdinalIgnoreCase) == true;
+ }
+}
+
+/// Writes the session log as plain text (to read or paste into a job report) or CSV (for Excel).
+public static class LogExport
+{
+ private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
+
+ public static string CategoryName(LogCategory category) => category == LogCategory.Dhcp ? "DHCP" : category.ToString();
+
+ /// Lines describing the session (app version, adapter, profile), written at the top.
+ public static string ToText(IEnumerable entries, IEnumerable header, DateTimeOffset exportedAt)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("ProDHCPServer session log");
+ sb.AppendLine($"Exported {exportedAt.ToString(TimeFormat, CultureInfo.InvariantCulture)} (UTC{exportedAt:zzz})");
+ foreach (var line in header) sb.AppendLine(line);
+ sb.AppendLine();
+ foreach (var e in entries)
+ {
+ sb.Append(e.Time.ToString(TimeFormat, CultureInfo.InvariantCulture)).Append(" ")
+ .Append(e.Severity.ToString().PadRight(8)).Append(' ')
+ .Append(CategoryName(e.Category).PadRight(8)).Append(' ')
+ .AppendLine(OneLine(e.Message));
+ if (!string.IsNullOrWhiteSpace(e.Detail))
+ {
+ foreach (var line in e.Detail.ReplaceLineEndings("\n").Split('\n'))
+ sb.Append(' ', 4).AppendLine(line.TrimEnd());
+ }
+ }
+ return sb.ToString();
+ }
+
+ public static string ToCsv(IEnumerable entries)
+ {
+ var sb = new StringBuilder();
+ sb.Append("Time,Severity,Category,Message,Detail\r\n");
+ foreach (var e in entries)
+ {
+ sb.Append(Field(e.Time.ToString(TimeFormat, CultureInfo.InvariantCulture))).Append(',')
+ .Append(Field(e.Severity.ToString())).Append(',')
+ .Append(Field(CategoryName(e.Category))).Append(',')
+ .Append(Field(e.Message)).Append(',')
+ .Append(Field(e.Detail ?? "")).Append("\r\n");
+ }
+ return sb.ToString();
+ }
+
+ ///
+ /// Quotes a CSV field when needed (RFC 4180). Text from devices (host names, vendor IDs) ends up in
+ /// messages, so a field that Excel would read as a formula gets a leading apostrophe.
+ ///
+ internal static string Field(string value)
+ {
+ if (value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r') value = "'" + value;
+ bool quote = value.IndexOfAny([',', '"', '\r', '\n']) >= 0;
+ return quote ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
+ }
+
+ private static string OneLine(string s) => s.ReplaceLineEndings(" ");
+
+ /// A file name like ProDHCPServer-log-2026-10-16-1405.txt.
+ public static string SuggestedFileName(DateTimeOffset at, string extension) =>
+ $"ProDHCPServer-log-{at:yyyy-MM-dd-HHmm}.{extension}";
+}
diff --git a/src/ProDHCPServer.Core/Profiles/Profile.cs b/src/ProDHCPServer.Core/Profiles/Profile.cs
new file mode 100644
index 0000000..fd28ace
--- /dev/null
+++ b/src/ProDHCPServer.Core/Profiles/Profile.cs
@@ -0,0 +1,216 @@
+using System.Globalization;
+using System.Net;
+using System.Text;
+using System.Text.Json.Serialization;
+using ProDHCPServer.Core.Dhcp;
+using ProDHCPServer.Core.Net;
+
+namespace ProDHCPServer.Core.Profiles;
+
+public enum CustomOptionFormat
+{
+ /// ASCII text, like a server name or URL.
+ Text,
+ /// One or more IPv4 addresses, separated by commas.
+ Addresses,
+ /// Raw bytes as hex, like "01 04 C0 A8 64 0A".
+ Hex,
+}
+
+/// A DHCP option that has no field of its own in the profile form.
+public sealed record CustomOption(byte Code, CustomOptionFormat Format, string Value);
+
+/// Where a preset's values come from: a page on the manufacturer's own site.
+public sealed record ProfileSource(string Title, string Url);
+
+///
+/// A saved set of server settings: subnet, pool, lease time and the options handed to devices.
+/// Strings are kept as typed so a half-finished profile can still be saved and fixed later;
+/// checks them.
+///
+public sealed record Profile
+{
+ public required string Name { get; init; }
+ public string? Notes { get; init; }
+ public string ServerIp { get; init; } = "192.168.100.1";
+ public int PrefixLength { get; init; } = 24;
+ public string PoolStart { get; init; } = "192.168.100.100";
+ public string PoolEnd { get; init; } = "192.168.100.199";
+ public int LeaseMinutes { get; init; } = 60;
+ /// Option 3.
+ public string? Router { get; init; }
+ /// Option 6, comma separated.
+ public string? DnsServers { get; init; }
+ /// Option 15.
+ public string? DomainName { get; init; }
+ /// Option 42, comma separated.
+ public string? NtpServers { get; init; }
+ /// Option 66: TFTP or provisioning server name, address or URL.
+ public string? TftpServerName { get; init; }
+ /// Option 150 (Cisco): TFTP server addresses, comma separated.
+ public string? TftpServers { get; init; }
+ /// Option 43: vendor-specific bytes as hex.
+ public string? VendorSpecificHex { get; init; }
+ public IReadOnlyList CustomOptions { get; init; } = [];
+ public bool AlwaysBroadcast { get; init; }
+ /// Documentation the values come from (shipped presets cite the manufacturers).
+ public IReadOnlyList Sources { get; init; } = [];
+
+ /// Shipped with the app; read-only. Duplicate it to change it.
+ [JsonIgnore] public bool IsPreset { get; init; }
+
+ /// Codes that have their own field, or that the server sets itself, so can't be custom options.
+ public static string? ReservedOptionReason(byte code) => code switch
+ {
+ 0 or 255 => "is a padding/end marker, not a real option",
+ DhcpOptionCode.SubnetMask => "is set from the subnet field",
+ DhcpOptionCode.Router => "has its own field (Gateway)",
+ DhcpOptionCode.DnsServers => "has its own field (DNS servers)",
+ DhcpOptionCode.DomainName => "has its own field (Domain name)",
+ DhcpOptionCode.BroadcastAddress => "is set from the subnet field",
+ DhcpOptionCode.NtpServers => "has its own field (NTP servers)",
+ DhcpOptionCode.VendorSpecific => "has its own field (Vendor-specific)",
+ DhcpOptionCode.TftpServerName => "has its own field (TFTP or provisioning server)",
+ DhcpOptionCode.TftpServerAddress => "has its own field (TFTP server addresses)",
+ DhcpOptionCode.RequestedIp or DhcpOptionCode.LeaseTime or DhcpOptionCode.OptionOverload or DhcpOptionCode.MessageType
+ or DhcpOptionCode.ServerIdentifier or DhcpOptionCode.ParameterRequestList or DhcpOptionCode.MaxMessageSize
+ or DhcpOptionCode.RenewalTime or DhcpOptionCode.RebindingTime or DhcpOptionCode.ClientId => "is set by the server itself",
+ _ => null,
+ };
+
+ /// Builds the server config, or returns plain-English problems.
+ public (DhcpServerConfig? Config, IReadOnlyList Errors) ToConfig()
+ {
+ var errors = new List();
+ var server = Address(ServerIp, "Server IP", errors);
+ var start = Address(PoolStart, "Pool start", errors);
+ var end = Address(PoolEnd, "Pool end", errors);
+ var router = string.IsNullOrWhiteSpace(Router) ? null : Address(Router, "Gateway", errors);
+ var dns = Addresses(DnsServers, "DNS server", errors);
+ var ntp = Addresses(NtpServers, "NTP server", errors);
+ var tftp = Addresses(TftpServers, "Option 150 address", errors);
+
+ var extra = new Dictionary();
+ if (ntp.Count > 0) extra[DhcpOptionCode.NtpServers] = ToBytes(ntp);
+ if (!string.IsNullOrWhiteSpace(TftpServerName)) extra[DhcpOptionCode.TftpServerName] = Text(TftpServerName, "Option 66", errors);
+ if (tftp.Count > 0) extra[DhcpOptionCode.TftpServerAddress] = ToBytes(tftp);
+ if (!string.IsNullOrWhiteSpace(VendorSpecificHex))
+ {
+ if (TryParseHex(VendorSpecificHex, out var v43)) extra[DhcpOptionCode.VendorSpecific] = Fit(v43, "Option 43", errors);
+ else errors.Add($"Option 43 \"{VendorSpecificHex.Trim()}\" isn't valid hex. Use pairs of hex digits, like 01 04 C0 A8 64 0A.");
+ }
+ foreach (var custom in CustomOptions)
+ {
+ if (ReservedOptionReason(custom.Code) is { } why) { errors.Add($"Option {custom.Code} {why}."); continue; }
+ if (extra.ContainsKey(custom.Code)) { errors.Add($"Option {custom.Code} is listed more than once."); continue; }
+ if (CustomBytes(custom, errors) is { } bytes) extra[custom.Code] = bytes;
+ }
+
+ string? domain = null;
+ if (!string.IsNullOrWhiteSpace(DomainName))
+ {
+ domain = DomainName.Trim();
+ Text(domain, "Domain name", errors);
+ }
+ if (LeaseMinutes <= 0) errors.Add("Lease time must be a whole number of minutes.");
+ if (errors.Count > 0) return (null, errors);
+
+ var config = new DhcpServerConfig
+ {
+ ServerIp = server!,
+ PrefixLength = PrefixLength,
+ PoolStart = start!,
+ PoolEnd = end!,
+ LeaseTime = TimeSpan.FromMinutes(LeaseMinutes),
+ Router = router,
+ DnsServers = dns,
+ DomainName = domain,
+ ExtraOptions = extra,
+ AlwaysBroadcast = AlwaysBroadcast,
+ };
+ var problems = config.Validate();
+ return problems.Count > 0 ? (null, problems) : (config, []);
+ }
+
+ /// One line for the start button area, like "192.168.100.1/24, pool .100 to .199".
+ public string Summary => $"{ServerIp}/{PrefixLength}, pool {PoolStart} to {PoolEnd}";
+
+ private static IPAddress? Address(string? text, string label, List errors)
+ {
+ if (IPv4.TryParse(text?.Trim(), out var ip)) return ip;
+ errors.Add($"{label} \"{text?.Trim()}\" isn't a valid IPv4 address.");
+ return null;
+ }
+
+ private static List Addresses(string? text, string label, List errors)
+ {
+ var list = new List();
+ foreach (var part in (text ?? "").Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (IPv4.TryParse(part, out var ip)) list.Add(ip);
+ else errors.Add($"{label} \"{part}\" isn't a valid IPv4 address.");
+ }
+ if (list.Count > 63) errors.Add($"Too many {label} addresses (63 at most).");
+ return list;
+ }
+
+ private static byte[] ToBytes(IEnumerable addresses) => addresses.SelectMany(a => a.GetAddressBytes()).ToArray();
+
+ private static byte[] Text(string text, string label, List errors)
+ {
+ var trimmed = text.Trim();
+ if (trimmed.Any(c => c < 0x20 || c > 0x7E))
+ {
+ errors.Add($"{label} can only use plain letters, digits and punctuation.");
+ return [];
+ }
+ return Fit(Encoding.ASCII.GetBytes(trimmed), label, errors);
+ }
+
+ private static byte[] Fit(byte[] data, string label, List errors)
+ {
+ if (data.Length == 0) errors.Add($"{label} is empty.");
+ else if (data.Length > 255) errors.Add($"{label} is too long ({data.Length} bytes; a DHCP option holds 255 at most).");
+ return data;
+ }
+
+ private static byte[]? CustomBytes(CustomOption option, List errors)
+ {
+ var label = $"Option {option.Code}";
+ switch (option.Format)
+ {
+ case CustomOptionFormat.Text:
+ return Text(option.Value ?? "", label, errors);
+ case CustomOptionFormat.Addresses:
+ var list = Addresses(option.Value, label, errors);
+ if (list.Count == 0 && string.IsNullOrWhiteSpace(option.Value)) errors.Add($"{label} is empty.");
+ return ToBytes(list);
+ default:
+ if (TryParseHex(option.Value, out var bytes)) return Fit(bytes, label, errors);
+ errors.Add($"{label} \"{option.Value?.Trim()}\" isn't valid hex. Use pairs of hex digits, like 01 04 C0 A8 64 0A.");
+ return null;
+ }
+ }
+
+ /// Accepts "0104C0A8", "01 04 c0 a8", "01:04:C0:A8" and "0x01,0x04".
+ public static bool TryParseHex(string? text, out byte[] bytes)
+ {
+ bytes = [];
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ var clean = new StringBuilder();
+ foreach (var token in text.Split([' ', ':', '-', ',', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
+ {
+ var t = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token;
+ // Separated single digits like "1 4" mean 01 04.
+ if (t.Length == 1) t = "0" + t;
+ clean.Append(t);
+ }
+ var hex = clean.ToString();
+ if (hex.Length == 0 || hex.Length % 2 != 0 || !hex.All(Uri.IsHexDigit)) return false;
+ bytes = Convert.FromHexString(hex);
+ return true;
+ }
+
+ /// Formats bytes the way the form shows them: "01 04 C0 A8".
+ public static string ToHex(byte[] bytes) => string.Join(" ", bytes.Select(b => b.ToString("X2", CultureInfo.InvariantCulture)));
+}
diff --git a/src/ProDHCPServer.Core/Profiles/ProfileLibrary.cs b/src/ProDHCPServer.Core/Profiles/ProfileLibrary.cs
new file mode 100644
index 0000000..eda6dd9
--- /dev/null
+++ b/src/ProDHCPServer.Core/Profiles/ProfileLibrary.cs
@@ -0,0 +1,234 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ProDHCPServer.Core.Profiles;
+
+/// The outcome of importing a profiles file.
+public sealed record ProfileImportResult(IReadOnlyList Added, IReadOnlyList Problems)
+{
+ public string Describe()
+ {
+ var parts = new List();
+ parts.Add(Added.Count switch
+ {
+ 0 => "No profiles were imported.",
+ 1 => $"Imported \"{Added[0].Name}\".",
+ var n => $"Imported {n} profiles: {string.Join(", ", Added.Select(p => $"\"{p.Name}\""))}.",
+ });
+ parts.AddRange(Problems);
+ return string.Join(" ", parts);
+ }
+}
+
+///
+/// The shipped presets plus the user's own profiles, saved as JSON. Profile names are unique,
+/// ignoring case. Presets can't be changed or deleted.
+///
+public sealed class ProfileLibrary
+{
+ public const int MaxImportBytes = 1_000_000;
+ public const int MaxProfiles = 200;
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
+ };
+
+ private readonly string? _path;
+ private readonly List _user = [];
+
+ /// Where the user's profiles are saved; null keeps them in memory (tests).
+ public ProfileLibrary(string? path) => _path = path;
+
+ public IReadOnlyList UserProfiles => _user;
+ public IEnumerable All => ProfilePresets.All.Concat(_user);
+
+ public Profile? Find(string? name) =>
+ name is null ? null : All.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Loads the user's profiles. On first run, if the old single-settings form held anything other
+ /// than the defaults, it becomes a profile called "My settings" so nothing typed in Phase 1 is lost.
+ /// Returns a problem message when the file couldn't be read (the app still starts).
+ ///
+ public string? Load(AppSettings? legacy = null)
+ {
+ _user.Clear();
+ if (_path is null) return null;
+ try
+ {
+ if (File.Exists(_path))
+ {
+ var file = JsonSerializer.Deserialize(File.ReadAllText(_path), Json);
+ foreach (var p in file?.Profiles ?? []) TryAdd(Clean(p), out _);
+ return null;
+ }
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
+ {
+ return $"Couldn't read your saved profiles ({ex.Message}). The presets are still available.";
+ }
+
+ if (legacy is not null && FromLegacy(legacy) is { } migrated)
+ {
+ _user.Add(migrated);
+ Save();
+ }
+ return null;
+ }
+
+ internal static Profile? FromLegacy(AppSettings s)
+ {
+ var p = new Profile
+ {
+ Name = "My settings",
+ Notes = "Your settings from the previous version of ProDHCPServer.",
+ ServerIp = s.ServerIp,
+ PrefixLength = s.PrefixLength,
+ PoolStart = s.PoolStart,
+ PoolEnd = s.PoolEnd,
+ LeaseMinutes = s.LeaseMinutes,
+ Router = s.Router,
+ DnsServers = s.DnsServers,
+ AlwaysBroadcast = s.AlwaysBroadcast,
+ };
+ var d = ProfilePresets.Default;
+ bool same = p.ServerIp == d.ServerIp && p.PrefixLength == d.PrefixLength && p.PoolStart == d.PoolStart && p.PoolEnd == d.PoolEnd
+ && p.LeaseMinutes == d.LeaseMinutes && string.IsNullOrEmpty(p.Router) && string.IsNullOrEmpty(p.DnsServers) && !p.AlwaysBroadcast;
+ return same ? null : p;
+ }
+
+ /// Adds or replaces a user profile. is its name before editing (for renames).
+ public void Save(Profile profile, string? originalName)
+ {
+ profile = Clean(profile);
+ if (string.IsNullOrWhiteSpace(profile.Name)) throw new ProfileException("Give the profile a name.");
+ if (ProfilePresets.All.Any(p => Same(p.Name, profile.Name)))
+ throw new ProfileException($"\"{profile.Name}\" is the name of a preset. Pick another name.");
+ if (originalName is not null && ProfilePresets.All.Any(p => Same(p.Name, originalName)))
+ throw new ProfileException($"\"{originalName}\" is a preset and can't be changed. Duplicate it first.");
+ if (_user.Any(p => Same(p.Name, profile.Name) && !Same(p.Name, originalName)))
+ throw new ProfileException($"There is already a profile called \"{profile.Name}\". Pick another name.");
+
+ int index = originalName is null ? -1 : _user.FindIndex(p => Same(p.Name, originalName));
+ if (index >= 0) _user[index] = profile;
+ else
+ {
+ if (_user.Count >= MaxProfiles) throw new ProfileException($"You already have {MaxProfiles} profiles. Delete some first.");
+ _user.Add(profile);
+ }
+ Save();
+ }
+
+ /// Copies any profile (preset or user) under a new unique name and saves it.
+ public Profile Duplicate(Profile source)
+ {
+ var copy = Clean(source) with { Name = UniqueName($"{source.Name} copy") };
+ Save(copy, null);
+ return copy;
+ }
+
+ public void Delete(string name)
+ {
+ if (ProfilePresets.All.Any(p => Same(p.Name, name))) throw new ProfileException($"\"{name}\" is a preset and can't be deleted.");
+ _user.RemoveAll(p => Same(p.Name, name));
+ Save();
+ }
+
+ public string UniqueName(string wanted)
+ {
+ wanted = wanted.Trim();
+ if (Find(wanted) is null) return wanted;
+ for (int i = 2; ; i++)
+ {
+ var candidate = $"{wanted} ({i})";
+ if (Find(candidate) is null) return candidate;
+ }
+ }
+
+ /// The profiles file format, also used for export.
+ public static string Export(IEnumerable profiles) =>
+ JsonSerializer.Serialize(new ProfileFile { Profiles = profiles.Select(Clean).ToList() }, Json);
+
+ ///
+ /// Imports profiles from an exported file (or a single profile object). Names that clash get " (2)" added.
+ /// Profiles that wouldn't work are skipped with the reason.
+ ///
+ public ProfileImportResult Import(string json)
+ {
+ if (json.Length > MaxImportBytes) return new([], ["The file is too big to be a ProDHCPServer profiles file."]);
+ List incoming;
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ incoming = doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("profiles", out _)
+ ? JsonSerializer.Deserialize(json, Json)?.Profiles ?? []
+ : [JsonSerializer.Deserialize(json, Json)!];
+ }
+ catch (Exception ex) when (ex is JsonException or NotSupportedException or ArgumentNullException)
+ {
+ return new([], [$"This isn't a ProDHCPServer profiles file ({ex.Message.Split('.')[0]})."]);
+ }
+
+ var added = new List();
+ var problems = new List();
+ foreach (var raw in incoming.Take(MaxProfiles))
+ {
+ if (raw is null || string.IsNullOrWhiteSpace(raw.Name)) { problems.Add("Skipped a profile with no name."); continue; }
+ var p = Clean(raw);
+ var (_, errors) = p.ToConfig();
+ if (errors.Count > 0) { problems.Add($"Skipped \"{p.Name}\": {errors[0]}"); continue; }
+ p = p with { Name = UniqueName(p.Name) };
+ if (!TryAdd(p, out var why)) { problems.Add($"Skipped \"{p.Name}\": {why}"); continue; }
+ added.Add(p);
+ }
+ if (incoming.Count > MaxProfiles) problems.Add($"Only the first {MaxProfiles} profiles were read.");
+ if (added.Count > 0) Save();
+ return new(added, problems);
+ }
+
+ private bool TryAdd(Profile p, out string? why)
+ {
+ why = null;
+ if (string.IsNullOrWhiteSpace(p.Name)) { why = "it has no name"; return false; }
+ if (Find(p.Name) is not null) { why = "the name is already used"; return false; }
+ if (_user.Count >= MaxProfiles) { why = $"you already have {MaxProfiles} profiles"; return false; }
+ _user.Add(p);
+ return true;
+ }
+
+ private void Save()
+ {
+ if (_path is null) return;
+ Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+ var tmp = _path + ".tmp";
+ File.WriteAllText(tmp, Export(_user));
+ File.Move(tmp, _path, overwrite: true);
+ }
+
+ /// Trims the name and clears the preset flag; nulls from hand-edited JSON become defaults.
+ private static Profile Clean(Profile p) => p with
+ {
+ Name = (p.Name ?? "").Trim(),
+ IsPreset = false,
+ CustomOptions = p.CustomOptions ?? [],
+ Sources = p.Sources ?? [],
+ ServerIp = p.ServerIp ?? "",
+ PoolStart = p.PoolStart ?? "",
+ PoolEnd = p.PoolEnd ?? "",
+ };
+
+ private static bool Same(string? a, string? b) => string.Equals(a?.Trim(), b?.Trim(), StringComparison.OrdinalIgnoreCase);
+
+ private sealed class ProfileFile
+ {
+ public string Format { get; init; } = "ProDHCPServer profiles";
+ public int Version { get; init; } = 1;
+ public List Profiles { get; init; } = [];
+ }
+}
+
+public sealed class ProfileException(string message) : Exception(message);
diff --git a/src/ProDHCPServer.Core/Profiles/ProfilePresets.cs b/src/ProDHCPServer.Core/Profiles/ProfilePresets.cs
new file mode 100644
index 0000000..f51bc00
--- /dev/null
+++ b/src/ProDHCPServer.Core/Profiles/ProfilePresets.cs
@@ -0,0 +1,129 @@
+namespace ProDHCPServer.Core.Profiles;
+
+///
+/// Read-only profiles shipped with the app. Duplicate one to change it.
+/// Every factory address here is from the manufacturer's own documentation, linked in
+/// (checked October 2026). The pools are chosen so they never hand out a documented factory address.
+///
+public static class ProfilePresets
+{
+ public const string DefaultName = "Default";
+
+ public static Profile Default { get; } = new()
+ {
+ Name = DefaultName,
+ Notes = "A private subnet that is rarely used on venue networks. Most networked AV gear asks for an address by DHCP " +
+ "out of the box and takes one from this: Q-SYS, Biamp Tesira and Parlé, Shure MXA, Sennheiser TeamConnect Ceiling 2, " +
+ "Dante devices, Symetrix, Crestron 4-Series, touch panels and DM NVX, AMX NX, Kramer, Atlona and Extron NAV.",
+ Sources =
+ [
+ new("Q-SYS: default IP address of Cores and peripherals", "https://support.qsys.com/en_US/faq/faq-%7C-what-is-the-default-ip-address-for-q-sys-cores-and-peripherals"),
+ new("Biamp: Tesira factory defaults (includes Parlé)", "https://support.biamp.com/Tesira/Miscellaneous/How_to_reset_a_Tesira_device_to_factory_defaults"),
+ new("Shure: MXA910 user guide", "https://pubs.shure.com/view/guide/MXA910/en-US.pdf"),
+ new("Sennheiser: TCC 2 / TCC M network troubleshooting", "https://help.sennheiser.com/hc/en-us/articles/39123738203161-TCC-2-TCC-M-won-t-appear-in-Control-Cockpit-Troubleshooting"),
+ new("Audinate: Dante information for network administrators", "https://www.getdante.com/resources/catalog/networking/dante-information-for-network-administrators/"),
+ new("Symetrix: connecting to Symetrix devices", "https://www.symetrix.co/tech-tips/connecting-to-symetrix-devices-with-a-pc"),
+ new("Crestron: CP4/CP4N quick start", "https://www.crestron.com/getmedia/cb15afc5-9dfa-412f-9ac2-439a5a312854/mg_qs_cp4-cp4n"),
+ new("Crestron: DM NVX quick start", "https://www.crestron.com/getmedia/76b2a92b-b8c8-44f7-8782-79b9a760a188/mg_qs_dm-nvx-363-360"),
+ new("AMX: NX-1200 quick start", "https://adn.harmanpro.com/site_elements/resources/2141_1522083687/NX-1200.QuickStartGuide_original.pdf"),
+ new("Kramer: SL-240C user manual", "https://cdn.kramerav.com/web/downloads/manuals/kramer_sl-240c_user_manual.pdf"),
+ new("Atlona: KB01412, web interface with DHCP", "https://support.atlona.com/hc/en-us/articles/360001487674-KB01412-How-to-access-the-web-interface-of-an-Atlona-system-with-DHCP-enabled"),
+ new("Extron: NAV E 222 user guide", "https://media.extron.com/public/download/files/userman/68-3825-01_A_NAV-E-222_User_Guide.pdf"),
+ ],
+ IsPreset = true,
+ };
+
+ public static IReadOnlyList All { get; } =
+ [
+ Default,
+ new()
+ {
+ Name = "192.168.254.x: Extron",
+ Notes = "Extron switchers and scalers ship on 192.168.254.254 (mask 255.255.0.0), and IPCP Pro control processors on " +
+ "192.168.254.250 (mask 255.255.255.0) with DHCP off. This puts your laptop on 192.168.254.1 so you can reach a " +
+ "factory-reset unit, while still handing out .100 to .199 to anything that asks.",
+ ServerIp = "192.168.254.1",
+ PoolStart = "192.168.254.100",
+ PoolEnd = "192.168.254.199",
+ Sources =
+ [
+ new("Extron: IN1606 setup guide", "https://media.extron.com/public/download/files/userman/68-2290-50_B_IN1606_SUG.pdf"),
+ new("Extron: IPCP Pro user guide", "https://media.extron.com/public/download/files/userman/68-2438-01_M_IPCP_Pro_UG.pdf"),
+ ],
+ IsPreset = true,
+ },
+ new()
+ {
+ Name = "192.168.0.x: Lightware, Christie, Sharp/NEC, Bose",
+ Notes = "Factory addresses on 192.168.0.x: Lightware (for example UMX-HDMI-140) 192.168.0.100, Christie GS Series projectors " +
+ "192.168.0.100, Sharp/NEC MultiSync M displays 192.168.0.10, Bose ESP 192.168.0.160, PowerMatch 192.168.0.40 (4-channel) " +
+ "or .80 (8-channel), CC-64 192.168.0.176. This puts your laptop on 192.168.0.1 and only hands out .200 to .230, " +
+ "clear of all of those. Only one device with a given address can be on the cable at a time.",
+ ServerIp = "192.168.0.1",
+ PoolStart = "192.168.0.200",
+ PoolEnd = "192.168.0.230",
+ Sources =
+ [
+ new("Lightware: UMX-HDMI-140 quick start", "https://assets.prod.pim.lightware.com/assets/File-Downloads/Guides-and-Manuals/Quick-Start-Guide/UMX-HDMI-140_series_QSG.pdf"),
+ new("Christie: GS Series 1075 user manual", "https://www.christiedigital.com/globalassets/resources/public/020-001225-04-christie-lit-man-usr-gs-series-1075.pdf"),
+ new("Sharp/NEC: MultiSync M installation guide", "https://assets.sharpnecdisplays.us/documents/installationguides/m-series-installationguide.pdf"),
+ new("Bose Professional: IP addresses of hardware", "https://boseprofessional.com/support/article/ip-address-hardware"),
+ ],
+ IsPreset = true,
+ },
+ new()
+ {
+ Name = "192.168.1.x: Kramer, Atlona, ClickShare, AMX",
+ Notes = "Addresses on 192.168.1.x: Kramer (for example SL-240C) falls back to 192.168.1.39 (mask 255.255.0.0) when no DHCP server answers, " +
+ "some Atlona products fall back to 192.168.1.150, a Barco ClickShare base unit is reached at 192.168.1.23 over a " +
+ "direct cable, and an AMX NX controller switched to static uses 192.168.1.3. This puts your laptop on 192.168.1.1 " +
+ "and only hands out .200 to .249.",
+ ServerIp = "192.168.1.1",
+ PoolStart = "192.168.1.200",
+ PoolEnd = "192.168.1.249",
+ Sources =
+ [
+ new("Kramer: SL-240C user manual", "https://cdn.kramerav.com/web/downloads/manuals/kramer_sl-240c_user_manual.pdf"),
+ new("Atlona: KB01412, web interface with DHCP", "https://support.atlona.com/hc/en-us/articles/360001487674-KB01412-How-to-access-the-web-interface-of-an-Atlona-system-with-DHCP-enabled"),
+ new("Barco: ClickShare Configurator connections", "https://www.barco.com/en/support/knowledge-base/2324-clickshare-configurator-connections"),
+ new("AMX: NX-1200 quick start", "https://adn.harmanpro.com/site_elements/resources/2141_1522083687/NX-1200.QuickStartGuide_original.pdf"),
+ ],
+ IsPreset = true,
+ },
+ new()
+ {
+ Name = "Cisco IP phones (option 150)",
+ Notes = "Cisco IP phones find their TFTP/call manager server through DHCP option 150 (Cisco says option 66 works but " +
+ "isn't recommended). Duplicate this profile and change option 150 to your server's address.",
+ TftpServers = "192.168.100.10",
+ Sources =
+ [
+ new("Cisco: Unified CM TFTP configuration", "https://www.cisco.com/c/en/us/td/docs/voice_ip_comm/cucm/admin/12_5_1SU1/systemConfig/cucm_b_system-configuration-guide-1251su1/cucm_b_system-configuration-guide-1251su1_restructured_chapter_0101101.html"),
+ ],
+ IsPreset = true,
+ },
+ new()
+ {
+ Name = "Poly phones (option 66)",
+ Notes = "Poly (Polycom) phones find their provisioning server through DHCP option 66. Duplicate this profile and change " +
+ "option 66 to your provisioning server's address or URL. Poly Lens can also use option 160 or 161.",
+ TftpServerName = "http://192.168.100.10/",
+ Sources =
+ [
+ new("Poly: UC Software provisioning guide", "https://kaas.hpcloud.hp.com/pdf-public/pdf_9122416_en-US-1.pdf"),
+ new("Poly Lens: provisioning phones", "https://info.lens.poly.com/docs/onboard/provisioning-phones"),
+ ],
+ IsPreset = true,
+ },
+ new()
+ {
+ Name = "Bench test (2-minute leases)",
+ Notes = "Short leases, so you can watch a device renew within a few minutes. Uses 10.10.10.x.",
+ ServerIp = "10.10.10.1",
+ PoolStart = "10.10.10.100",
+ PoolEnd = "10.10.10.199",
+ LeaseMinutes = 2,
+ IsPreset = true,
+ },
+ ];
+}
diff --git a/src/ProDHCPServer.Windows/DeviceMonitor.cs b/src/ProDHCPServer.Windows/DeviceMonitor.cs
new file mode 100644
index 0000000..3541ee7
--- /dev/null
+++ b/src/ProDHCPServer.Windows/DeviceMonitor.cs
@@ -0,0 +1,142 @@
+using System.Net;
+using System.Net.NetworkInformation;
+using ProDHCPServer.Core;
+using ProDHCPServer.Core.Devices;
+using ProDHCPServer.Core.Net;
+using ProDHCPServer.Core.Status;
+
+namespace ProDHCPServer.Windows;
+
+/// A device to keep pinging: its MAC (the key), current address and the name used in the log.
+public sealed record MonitoredDevice(MacAddress Mac, IPAddress Ip, string Name);
+
+///
+/// Pings every device that holds a lease, once a second, and keeps each one's history for the latency
+/// graph. Logs when a device stops answering and when it comes back, so drop-outs end up in the job report.
+///
+public sealed class DeviceMonitor : IAsyncDisposable
+{
+ /// More than this many devices and the rest aren't pinged (a big switch full of gear).
+ public const int MaxDevices = 64;
+
+ private readonly SessionLog _log;
+ private readonly object _gate = new();
+ private readonly Dictionary _pingers = [];
+ private readonly Dictionary _names = [];
+
+ public DeviceMonitor(SessionLog log) => _log = log;
+
+ public PingHistory? History(MacAddress mac)
+ {
+ lock (_gate) return _pingers.TryGetValue(mac, out var p) ? p.History : null;
+ }
+
+ /// Starts pinging new devices, follows address changes and stops pinging devices that are gone.
+ public void Sync(IReadOnlyList devices)
+ {
+ var stale = new List();
+ lock (_gate)
+ {
+ var wanted = devices.Take(MaxDevices).ToDictionary(d => d.Mac);
+ foreach (var (mac, pinger) in _pingers.ToList())
+ {
+ if (wanted.TryGetValue(mac, out var d) && d.Ip.Equals(pinger.Target)) continue;
+ stale.Add(pinger);
+ _pingers.Remove(mac);
+ }
+ foreach (var d in wanted.Values)
+ {
+ _names[d.Mac] = d.Name;
+ if (_pingers.ContainsKey(d.Mac)) continue;
+ var mac = d.Mac;
+ _pingers[mac] = new DevicePinger(d.Ip, t => OnTransition(mac, d.Ip, t));
+ }
+ }
+ foreach (var p in stale) _ = p.DisposeAsync().AsTask();
+ }
+
+ private void OnTransition(MacAddress mac, IPAddress ip, PingTransition t)
+ {
+ string name;
+ lock (_gate) name = _names.GetValueOrDefault(mac) ?? mac.ToString();
+ if (t.Answering)
+ _log.Add(LogSeverity.Success, LogCategory.Device,
+ $"{name} ({ip}) is answering ping again after {Duration(t.OutageLength ?? TimeSpan.Zero)} without replies.");
+ else
+ _log.Add(LogSeverity.Warning, LogCategory.Device,
+ $"{name} ({ip}) stopped answering ping at {t.At:HH:mm:ss}.");
+ }
+
+ private static string Duration(TimeSpan t) =>
+ t.TotalMinutes >= 1 ? $"{(int)t.TotalMinutes} min {t.Seconds} s" : $"{Math.Max(1, (int)Math.Round(t.TotalSeconds))} s";
+
+ public async Task ClearAsync()
+ {
+ List all;
+ lock (_gate)
+ {
+ all = _pingers.Values.ToList();
+ _pingers.Clear();
+ _names.Clear();
+ }
+ await Task.WhenAll(all.Select(p => p.DisposeAsync().AsTask()));
+ }
+
+ public async ValueTask DisposeAsync() => await ClearAsync();
+}
+
+/// Pings one address once a second and records the results.
+public sealed class DevicePinger : IAsyncDisposable
+{
+ private static readonly TimeSpan Interval = TimeSpan.FromSeconds(1);
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Action _onTransition;
+ private readonly Task _loop;
+
+ public DevicePinger(IPAddress target, Action onTransition)
+ {
+ Target = target;
+ _onTransition = onTransition;
+ _loop = Task.Run(() => Loop(_cts.Token));
+ }
+
+ public IPAddress Target { get; }
+ public PingHistory History { get; } = new();
+
+ private async Task Loop(CancellationToken ct)
+ {
+ using var ping = new Ping();
+ while (!ct.IsCancellationRequested)
+ {
+ var started = DateTimeOffset.Now;
+ ProbeResult result;
+ try
+ {
+ var reply = await ping.SendPingAsync(Target, Interval, cancellationToken: ct);
+ result = reply.Status == IPStatus.Success
+ ? new ProbeResult(started, true, reply.RoundtripTime)
+ : new ProbeResult(started, false, null, $"Ping {Target}: {reply.Status}");
+ }
+ catch (OperationCanceledException) { return; }
+ catch (PingException ex)
+ {
+ result = new ProbeResult(started, false, null, ex.InnerException?.Message ?? ex.Message);
+ }
+ if (History.Add(result) is { } transition) _onTransition(transition);
+
+ // Keep a steady once-a-second rhythm whether the reply was quick or timed out.
+ var wait = Interval - (DateTimeOffset.Now - started);
+ if (wait > TimeSpan.Zero)
+ {
+ try { await Task.Delay(wait, ct); } catch (OperationCanceledException) { return; }
+ }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _cts.Cancel();
+ try { await _loop; } catch { /* cancelled */ }
+ _cts.Dispose();
+ }
+}
diff --git a/src/ProDHCPServer.Windows/DeviceProber.cs b/src/ProDHCPServer.Windows/DeviceProber.cs
index 715bd9c..4a03843 100644
--- a/src/ProDHCPServer.Windows/DeviceProber.cs
+++ b/src/ProDHCPServer.Windows/DeviceProber.cs
@@ -1,61 +1,26 @@
using System.Net;
-using System.Net.NetworkInformation;
using System.Net.Sockets;
using ProDHCPServer.Core.Status;
namespace ProDHCPServer.Windows;
-/// Pings one device every second and checks its web ports every few seconds.
+/// Checks the focused device's web ports every few seconds. Pings come from .
public sealed class DeviceProber : IAsyncDisposable
{
private readonly CancellationTokenSource _cts = new();
- private readonly Task _pingLoop;
private readonly Task _portLoop;
public DeviceProber(IPAddress target)
{
Target = target;
- _pingLoop = Task.Run(() => PingLoop(_cts.Token));
_portLoop = Task.Run(() => PortLoop(_cts.Token));
}
public IPAddress Target { get; }
- public ProbeResult? LastPing { get; private set; }
- public int ConsecutivePingFailures { get; private set; }
public WebUiProbe? LastWebUi { get; private set; }
public event Action? Updated;
- private async Task PingLoop(CancellationToken ct)
- {
- using var ping = new Ping();
- while (!ct.IsCancellationRequested)
- {
- try
- {
- var reply = await ping.SendPingAsync(Target, TimeSpan.FromMilliseconds(1000), cancellationToken: ct);
- if (reply.Status == IPStatus.Success)
- {
- LastPing = new ProbeResult(DateTimeOffset.Now, true, reply.RoundtripTime);
- ConsecutivePingFailures = 0;
- }
- else
- {
- LastPing = new ProbeResult(DateTimeOffset.Now, false, null, $"Ping {Target}: {reply.Status}");
- ConsecutivePingFailures++;
- }
- }
- catch (OperationCanceledException) { return; }
- catch (PingException ex)
- {
- LastPing = new ProbeResult(DateTimeOffset.Now, false, null, ex.InnerException?.Message ?? ex.Message);
- ConsecutivePingFailures++;
- }
- Updated?.Invoke();
- try { await Task.Delay(1000, ct); } catch (OperationCanceledException) { return; }
- }
- }
-
private async Task PortLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
@@ -92,7 +57,7 @@ private async Task PortLoop(CancellationToken ct)
public async ValueTask DisposeAsync()
{
_cts.Cancel();
- try { await Task.WhenAll(_pingLoop, _portLoop); } catch { /* cancelled */ }
+ try { await _portLoop; } catch { /* cancelled */ }
_cts.Dispose();
}
}
diff --git a/tests/ProDHCPServer.Core.Tests/LogExportTests.cs b/tests/ProDHCPServer.Core.Tests/LogExportTests.cs
new file mode 100644
index 0000000..15432bf
--- /dev/null
+++ b/tests/ProDHCPServer.Core.Tests/LogExportTests.cs
@@ -0,0 +1,60 @@
+namespace ProDHCPServer.Core.Tests;
+
+public class LogExportTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 10, 16, 14, 5, 1, TimeSpan.FromHours(11));
+
+ private static readonly LogEntry[] Entries =
+ [
+ new(T0, LogSeverity.Info, LogCategory.Adapter, "Backed up \"Ethernet 4\" (DHCP)."),
+ new(T0.AddSeconds(1), LogSeverity.Success, LogCategory.Dhcp, "ACK 192.168.100.100 to NAV-E, 60 min", "xid 0x1234\r\nflags 0x8000"),
+ new(T0.AddSeconds(2), LogSeverity.Warning, LogCategory.Device, "NAV-E (192.168.100.100) stopped answering ping."),
+ new(T0.AddSeconds(3), LogSeverity.Error, LogCategory.Server, "Another DHCP server answered at 192.168.100.250, offering 192.168.100.101."),
+ ];
+
+ [Fact]
+ public void Filter_by_level_category_and_text()
+ {
+ Assert.Equal(4, Entries.Count(new LogFilter().Matches));
+ Assert.Equal(2, Entries.Count(new LogFilter(LogLevelFilter.WarningsAndErrors).Matches));
+ Assert.Single(Entries, new LogFilter(LogLevelFilter.ErrorsOnly).Matches);
+ Assert.Single(Entries, new LogFilter(Category: LogCategory.Dhcp).Matches);
+ Assert.Equal(3, Entries.Count(new LogFilter(Text: " 192.168.100.10 ").Matches));
+ // Searches the detail too, ignoring case.
+ Assert.Single(Entries, new LogFilter(Text: "FLAGS 0x8000").Matches);
+ Assert.True(new LogFilter().IsEmpty);
+ Assert.False(new LogFilter(Text: "x").IsEmpty);
+ }
+
+ [Fact]
+ public void Text_export_has_header_full_times_and_indented_detail()
+ {
+ var text = LogExport.ToText(Entries, ["Adapter: Ethernet 4", "Profile: Default"], T0.AddMinutes(10));
+ var lines = text.ReplaceLineEndings("\n").Split('\n');
+ Assert.Equal("ProDHCPServer session log", lines[0]);
+ Assert.Equal("Exported 2026-10-16 14:15:01 (UTC+11:00)", lines[1]);
+ Assert.Equal("Adapter: Ethernet 4", lines[2]);
+ Assert.Contains("2026-10-16 14:05:02 Success DHCP ACK 192.168.100.100 to NAV-E, 60 min", lines);
+ Assert.Contains(" xid 0x1234", lines);
+ Assert.Contains(" flags 0x8000", lines);
+ }
+
+ [Fact]
+ public void Csv_export_quotes_fields_and_defuses_formulas()
+ {
+ var csv = LogExport.ToCsv([.. Entries, new(T0, LogSeverity.Info, LogCategory.Dhcp, "=HYPERLINK(\"x\")")]);
+ var rows = csv.Split("\r\n");
+ Assert.Equal("Time,Severity,Category,Message,Detail", rows[0]);
+ Assert.Equal("2026-10-16 14:05:01,Info,Adapter,\"Backed up \"\"Ethernet 4\"\" (DHCP).\",", rows[1]);
+ Assert.Contains("\r\n2026-10-16 14:05:02,Success,DHCP,\"ACK 192.168.100.100 to NAV-E, 60 min\",\"xid 0x1234\r\nflags 0x8000\"\r\n", csv);
+ Assert.Contains(",\"'=HYPERLINK(\"\"x\"\")\",", csv);
+ Assert.Equal("'-1", LogExport.Field("-1"));
+ Assert.Equal("plain", LogExport.Field("plain"));
+ }
+
+ [Fact]
+ public void Suggested_file_name_has_date_and_time()
+ {
+ Assert.Equal("ProDHCPServer-log-2026-10-16-1405.csv", LogExport.SuggestedFileName(T0, "csv"));
+ }
+}
diff --git a/tests/ProDHCPServer.Core.Tests/NetTests.cs b/tests/ProDHCPServer.Core.Tests/NetTests.cs
index c84feed..f2e314c 100644
--- a/tests/ProDHCPServer.Core.Tests/NetTests.cs
+++ b/tests/ProDHCPServer.Core.Tests/NetTests.cs
@@ -97,11 +97,11 @@ public void Overlap_detection_ignores_serving_adapter_apipa_and_disconnected()
[Fact]
public void Settings_to_config_reports_every_bad_field()
{
- var (config, errors) = new AppSettings { ServerIp = "192.168.100.300", DnsServers = "8.8.8.8, nope" }.ToConfig();
+ var (config, errors) = new Profiles.Profile { Name = "t", ServerIp = "192.168.100.300", DnsServers = "8.8.8.8, nope" }.ToConfig();
Assert.Null(config);
Assert.Equal(2, errors.Count);
- var (ok, none) = new AppSettings { Router = "192.168.100.254", DnsServers = "1.1.1.1 8.8.8.8" }.ToConfig();
+ var (ok, none) = new Profiles.Profile { Name = "t", Router = "192.168.100.254", DnsServers = "1.1.1.1 8.8.8.8" }.ToConfig();
Assert.Empty(none);
Assert.Equal(2, ok!.DnsServers.Count);
}
diff --git a/tests/ProDHCPServer.Core.Tests/PingHistoryTests.cs b/tests/ProDHCPServer.Core.Tests/PingHistoryTests.cs
new file mode 100644
index 0000000..13c0837
--- /dev/null
+++ b/tests/ProDHCPServer.Core.Tests/PingHistoryTests.cs
@@ -0,0 +1,87 @@
+using ProDHCPServer.Core.Devices;
+using ProDHCPServer.Core.Status;
+
+namespace ProDHCPServer.Core.Tests;
+
+public class PingHistoryTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 10, 16, 9, 0, 0, TimeSpan.Zero);
+
+ private static ProbeResult Ok(int second, double ms) => new(T0.AddSeconds(second), true, ms);
+ private static ProbeResult Miss(int second) => new(T0.AddSeconds(second), false, null, "TimedOut");
+
+ [Fact]
+ public void Stats_cover_only_the_window()
+ {
+ var h = new PingHistory();
+ h.Add(Ok(0, 100)); // outside a 10 s window at second 20
+ h.Add(Ok(15, 2));
+ h.Add(Miss(16));
+ h.Add(Ok(17, 4));
+ h.Add(Ok(18, 6));
+
+ var s = h.Stats(T0.AddSeconds(20), TimeSpan.FromSeconds(10));
+ Assert.Equal(4, s.Sent);
+ Assert.Equal(1, s.Lost);
+ Assert.Equal(25, s.LossPercent);
+ Assert.Equal(2, s.MinMs);
+ Assert.Equal(4, s.AvgMs);
+ Assert.Equal(6, s.MaxMs);
+ Assert.Equal("4 ms avg (2 ms to 6 ms), 25% loss", s.Describe());
+ }
+
+ [Fact]
+ public void Stats_with_no_replies_say_so()
+ {
+ var s = PingHistory.Compute([Miss(0), Miss(1)]);
+ Assert.Null(s.AvgMs);
+ Assert.Equal(100, s.LossPercent);
+ Assert.Equal("No replies to 2 pings", s.Describe());
+ Assert.Equal("No pings yet", PingHistory.Compute([]).Describe());
+ Assert.Equal("<1 ms", PingStats.Ms(0.4));
+ }
+
+ [Fact]
+ public void Keeps_only_the_newest_samples()
+ {
+ var h = new PingHistory(capacity: 3);
+ for (int i = 0; i < 5; i++) h.Add(Ok(i, i));
+ Assert.Equal([2.0, 3.0, 4.0], h.Snapshot().Select(s => s.RoundTripMs!.Value));
+ }
+
+ [Fact]
+ public void Reports_down_after_three_misses_and_back_up_with_outage_length()
+ {
+ var h = new PingHistory();
+ Assert.Null(h.Add(Ok(0, 1)));
+ Assert.Null(h.Add(Miss(1)));
+ Assert.Null(h.Add(Miss(2)));
+ var down = h.Add(Miss(3));
+ Assert.NotNull(down);
+ Assert.False(down.Answering);
+ Assert.Equal(T0.AddSeconds(1), down.At);
+ Assert.Null(h.Add(Miss(4)));
+ Assert.Equal(4, h.ConsecutiveFailures);
+
+ var up = h.Add(Ok(6, 2));
+ Assert.NotNull(up);
+ Assert.True(up.Answering);
+ Assert.Equal(TimeSpan.FromSeconds(5), up.OutageLength);
+ Assert.Equal(0, h.ConsecutiveFailures);
+ }
+
+ [Fact]
+ public void A_short_blip_or_a_device_that_never_answered_is_not_reported()
+ {
+ var h = new PingHistory();
+ h.Add(Ok(0, 1));
+ h.Add(Miss(1));
+ h.Add(Miss(2));
+ Assert.Null(h.Add(Ok(3, 1)));
+
+ var never = new PingHistory();
+ for (int i = 0; i < 5; i++) Assert.Null(never.Add(Miss(i)));
+ // First reply from a device that was still booting is not a "back up" event.
+ Assert.Null(never.Add(Ok(6, 1)));
+ }
+}
diff --git a/tests/ProDHCPServer.Core.Tests/ProfileTests.cs b/tests/ProDHCPServer.Core.Tests/ProfileTests.cs
new file mode 100644
index 0000000..66593f8
--- /dev/null
+++ b/tests/ProDHCPServer.Core.Tests/ProfileTests.cs
@@ -0,0 +1,248 @@
+using ProDHCPServer.Core.Dhcp;
+using ProDHCPServer.Core.Net;
+using ProDHCPServer.Core.Profiles;
+using static ProDHCPServer.Core.Tests.Packets;
+
+namespace ProDHCPServer.Core.Tests;
+
+public class ProfileTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(Path.GetTempPath(), "pdhcp-profiles-" + Guid.NewGuid().ToString("N"));
+ private string FilePath => Path.Combine(_dir, "profiles.json");
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
+ }
+
+ [Fact]
+ public void Every_preset_is_valid()
+ {
+ foreach (var preset in ProfilePresets.All)
+ {
+ var (config, errors) = preset.ToConfig();
+ Assert.True(errors.Count == 0, $"{preset.Name}: {string.Join(" ", errors)}");
+ Assert.NotNull(config);
+ Assert.True(preset.IsPreset);
+ Assert.False(string.IsNullOrWhiteSpace(preset.Notes));
+ }
+ Assert.Equal(ProfilePresets.All.Count, ProfilePresets.All.Select(p => p.Name.ToLowerInvariant()).Distinct().Count());
+ }
+
+ [Theory]
+ // Factory addresses from the manufacturers' documentation (see the preset sources).
+ [InlineData("192.168.254.x: Extron", new[] { "192.168.254.254", "192.168.254.250" })]
+ [InlineData("192.168.0.x: Lightware, Christie, Sharp/NEC, Bose", new[] { "192.168.0.100", "192.168.0.10", "192.168.0.160", "192.168.0.40", "192.168.0.80", "192.168.0.176" })]
+ [InlineData("192.168.1.x: Kramer, Atlona, ClickShare, AMX", new[] { "192.168.1.39", "192.168.1.150", "192.168.1.23", "192.168.1.3" })]
+ public void Brand_presets_reach_factory_addresses_without_handing_them_out(string name, string[] factory)
+ {
+ var preset = ProfilePresets.All.Single(p => p.Name == name);
+ var config = preset.ToConfig().Config!;
+ foreach (var ip in factory.Select(IPv4.Parse))
+ {
+ Assert.True(config.InSubnet(ip), $"{ip} should be reachable from {preset.Summary}");
+ Assert.False(config.InPool(ip), $"{ip} must not be handed out by {preset.Name}");
+ Assert.NotEqual(ip, config.ServerIp);
+ }
+ }
+
+ [Fact]
+ public void Brand_presets_cite_the_manufacturer()
+ {
+ foreach (var preset in ProfilePresets.All.Where(p => p.Name != "Bench test (2-minute leases)"))
+ {
+ Assert.NotEmpty(preset.Sources);
+ Assert.All(preset.Sources, s => Assert.StartsWith("https://", s.Url));
+ Assert.All(preset.Sources, s => Assert.True(Uri.IsWellFormedUriString(s.Url, UriKind.Absolute), s.Url));
+ }
+ }
+
+ [Fact]
+ public void Extra_options_reach_the_offer()
+ {
+ var profile = new Profile
+ {
+ Name = "Phones",
+ DomainName = "av.local",
+ NtpServers = "192.168.100.1",
+ TftpServerName = "http://192.168.100.10/",
+ TftpServers = "192.168.100.10, 192.168.100.11",
+ VendorSpecificHex = "01 04 C0 A8 64 0A",
+ CustomOptions = [new CustomOption(160, CustomOptionFormat.Text, "https://prov.example/"), new CustomOption(242, CustomOptionFormat.Hex, "0x01,0x02")],
+ };
+ var (config, errors) = profile.ToConfig();
+ Assert.Empty(errors);
+
+ var engine = new DhcpServerEngine(config!);
+ var offer = engine.Send(Client(DhcpMessageType.Discover, MacA))!.Packet;
+ Assert.Equal("av.local", offer.GetOption(DhcpOptionCode.DomainName)!.AsString());
+ Assert.Equal([IPv4.Parse("192.168.100.1")], offer.GetOption(DhcpOptionCode.NtpServers)!.AsAddresses());
+ Assert.Equal("http://192.168.100.10/", offer.GetOption(DhcpOptionCode.TftpServerName)!.AsString());
+ Assert.Equal(2, offer.GetOption(DhcpOptionCode.TftpServerAddress)!.AsAddresses().Count);
+ Assert.Equal([1, 4, 0xC0, 0xA8, 0x64, 0x0A], offer.GetOption(DhcpOptionCode.VendorSpecific)!.Data);
+ Assert.Equal("https://prov.example/", offer.GetOption(160)!.AsString());
+ Assert.Equal([1, 2], offer.GetOption(242)!.Data);
+ }
+
+ [Theory]
+ [InlineData(53, "is set by the server itself")]
+ [InlineData(3, "has its own field (Gateway)")]
+ [InlineData(1, "is set from the subnet field")]
+ public void Custom_options_cant_replace_managed_ones(byte code, string reason)
+ {
+ var (_, errors) = new Profile { Name = "x", CustomOptions = [new CustomOption(code, CustomOptionFormat.Hex, "01")] }.ToConfig();
+ Assert.Equal($"Option {code} {reason}.", Assert.Single(errors));
+ }
+
+ [Fact]
+ public void Bad_option_values_are_explained()
+ {
+ var (_, errors) = new Profile
+ {
+ Name = "x",
+ VendorSpecificHex = "01 0G",
+ NtpServers = "ntp.local",
+ CustomOptions =
+ [
+ new CustomOption(200, CustomOptionFormat.Addresses, ""),
+ new CustomOption(201, CustomOptionFormat.Text, new string('a', 300)),
+ new CustomOption(202, CustomOptionFormat.Hex, "AA"),
+ new CustomOption(202, CustomOptionFormat.Hex, "BB"),
+ ],
+ }.ToConfig();
+ Assert.Contains("Option 43 \"01 0G\" isn't valid hex. Use pairs of hex digits, like 01 04 C0 A8 64 0A.", errors);
+ Assert.Contains("NTP server \"ntp.local\" isn't a valid IPv4 address.", errors);
+ Assert.Contains("Option 200 is empty.", errors);
+ Assert.Contains("Option 201 is too long (300 bytes; a DHCP option holds 255 at most).", errors);
+ Assert.Contains("Option 202 is listed more than once.", errors);
+ }
+
+ [Theory]
+ [InlineData("0104C0A8", new byte[] { 1, 4, 0xC0, 0xA8 })]
+ [InlineData("01 04 c0 a8", new byte[] { 1, 4, 0xC0, 0xA8 })]
+ [InlineData("01:04-C0,A8", new byte[] { 1, 4, 0xC0, 0xA8 })]
+ [InlineData("0x01 0x4", new byte[] { 1, 4 })]
+ public void Hex_accepts_common_formats(string text, byte[] expected)
+ {
+ Assert.True(Profile.TryParseHex(text, out var bytes));
+ Assert.Equal(expected, bytes);
+ Assert.False(Profile.TryParseHex("ABC", out _));
+ Assert.Equal("01 04 C0 A8", Profile.ToHex([1, 4, 0xC0, 0xA8]));
+ }
+
+ [Fact]
+ public void Save_rename_duplicate_delete_and_reload()
+ {
+ var lib = new ProfileLibrary(FilePath);
+ lib.Load();
+ var mine = new Profile { Name = " Venue A ", ServerIp = "10.0.0.1", PoolStart = "10.0.0.100", PoolEnd = "10.0.0.150" };
+ lib.Save(mine, null);
+ Assert.Equal("Venue A", lib.Find("venue a")!.Name);
+
+ lib.Save(lib.Find("Venue A")! with { Name = "Venue B" }, "Venue A");
+ Assert.Null(lib.Find("Venue A"));
+ var copy = lib.Duplicate(lib.Find("Venue B")!);
+ Assert.Equal("Venue B copy", copy.Name);
+ Assert.Equal("Venue B copy (2)", lib.Duplicate(lib.Find("Venue B")!).Name);
+
+ var presetCopy = lib.Duplicate(ProfilePresets.Default);
+ Assert.Equal("Default copy", presetCopy.Name);
+ Assert.False(presetCopy.IsPreset);
+
+ lib.Delete("Venue B copy (2)");
+ var reloaded = new ProfileLibrary(FilePath);
+ Assert.Null(reloaded.Load());
+ Assert.Equal(["Venue B", "Venue B copy", "Default copy"], reloaded.UserProfiles.Select(p => p.Name));
+ Assert.Equal("10.0.0.1", reloaded.Find("Venue B")!.ServerIp);
+ }
+
+ [Fact]
+ public void Presets_are_protected_and_names_unique()
+ {
+ var lib = new ProfileLibrary(null);
+ lib.Save(new Profile { Name = "Mine" }, null);
+ Assert.Throws(() => lib.Save(new Profile { Name = "default" }, null));
+ Assert.Throws(() => lib.Save(ProfilePresets.Default with { Notes = "changed" }, ProfilePresets.DefaultName));
+ Assert.Throws(() => lib.Save(new Profile { Name = "MINE" }, null));
+ Assert.Throws(() => lib.Save(new Profile { Name = " " }, null));
+ Assert.Throws(() => lib.Delete(ProfilePresets.DefaultName));
+ // Saving under its own name is fine.
+ lib.Save(new Profile { Name = "Mine", LeaseMinutes = 5 }, "mine");
+ Assert.Equal(5, lib.Find("Mine")!.LeaseMinutes);
+ }
+
+ [Fact]
+ public void Export_then_import_round_trips_and_renames_clashes()
+ {
+ var source = new ProfileLibrary(null);
+ source.Save(new Profile
+ {
+ Name = "Phones",
+ TftpServers = "192.168.100.10",
+ CustomOptions = [new CustomOption(160, CustomOptionFormat.Text, "https://prov/")],
+ }, null);
+ var json = ProfileLibrary.Export(source.UserProfiles);
+ Assert.Contains("\"format\": \"ProDHCPServer profiles\"", json);
+ Assert.Contains("\"format\": \"text\"", json);
+ Assert.DoesNotContain("isPreset", json);
+
+ var target = new ProfileLibrary(null);
+ target.Save(new Profile { Name = "Phones" }, null);
+ var result = target.Import(json);
+ Assert.Empty(result.Problems);
+ var imported = Assert.Single(result.Added);
+ Assert.Equal("Phones (2)", imported.Name);
+ Assert.Equal("https://prov/", imported.CustomOptions[0].Value);
+ Assert.Equal("Imported \"Phones (2)\".", result.Describe());
+ }
+
+ [Fact]
+ public void Import_accepts_a_single_profile_and_skips_broken_ones()
+ {
+ var lib = new ProfileLibrary(null);
+ var single = lib.Import("""{ "name": "Solo", "serverIp": "10.1.1.1", "poolStart": "10.1.1.10", "poolEnd": "10.1.1.20" }""");
+ Assert.Equal("Solo", Assert.Single(single.Added).Name);
+
+ var mixed = lib.Import("""
+ { "profiles": [
+ { "name": "Good", "serverIp": "10.2.2.1", "poolStart": "10.2.2.10", "poolEnd": "10.2.2.20" },
+ { "name": "Bad", "serverIp": "10.3.3.1", "poolStart": "10.9.9.10", "poolEnd": "10.9.9.20" },
+ { "name": "" }
+ ] }
+ """);
+ Assert.Equal("Good", Assert.Single(mixed.Added).Name);
+ Assert.Equal(2, mixed.Problems.Count);
+ Assert.StartsWith("Skipped \"Bad\": The pool", mixed.Problems[0]);
+
+ var junk = lib.Import("not json");
+ Assert.Empty(junk.Added);
+ Assert.StartsWith("This isn't a ProDHCPServer profiles file", Assert.Single(junk.Problems));
+ }
+
+ [Fact]
+ public void First_run_turns_changed_phase_1_settings_into_a_profile()
+ {
+ var lib = new ProfileLibrary(FilePath);
+ lib.Load(new AppSettings { ServerIp = "10.10.10.1", PoolStart = "10.10.10.100", PoolEnd = "10.10.10.199", AlwaysBroadcast = true });
+ var migrated = Assert.Single(lib.UserProfiles);
+ Assert.Equal("My settings", migrated.Name);
+ Assert.True(migrated.AlwaysBroadcast);
+ Assert.True(File.Exists(FilePath));
+
+ // Defaults aren't worth a profile, and an existing file is never overwritten by old settings.
+ Assert.Null(ProfileLibrary.FromLegacy(new AppSettings()));
+ var again = new ProfileLibrary(FilePath);
+ again.Load(new AppSettings { ServerIp = "172.16.0.1" });
+ Assert.Equal("10.10.10.1", Assert.Single(again.UserProfiles).ServerIp);
+ }
+
+ [Fact]
+ public void A_damaged_profiles_file_doesnt_stop_the_app()
+ {
+ Directory.CreateDirectory(_dir);
+ File.WriteAllText(FilePath, "{ broken");
+ var lib = new ProfileLibrary(FilePath);
+ Assert.StartsWith("Couldn't read your saved profiles", lib.Load());
+ Assert.Equal(ProfilePresets.All.Count, lib.All.Count());
+ }
+}
diff --git a/tests/ProDHCPServer.Windows.Tests/WindowsIntegrationTests.cs b/tests/ProDHCPServer.Windows.Tests/WindowsIntegrationTests.cs
index 2360ad3..ec98998 100644
--- a/tests/ProDHCPServer.Windows.Tests/WindowsIntegrationTests.cs
+++ b/tests/ProDHCPServer.Windows.Tests/WindowsIntegrationTests.cs
@@ -232,4 +232,26 @@ public async Task Ping_and_port_probe_work_against_localhost()
using var ping = new Ping();
Assert.Equal(IPStatus.Success, (await ping.SendPingAsync(IPAddress.Loopback, 1000)).Status);
}
+
+ [WindowsFact]
+ public async Task Device_monitor_pings_once_a_second_and_logs_nothing_while_answering()
+ {
+ var log = new Core.SessionLog();
+ await using var monitor = new DeviceMonitor(log);
+ var mac = MacAddress.Parse("02:00:00:00:00:01");
+ monitor.Sync([new MonitoredDevice(mac, IPAddress.Loopback, "Loopback")]);
+ await Task.Delay(TimeSpan.FromSeconds(3.5));
+
+ var history = monitor.History(mac);
+ Assert.NotNull(history);
+ var samples = history.Snapshot();
+ output.WriteLine(string.Join(", ", samples.Select(s => $"{s.Success}/{s.RoundTripMs}")));
+ Assert.InRange(samples.Count, 3, 5);
+ Assert.All(samples, s => Assert.True(s.Success));
+ Assert.DoesNotContain(log.Snapshot(), e => e.Category == Core.LogCategory.Device);
+
+ // Dropping the device stops its pinger.
+ monitor.Sync([]);
+ Assert.Null(monitor.History(mac));
+ }
}