diff --git a/App.xaml b/App.xaml
index a9c8f75..49d49f9 100644
--- a/App.xaml
+++ b/App.xaml
@@ -11,8 +11,7 @@
-
-
+
diff --git a/App.xaml.cs b/App.xaml.cs
index c672c00..13e1e71 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -37,6 +37,9 @@ public partial class App : Application
ConfigureServices(services);
_serviceProvider = services.BuildServiceProvider();
+ // Load the persisted language dictionary before any VM or view reads a string.
+ _ = _serviceProvider.GetRequiredService();
+
// Wire runtime-warning hooks on pure Model classes before any config is loaded.
// Keeps the Models layer DI-free while still routing warnings through IAppLogger.
var logger = _serviceProvider.GetRequiredService();
@@ -84,6 +87,11 @@ public partial class App : Application
services.AddSingleton();
services.AddSingleton();
+ // Auto-test orchestrator. Factory indirection breaks the Orchestrator↔MainViewModel
+ // construction cycle (orchestrator needs IAutoTestHost, MainViewModel implements it).
+ services.AddSingleton>(sp => () => sp.GetRequiredService());
+ services.AddSingleton();
+
// ── ViewModels ────────────────────────────────────────────────────────
services.AddSingleton();
diff --git a/CLAUDE.md b/CLAUDE.md
index 16d723c..f51a330 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -39,6 +39,7 @@ Services/
IConfigurationService.cs — XML persistence for all config files
ICalibrationService.cs — Sensor calibration operations
IPdfService.cs — PDF test report generation
+ IAutoTestOrchestrator.cs — Dashboard "Connect & Auto Test" state machine (K-Line → read → unlock → bench on → test)
Services/Impl/
BenchService.cs — Test orchestration, temperature PID, relay control (background Task)
@@ -50,6 +51,7 @@ Services/Impl/
PdfService.cs — QuestPDF report generation (multi-page, charts, verdict)
ReportChartRenderer.cs — SVG chart rendering for PDF tolerance-band visualizations
ReportTheme.cs — Report styling constants
+ AutoTestOrchestrator.cs — Linear auto-test sequence; E-stops past TurningOnBench, aborts cleanly before
Models/
CanBusParameter.cs — CAN parameter model + BenchParameterNames/PumpParameterNames/KlineKeys constants
diff --git a/Converters/PercentToPixelsConverter.cs b/Converters/PercentToPixelsConverter.cs
new file mode 100644
index 0000000..042bbb0
--- /dev/null
+++ b/Converters/PercentToPixelsConverter.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace HC_APTBS.Converters
+{
+ ///
+ /// Maps a 0-100 percentage to a pixel value against a reference height supplied
+ /// via 's parameter. Used by
+ /// to project the
+ /// tolerance band and target marker onto the fixed-height gauge track.
+ ///
+ public sealed class PercentToPixelsConverter : IValueConverter
+ {
+ ///
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ double percent = value switch
+ {
+ double d => d,
+ float f => f,
+ int i => i,
+ _ => 0.0
+ };
+
+ double height = parameter switch
+ {
+ double d => d,
+ string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed,
+ _ => 0.0
+ };
+
+ if (double.IsNaN(percent) || double.IsNaN(height) || height <= 0) return 0.0;
+ return Math.Clamp(percent, 0.0, 100.0) / 100.0 * height;
+ }
+
+ ///
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+ }
+}
diff --git a/HC_APTBS.csproj b/HC_APTBS.csproj
index e7046d5..4cfd1f1 100644
--- a/HC_APTBS.csproj
+++ b/HC_APTBS.csproj
@@ -11,6 +11,9 @@
x64
HC_APTBS
HC_APTBS
+
+ app.manifest
@@ -20,6 +23,26 @@
+
+
+ $(DefineConstants);DEVELOPER_TOOLS
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Infrastructure/Pcan/PcanAdapter.cs b/Infrastructure/Pcan/PcanAdapter.cs
index 56273b7..f95f7c3 100644
--- a/Infrastructure/Pcan/PcanAdapter.cs
+++ b/Infrastructure/Pcan/PcanAdapter.cs
@@ -493,7 +493,7 @@ namespace HC_APTBS.Infrastructure.Pcan
}
else if (param.Name == PumpParameterNames.Rpm)
{
- // RPM is packed in the upper 12 bits across two bytes (two encoding variants).
+ // RPM encoding variants per param.Type — see DecodeRpmValue for details.
param.Value = DecodeRpmValue(data, param);
param.Value = param.GetTransformResult();
}
@@ -623,10 +623,11 @@ namespace HC_APTBS.Infrastructure.Pcan
///
/// Decodes an RPM value from the CAN frame.
- /// Two encoding variants exist depending on param.Type:
+ /// Encoding variants depending on param.Type:
///
- /// - 0/1 — Upper 12 bits: ByteH shifted left 4, ByteL shifted right 4
+ /// - 0/1 — Upper 12 bits: ByteH shifted left 4, ByteL shifted right 4 (low nibble of ByteL holds a counter)
/// - 2 — Lower 12 bits: lower nibble of ByteH as upper bits, ByteL as lower byte
+ /// - 3 — Full 16 bits: ByteH as high byte, ByteL as low byte (no packed counter)
///
///
private static double DecodeRpmValue(byte[] data, CanBusParameter param)
@@ -635,6 +636,7 @@ namespace HC_APTBS.Infrastructure.Pcan
{
0 or 1 => (data[param.ByteH] << 4) | (data[param.ByteL] >> 4),
2 => ((data[param.ByteH] & 0x0F) << 8) | data[param.ByteL],
+ 3 => (data[param.ByteH] << 8) | data[param.ByteL],
_ => 0
};
return raw;
diff --git a/MainWindow.xaml b/MainWindow.xaml
index a893893..cb9a494 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -10,8 +10,7 @@
xmlns:models="clr-namespace:HC_APTBS.Models"
mc:Ignorable="d"
Title="{DynamicResource App.Title}"
- Height="1080" Width="1920"
- WindowState="Maximized"
+ MinHeight="600" MinWidth="900"
WindowStartupLocation="CenterScreen"
FontFamily="Ebrima"
Closing="OnWindowClosing">
@@ -111,7 +110,7 @@
-
+
@@ -121,100 +120,172 @@
-
+
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 17be1a8..2223aa7 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -1,24 +1,233 @@
+using System;
using System.ComponentModel;
+using System.Runtime.InteropServices;
using System.Windows;
+using System.Windows.Controls.Primitives;
+using System.Windows.Interop;
using HC_APTBS.ViewModels;
using Wpf.Ui.Controls;
namespace HC_APTBS;
///
-/// Code-behind for MainWindow — minimal: sets DataContext and forwards the
-/// Closing event to a ViewModel command if needed.
+/// Code-behind for MainWindow. Bridges selection
+/// to in both directions.
+///
+/// Click handling: NavigationViewItem inherits from ButtonBase, so we listen
+/// for the bubbling Click routed event at the NavigationView level. This
+/// avoids NavigationView.SelectionChanged + Navigate(), which require items
+/// to set TargetPageType (we don't — page hosting stays with the TabControl).
+///
+/// Programmatic selection: WPF-UI 3.0.5's NavigationView.SelectedItem is a
+/// CLR property with `protected set` — there is no SelectedItemProperty DP
+/// to set. Instead we drive each item's IsActive flag directly by calling
+/// the public NavigationViewItem.Activate / Deactivate methods, which is
+/// exactly what WPF-UI's own navigation pipeline does internally. This is
+/// what makes the IsActive trigger in the BoxyNavItemTemplate fire.
///
public partial class MainWindow : FluentWindow
{
+ private readonly MainViewModel _viewModel;
+ private NavigationViewItem? _activeItem;
+
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
+
+ // Maximisation is handled in the OnSourceInitialized override below, AFTER
+ // the WM_GETMINMAXINFO hook is installed. Setting WindowState=Maximized in
+ // XAML applies during InitializeComponent and triggers the first maximize
+ // before any hook can run — that's what produces the "bigger than fullscreen
+ // and clipped" symptom on FluentWindow / DPI-scaled displays.
+
+ _viewModel = viewModel;
DataContext = viewModel;
+
+ // Catch every NavigationViewItem click that bubbles up.
+ NavView.AddHandler(
+ ButtonBase.ClickEvent,
+ new RoutedEventHandler(OnNavItemClicked));
+
+#if DEVELOPER_TOOLS
+ InjectDeveloperToolsPage();
+#endif
+
+ Loaded += OnLoaded;
+ viewModel.PropertyChanged += OnViewModelPropertyChanged;
+ }
+
+#if DEVELOPER_TOOLS
+ ///
+ /// Adds the Developer Tools nav item and tab at runtime. Done in code-behind
+ /// rather than XAML so the consumer Release build (which removes the page
+ /// files entirely) has zero references to a type that doesn't exist there.
+ /// The nav item is appended to FooterMenuItems next to Settings; the
+ /// hosting is appended to
+ /// PageTabs at index 6, matching AppPage.Developer.
+ ///
+ private void InjectDeveloperToolsPage()
+ {
+ var navItem = new Wpf.Ui.Controls.NavigationViewItem
+ {
+ Content = "Developer",
+ Tag = AppPage.Developer,
+ TargetPageTag = "Developer",
+ Icon = new Wpf.Ui.Controls.SymbolIcon { Symbol = Wpf.Ui.Controls.SymbolRegular.WrenchScrewdriver24 },
+ };
+ NavView.FooterMenuItems.Add(navItem);
+
+ var page = new Views.Pages.DeveloperPage();
+ page.SetBinding(System.Windows.FrameworkElement.DataContextProperty,
+ new System.Windows.Data.Binding(nameof(MainViewModel.DeveloperPage)));
+ PageTabs.Items.Add(new System.Windows.Controls.TabItem { Content = page });
+ }
+#endif
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ SyncSelectionFromViewModel();
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(MainViewModel.SelectedPage))
+ SyncSelectionFromViewModel();
+ }
+
+ private void OnNavItemClicked(object sender, RoutedEventArgs e)
+ {
+ if (e.OriginalSource is DependencyObject source
+ && FindAncestor(source) is { Tag: AppPage page } item
+ && _viewModel.SelectedPage != page)
+ {
+ _viewModel.SelectedPage = page;
+ }
+ }
+
+ private void SyncSelectionFromViewModel()
+ {
+ var item = FindItemForPage(_viewModel.SelectedPage);
+ if (item is null || ReferenceEquals(_activeItem, item))
+ return;
+
+ // Flip IsActive on the previous and new items. NavigationViewItem
+ // exposes Activate/Deactivate publicly via INavigationViewItem; both
+ // call SetCurrentValue(IsActiveProperty, true/false) internally,
+ // which fires the IsActive trigger in BoxyNavItemTemplate.
+ _activeItem?.Deactivate(NavView);
+ item.Activate(NavView);
+ _activeItem = item;
+ }
+
+ private NavigationViewItem? FindItemForPage(AppPage page)
+ {
+ foreach (var menu in NavView.MenuItems)
+ if (menu is NavigationViewItem nvi && nvi.Tag is AppPage tag && tag == page)
+ return nvi;
+
+ foreach (var footer in NavView.FooterMenuItems)
+ if (footer is NavigationViewItem nvi && nvi.Tag is AppPage tag && tag == page)
+ return nvi;
+
+ return null;
+ }
+
+ private static T? FindAncestor(DependencyObject? start) where T : DependencyObject
+ {
+ for (var node = start; node is not null; node = System.Windows.Media.VisualTreeHelper.GetParent(node))
+ if (node is T match)
+ return match;
+ return null;
}
private void OnWindowClosing(object sender, CancelEventArgs e)
{
// Allow the window to close; services are disposed by the DI container.
}
+
+ // ── Maximised-bounds hook (WM_GETMINMAXINFO) ──────────────────────────────
+
+ private const int WM_GETMINMAXINFO = 0x0024;
+ private const int MONITOR_DEFAULTTONEAREST = 2;
+
+ ///
+ /// Override (instead of event subscription) so the hook is installed at the
+ /// earliest possible point in the window lifecycle, before any subsequent
+ /// WM_GETMINMAXINFO. The maximize state itself is set here too, so it
+ /// runs only after the hook is in place.
+ ///
+ protected override void OnSourceInitialized(EventArgs e)
+ {
+ base.OnSourceInitialized(e);
+ var hwnd = new WindowInteropHelper(this).Handle;
+ HwndSource.FromHwnd(hwnd)?.AddHook(WindowProc);
+ WindowState = WindowState.Maximized;
+ }
+
+ private static IntPtr WindowProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
+ {
+ if (msg == WM_GETMINMAXINFO)
+ {
+ ApplyWorkAreaMaxBounds(hwnd, lParam);
+ handled = true;
+ }
+ return IntPtr.Zero;
+ }
+
+ ///
+ /// Constrains the window's WM_GETMINMAXINFO response to the current monitor's
+ /// work area. Without this hook, a maximised takes the full
+ /// monitor rectangle (including the taskbar) and gains an 8 px "phantom border" past
+ /// each edge, which is what produces the "bigger than full screen and clipped" symptom.
+ ///
+ private static void ApplyWorkAreaMaxBounds(IntPtr hwnd, IntPtr lParam)
+ {
+ var mmi = Marshal.PtrToStructure(lParam);
+ IntPtr monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
+ if (monitor != IntPtr.Zero)
+ {
+ var info = new MONITORINFO { cbSize = Marshal.SizeOf() };
+ if (GetMonitorInfo(monitor, ref info))
+ {
+ var work = info.rcWork;
+ var monitorRect = info.rcMonitor;
+ mmi.ptMaxPosition.x = Math.Abs(work.left - monitorRect.left);
+ mmi.ptMaxPosition.y = Math.Abs(work.top - monitorRect.top);
+ mmi.ptMaxSize.x = Math.Abs(work.right - work.left);
+ mmi.ptMaxSize.y = Math.Abs(work.bottom - work.top);
+ }
+ }
+ Marshal.StructureToPtr(mmi, lParam, true);
+ }
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int dwFlags);
+
+ [DllImport("user32.dll", CharSet = CharSet.Auto)]
+ private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT { public int x; public int y; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RECT { public int left, top, right, bottom; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MONITORINFO
+ {
+ public int cbSize;
+ public RECT rcMonitor;
+ public RECT rcWork;
+ public uint dwFlags;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MINMAXINFO
+ {
+ public POINT ptReserved;
+ public POINT ptMaxSize;
+ public POINT ptMaxPosition;
+ public POINT ptMinTrackSize;
+ public POINT ptMaxTrackSize;
+ }
}
diff --git a/Models/BenchConfiguration.cs b/Models/BenchConfiguration.cs
index 8b111d8..ba7c4b0 100644
--- a/Models/BenchConfiguration.cs
+++ b/Models/BenchConfiguration.cs
@@ -59,6 +59,9 @@ namespace HC_APTBS.Models
/// Flasher relay toggle interval (ms).
public int FlasherIntervalMs { get; set; } = 800;
+ /// RPM trend chart update rate on the Pump page (Hz). Drives a fixed-rate timer independent of value changes.
+ public int RpmChartUpdateHz { get; set; } = 15;
+
// ── PID temperature controller ────────────────────────────────────────
/// Proportional gain.
@@ -119,6 +122,16 @@ namespace HC_APTBS.Models
/// When true, the T-in temperature sensor check is bypassed.
public bool DefaultIgnoreTin { get; set; } = true;
+ // ── Auto Test ─────────────────────────────────────────────────────────
+
+ ///
+ /// When true, the Dashboard "Connect & Auto Test" flow bypasses the oil-pump
+ /// leak-check dialog. Operator accepts that responsibility up front by enabling
+ /// this toggle. Does not affect the manual Bench page oil-pump toggle, which
+ /// always shows the dialog.
+ ///
+ public bool AutoTestSkipsOilPumpConfirm { get; set; }
+
// ── Log rotation ──────────────────────────────────────────────────────
/// Number of daily log files to retain before the oldest is deleted.
diff --git a/Models/BipStatusDefinition.cs b/Models/BipStatusDefinition.cs
index f74f1ce..d8c9c68 100644
--- a/Models/BipStatusDefinition.cs
+++ b/Models/BipStatusDefinition.cs
@@ -6,10 +6,7 @@ namespace HC_APTBS.Models
// BIP (Begin of Injection Period) status definitions.
// Applies only to pre-injection PSG5-PI pumps (Type 2 T15xxx / Type 3 T18xxx Ford).
// Null on standard VP44 pumps.
- //
- // Data model only in this revision: runtime polling of a BIP capture source
- // (KWP RAM read of ADR-S_BIP_HW_UW / 0x0106, or a dedicated CAN frame) is not
- // yet wired. PumpBipDefinition.Match(ushort) is the seam for future work.
+ // Data source: KWP RAM read of ADR-S_BIP_HW_UW at address 0x0106 (16-bit unsigned).
///
/// One BIP-STATUS entry: a 16-bit HEX pattern matched against the captured BIP
@@ -17,6 +14,9 @@ namespace HC_APTBS.Models
///
public class BipStatusDefinition
{
+ /// Zero-based definition index (BIP-STATUS0 = 0 … BIP-STATUS7 = 7).
+ public int Index { get; set; }
+
/// Whether this definition participates in pattern matching.
public bool Enabled { get; set; } = true;
@@ -54,9 +54,11 @@ namespace HC_APTBS.Models
///
/// Returns the first enabled definition whose
- /// exactly equals , or null if none match.
+ /// matches via bitmask semantics: all bits set in the pattern
+ /// must be set in the captured word ((value & pattern) == pattern).
+ /// Pattern 0x0000 matches any value (OK state by convention in the legacy CFG).
///
public BipStatusDefinition? Match(ushort value) =>
- Bits.FirstOrDefault(b => b.Enabled && b.HexPattern == value);
+ Bits.FirstOrDefault(b => b.Enabled && (value & b.HexPattern) == b.HexPattern);
}
}
diff --git a/Models/CanBusParameter.cs b/Models/CanBusParameter.cs
index dae9916..4091f86 100644
--- a/Models/CanBusParameter.cs
+++ b/Models/CanBusParameter.cs
@@ -93,6 +93,21 @@ namespace HC_APTBS.Models
///
public bool NeedsUpdate { get; set; }
+ ///
+ /// Raised on the CAN read thread after a decoded frame causes
+ /// to change. The decoder compares post-filter values and only fires on a real
+ /// delta, so handlers that only care about state transitions do not need their own
+ /// change-detection. Handlers run on the CAN read thread — they must not block and
+ /// must marshal to the UI thread themselves if they touch WPF state.
+ ///
+ public event Action? ValueChanged;
+
+ ///
+ /// Invokes . Intended to be called by the CAN decoder
+ /// after a value update; internal so other layers cannot raise it spuriously.
+ ///
+ internal void RaiseValueChanged() => ValueChanged?.Invoke(this);
+
///
/// True for receive-direction params (decoded from incoming CAN frames).
/// False for transmit-direction params (packed into outgoing frames).
diff --git a/Models/CustomCommand.cs b/Models/CustomCommand.cs
new file mode 100644
index 0000000..655c804
--- /dev/null
+++ b/Models/CustomCommand.cs
@@ -0,0 +1,23 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace HC_APTBS.Models
+{
+ ///
+ /// A named, user-saved KWP custom command. Persisted in
+ /// %UserProfile%\.HC_APTBS\config\custom_commands.xml and managed by the
+ /// Developer Tools page's saved-commands library.
+ ///
+ /// stores the raw payload as a space-separated
+ /// hex string (e.g. "19 02 00 44") — the same format the Developer
+ /// page's hex parser accepts. Round-trip parsing happens at send time, not
+ /// here, so the user can edit the string in place.
+ ///
+ public sealed partial class CustomCommand : ObservableObject
+ {
+ /// Display name shown in the saved-commands list.
+ [ObservableProperty] private string _name = string.Empty;
+
+ /// Space-separated hex bytes that form the KWP custom payload.
+ [ObservableProperty] private string _hexBytes = string.Empty;
+ }
+}
diff --git a/Models/EepromPassword.cs b/Models/EepromPassword.cs
new file mode 100644
index 0000000..0c861b3
--- /dev/null
+++ b/Models/EepromPassword.cs
@@ -0,0 +1,26 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace HC_APTBS.Models
+{
+ ///
+ /// A named EEPROM unlock password. Persisted in
+ /// %UserProfile%\.HC_APTBS\config\eeprom_passwords.xml and managed by
+ /// the Developer Tools page's password library.
+ ///
+ /// Applying a password sends the KWP unlock packet
+ /// [0x18 0x00 Zone KeyHi KeyLo] over the persistent K-Line session.
+ /// Keys vary across pump variants, so users typically include the variant
+ /// in to disambiguate.
+ ///
+ public sealed partial class EepromPassword : ObservableObject
+ {
+ /// Display name shown in the password list (e.g. "Bosch v2 — Zone 0").
+ [ObservableProperty] private string _name = string.Empty;
+
+ /// EEPROM zone identifier (typically 0–3 plus 8 for the magic zone).
+ [ObservableProperty] private byte _zone;
+
+ /// 16-bit unlock key. Big-endian on the wire (KeyHi then KeyLo).
+ [ObservableProperty] private ushort _key;
+ }
+}
diff --git a/Models/PumpDefinition.cs b/Models/PumpDefinition.cs
index 0afae02..14ad0ad 100644
--- a/Models/PumpDefinition.cs
+++ b/Models/PumpDefinition.cs
@@ -43,6 +43,21 @@ namespace HC_APTBS.Models
/// Lock-angle (shaft timing reference) in degrees.
public string Chaveta { get; set; } = string.Empty;
+ ///
+ /// Alternative K-Line pump-IDs that should resolve to this canonical pump.
+ /// Used when the ECU reports a Bosch number (e.g. 1093423001) that differs from
+ /// this pump's canonical (e.g. 0470504027). Loaded from the
+ /// per-pump <Aliases><KlineId> entries in pumps.xml.
+ ///
+ public List KlineAliases { get; set; } = new();
+
+ ///
+ /// Alternative ModelReference strings (12-char ECU prefix, e.g. ME190297C150)
+ /// that should resolve to this canonical pump. Loaded from the per-pump
+ /// <Aliases><ModelRef> entries in pumps.xml.
+ ///
+ public List ModelRefAliases { get; set; } = new();
+
// ── Physical parameters ───────────────────────────────────────────────────
///
diff --git a/Models/TestFlowState.cs b/Models/TestFlowState.cs
deleted file mode 100644
index 2efc8bf..0000000
--- a/Models/TestFlowState.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace HC_APTBS.Models
-{
- ///
- /// Sequential state of the Tests page wizard flow.
- /// Advance is gated: Plan → Preconditions (when phases are enabled),
- /// Preconditions → Running (when all required checks pass),
- /// Running → Done (when the bench service reports the test finished).
- /// Back navigation is allowed only between Plan and Preconditions.
- ///
- public enum TestFlowState
- {
- /// Operator selects tests and enables individual phases.
- Plan,
-
- /// Pre-run safety and readiness checklist; Start is hard-blocked until all green.
- Preconditions,
-
- /// Test is executing on the bench. Live phase timeline and measurements.
- Running,
-
- /// Test finished (complete or aborted). Summary and next-step actions.
- Done
- }
-}
diff --git a/Resources/NavStyles.xaml b/Resources/NavStyles.xaml
index 2acd9c3..baff004 100644
--- a/Resources/NavStyles.xaml
+++ b/Resources/NavStyles.xaml
@@ -1,57 +1,11 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Resources/Strings.en.xaml b/Resources/Strings.en.xaml
index 893e835..76135fe 100644
--- a/Resources/Strings.en.xaml
+++ b/Resources/Strings.en.xaml
@@ -39,6 +39,8 @@
Last test: FAIL
Start Test
Requires a selected pump and an open CAN connection.
+ Connect & Auto Test
+ Opens K-Line, reads the pump, unlocks if required, turns on the bench and starts the test in one step.
Stop
EMERGENCY STOP
@@ -71,6 +73,12 @@
Error
No devices found
Bench controller
+
+ Connecting to CAN…
+ Disconnecting CAN…
+ CAN connected
+ CAN disconnected
+ Operation failed
Confirm device change
The {0} session is active. Disconnect?
@@ -180,6 +188,15 @@
FBKW
RPM trend
Active
+ BIP-STATUS
+ BIP:
+ BIP captured, no current fault
+ No BIP within entire detection window
+ No BIP in detection window, MV off (o.k.)
+ No MV drive due to MAB
+ BIP captured, but deviation from characteristic curve too large
+ BIP captured, but too close to HLU switching point
+ No BIP within entire detection window (Ford)
Idling Calibration
Version
Current DFI
@@ -299,16 +316,22 @@
Required:
Test:
Critical
+ Plan — select phases to run
+ estimated
+ All phases
+ Hover a phase for required / send values
+ Cond
+ Meas
+ M/s
+ no measurements
Test started...
Test stopped.
-
- 1. Plan
- 2. Preconditions
- 3. Running
- 4. Done
- Next ▶
- ◀ Back
+
+ Ready to start
+ Not ready
+ Running
+ Select at least one phase to start.
Preconditions
@@ -345,6 +368,7 @@
FAILED
View full results
Run again
+ Clear test data
Abort test?
@@ -443,6 +467,7 @@
UNLOCKED
UNLOCK FAILED
Type {0}
+ Retry unlock
K-Line Fault Codes
@@ -470,6 +495,7 @@
UP tolerance extension:
PFP tolerance extension:
Ignore T-in by default
+ Skip oil-pump confirmation during Auto Test (operator accepts leak-check responsibility up front)
Proportional (P):
Integral (I):
@@ -502,6 +528,7 @@
Pump params (ms):
Blink interval (ms):
Flasher interval (ms):
+ RPM chart rate (Hz):
Users
Manage Users...
@@ -526,6 +553,35 @@
Cannot Remove
Cannot remove the last remaining user.
+
+ Auto Test
+ Running pre-flight checks…
+ Connecting K-Line…
+ Reading pump identification…
+ Unlocking pump…
+ Unlocking pump — {0}
+ Turning on bench…
+ Starting oil pump…
+ Starting test procedure…
+ Test running…
+ Test running — {0}
+ Auto Test completed.
+ Aborted — {0}
+ Cancelled by operator
+ Pre-flight conditions not met
+ Failed to open K-Line session
+ K-Line session lost
+ Failed to read pump identification
+ Pump ID not recognised
+ Immobilizer unlock failed
+ Bench CAN bus lost
+ Pump ECU CAN lost
+ Critical alarm triggered
+ Oil-pump confirmation cancelled by operator
+ Test interrupted
+ Test failed
+ Unexpected error
+
Failed to generate report:\n{0}
Report Error
diff --git a/Resources/Strings.es.xaml b/Resources/Strings.es.xaml
index 2456e50..5c436aa 100644
--- a/Resources/Strings.es.xaml
+++ b/Resources/Strings.es.xaml
@@ -39,6 +39,8 @@
Última prueba: FALLIDA
Iniciar prueba
Requiere una bomba seleccionada y conexión CAN abierta.
+ Conectar y probar
+ Abre K-Line, lee la bomba, desbloquea si procede, enciende el banco y comienza la prueba en un solo paso.
Detener
PARADA DE EMERGENCIA
@@ -71,6 +73,12 @@
Error
Sin dispositivos
Controlador del banco
+
+ Conectando con CAN…
+ Desconectando CAN…
+ CAN conectado
+ CAN desconectado
+ Operación fallida
Confirmar cambio de dispositivo
La sesión {0} está activa. ¿Desea desconectar?
@@ -180,6 +188,15 @@
FBKW
Tendencia RPM
Activo
+ BIP-STATUS
+ BIP:
+ BIP detectado, sin fallo actual
+ Sin BIP en todo el período de detección
+ Sin BIP en período de detección, MV desactivada (o.k.)
+ Sin excitación de MV por MAB
+ BIP detectado, pero desviación excesiva respecto a la curva característica
+ BIP detectado, pero demasiado cerca del punto de conmutación HLU
+ Sin BIP en todo el período de detección (Ford)
Calibración Ralentí
Versión
DFI Actual
@@ -299,16 +316,22 @@
Requerido:
Test:
Crítico
+ Plan — selecciona las fases a ejecutar
+ estimado
+ Todas las fases
+ Pasa el cursor sobre una fase para ver valores
+ Cond
+ Med
+ M/s
+ sin mediciones
Test iniciado...
Test detenido.
-
- 1. Planificar
- 2. Precondiciones
- 3. En ejecución
- 4. Finalizado
- Siguiente ▶
- ◀ Atrás
+
+ Listo para comenzar
+ No listo
+ Ejecutando
+ Selecciona al menos una fase para comenzar.
Precondiciones
@@ -345,6 +368,7 @@
FALLIDO
Ver resultados completos
Ejecutar de nuevo
+ Borrar resultados
¿Abortar el test?
@@ -443,6 +467,7 @@
DESBLOQUEADO
DESBLOQUEO FALLIDO
Tipo {0}
+ Reintentar desbloqueo
Códigos de Falla K-Line
@@ -470,6 +495,7 @@
Extensión tolerancia UP:
Extensión tolerancia PFP:
Ignorar T-in por defecto
+ Omitir confirmación de bomba de aceite en Auto Test (el operario asume la comprobación de fugas)
Proporcional (P):
Integral (I):
@@ -502,6 +528,7 @@
Parámetros bomba (ms):
Intervalo parpadeo (ms):
Intervalo flasher (ms):
+ Tasa gráfico RPM (Hz):
Usuarios
Administrar Usuarios...
@@ -526,6 +553,35 @@
No Se Puede Eliminar
No se puede eliminar el último usuario restante.
+
+ Auto Test
+ Comprobaciones previas…
+ Conectando K-Line…
+ Leyendo identificación de bomba…
+ Desbloqueando bomba…
+ Desbloqueando bomba — {0}
+ Encendiendo banco…
+ Arrancando bomba de aceite…
+ Iniciando procedimiento de prueba…
+ Prueba en curso…
+ Prueba en curso — {0}
+ Auto Test completado.
+ Abortado — {0}
+ Cancelado por el operario
+ No se cumplen las condiciones previas
+ No se pudo abrir la sesión K-Line
+ Sesión K-Line perdida
+ Fallo al leer la identificación de la bomba
+ ID de bomba no reconocida
+ Fallo en el desbloqueo del inmovilizador
+ Bus CAN del banco perdido
+ CAN de la ECU perdido
+ Alarma crítica disparada
+ Confirmación de bomba de aceite cancelada por el operador
+ Prueba interrumpida
+ Prueba fallida
+ Error inesperado
+
Error al generar informe:\n{0}
Error de Informe
diff --git a/Resources/Styles.xaml b/Resources/Styles.xaml
index 1eb08af..e2d903c 100644
--- a/Resources/Styles.xaml
+++ b/Resources/Styles.xaml
@@ -121,7 +121,7 @@
-
+
@@ -211,6 +211,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Services/AutoTestState.cs b/Services/AutoTestState.cs
new file mode 100644
index 0000000..03a10b1
--- /dev/null
+++ b/Services/AutoTestState.cs
@@ -0,0 +1,117 @@
+namespace HC_APTBS.Services
+{
+ ///
+ /// Phases of the Dashboard "Connect & Auto Test" orchestration.
+ /// Drives the inline snackbar label and the orchestrator's internal branching.
+ ///
+ public enum AutoTestState
+ {
+ /// No auto-test sequence is active.
+ Idle = 0,
+
+ /// Pre-flight gate before any hardware action (e.g. operator confirmation).
+ Preflight,
+
+ /// Opening the K-Line session over FTDI.
+ ConnectingKLine,
+
+ /// Reading pump identification/DFI/serial over K-Line.
+ ReadingPump,
+
+ /// Running the immobilizer unlock (Ford VP44 Types 1 and 2).
+ Unlocking,
+
+ /// Energising the electronic relay and starting CAN senders.
+ TurningOnBench,
+
+ /// Energising the oil-pump relay.
+ StartingOilPump,
+
+ /// Kicking off RunTestsAsync.
+ StartingTest,
+
+ /// Test sequence executing; phase/sample updates from BenchService.
+ Running,
+
+ /// Sequence finished successfully.
+ Completed,
+
+ /// Sequence aborted; carries the cause.
+ Aborted,
+ }
+
+ ///
+ /// Reason an auto-test sequence aborted, for snackbar messaging and logging.
+ ///
+ public enum AutoTestFailureReason
+ {
+ /// No failure (default).
+ None = 0,
+
+ /// Operator cancelled from the snackbar or dismissed the pre-flight dialog.
+ UserCancelled,
+
+ /// CanExecute gate failed after click (alarm appeared between render and execute).
+ PreflightDenied,
+
+ /// FTDI port not found or threw.
+ KLineConnectFailed,
+
+ /// K-Line session dropped to Failed state mid-sequence.
+ KLineLost,
+
+ /// Pump identification read failed (exception or result=0).
+ ReadFailed,
+
+ /// K-Line read completed but the pump ID is not in pumps.xml.
+ PumpNotRecognized,
+
+ /// Unlock sequence failed or verification returned locked.
+ UnlockFailed,
+
+ /// Bench CAN liveness dropped mid-sequence.
+ BenchCanLost,
+
+ /// Pump ECU CAN liveness dropped mid-sequence.
+ PumpCanLost,
+
+ /// A critical alarm bit transitioned to active during the sequence.
+ AlarmTriggered,
+
+ /// Operator has not enabled AutoTestSkipsOilPumpConfirm and the auto flow cannot proceed.
+ OilPumpNotConfirmed,
+
+ /// BenchService.RunTestsAsync signalled interrupted=true.
+ TestInterrupted,
+
+ /// BenchService.RunTestsAsync completed with success=false.
+ TestFailed,
+
+ /// Unexpected exception not covered by the categories above.
+ Unexpected,
+ }
+
+ /// Extension helpers for .
+ public static class AutoTestStateExtensions
+ {
+ ///
+ /// True for all intermediate phases (not Idle / Completed / Aborted).
+ /// Used by MainViewModel.CanAutoTest to block re-entry and to pick the button's
+ /// "Cancel" variant.
+ ///
+ public static bool IsRunning(this AutoTestState state) =>
+ state != AutoTestState.Idle &&
+ state != AutoTestState.Completed &&
+ state != AutoTestState.Aborted;
+
+ ///
+ /// True once the bench has been energised, i.e. after a failure the auto-flow
+ /// must request an emergency stop (not just disconnect).
+ ///
+ public static bool IsPastBenchOn(this AutoTestState state) =>
+ state == AutoTestState.TurningOnBench ||
+ state == AutoTestState.StartingOilPump ||
+ state == AutoTestState.StartingTest ||
+ state == AutoTestState.Running;
+ }
+}
diff --git a/Services/IAutoTestOrchestrator.cs b/Services/IAutoTestOrchestrator.cs
new file mode 100644
index 0000000..c52e3e3
--- /dev/null
+++ b/Services/IAutoTestOrchestrator.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using HC_APTBS.Models;
+
+namespace HC_APTBS.Services
+{
+ ///
+ /// Minimal host-side contract supplying runtime context that the
+ /// cannot derive on its own
+ /// (e.g. , which is owned by a ViewModel).
+ /// Implemented by MainViewModel.
+ ///
+ public interface IAutoTestHost
+ {
+ /// Currently selected pump, if any.
+ PumpDefinition? CurrentPump { get; }
+
+ ///
+ /// Ensures the oil-pump relay is energised before the auto-test proceeds.
+ ///
+ /// - Returns true immediately if the pump is already on.
+ /// - When is true, silently
+ /// starts the pump and returns true.
+ /// - Otherwise, presents the leak-check confirmation dialog on the UI
+ /// thread. Returns true if the operator accepts (and the pump
+ /// is started), false if the operator cancels.
+ ///
+ /// Safe to call from the orchestrator's background execution — the host
+ /// marshals to the UI thread internally.
+ ///
+ Task EnsureOilPumpOnAsync(bool skipConfirmation);
+ }
+
+ ///
+ /// Orchestrates the Dashboard "Connect & Auto Test" sequence.
+ ///
+ /// Drives a linear state machine () that
+ /// connects the K-Line, reads the pump identification, runs the immobilizer
+ /// unlock (if required), energises the bench, starts the oil pump, and then
+ /// launches . Monitors liveness and
+ /// alarm transitions throughout so the sequence aborts safely on any failure,
+ /// requesting an emergency stop once the bench is energised.
+ ///
+ public interface IAutoTestOrchestrator
+ {
+ /// Current state of the sequence.
+ AutoTestState State { get; }
+
+ ///
+ /// Raised on every state transition. The optional detail argument
+ /// carries phase-specific information (progress percent, phase name, etc.).
+ /// Marshalling to the UI thread is the subscriber's responsibility.
+ ///
+ event Action? StateChanged;
+
+ ///
+ /// Raised once when the sequence aborts, before transitions
+ /// to .
+ ///
+ event Action? Failed;
+
+ ///
+ /// Runs the full auto-test sequence. Returns true on success,
+ /// false on any abort (failure reason is delivered via ).
+ ///
+ Task RunAsync(CancellationToken ct);
+
+ ///
+ /// Requests a cooperative cancellation of the in-flight sequence.
+ /// Safe to call when is .
+ ///
+ void Cancel();
+ }
+}
diff --git a/Services/IConfigurationService.cs b/Services/IConfigurationService.cs
index 4553a63..2b598c2 100644
--- a/Services/IConfigurationService.cs
+++ b/Services/IConfigurationService.cs
@@ -43,6 +43,28 @@ namespace HC_APTBS.Services
/// Persists a pump definition back to the database.
void SavePump(PumpDefinition pump);
+ // ── Pump equivalence / alias lookup ───────────────────────────────────────
+
+ ///
+ /// Returns the canonical pump ID whose <Aliases><KlineId>
+ /// entries contain , or null if no
+ /// alias matches. Comparison is case-insensitive and trims whitespace.
+ ///
+ string? FindPumpIdByKlineAlias(string klinePumpId);
+
+ ///
+ /// Returns the canonical pump ID whose <Aliases><ModelRef>
+ /// entries equal (case-insensitive, trimmed),
+ /// or null if no alias matches.
+ ///
+ string? FindPumpIdByModelRef(string modelRef);
+
+ ///
+ /// Persists a new K-Line alias under the given canonical pump.
+ /// No-op if the canonical pump does not exist or if the alias is already present.
+ ///
+ void AddKlineAlias(string canonicalPumpId, string klineAlias);
+
// ── Clients ───────────────────────────────────────────────────────────────
/// Sorted client name → contact info dictionary.
@@ -51,6 +73,26 @@ namespace HC_APTBS.Services
/// Persists the client list to clients.xml.
void SaveClients();
+ // ── Developer libraries (saved KWP commands + EEPROM passwords) ──────────
+
+ ///
+ /// User-saved raw KWP custom commands, persisted in custom_commands.xml.
+ /// Populated and edited by the Developer Tools page; not used by production paths.
+ ///
+ ObservableCollection CustomCommands { get; }
+
+ /// Persists to custom_commands.xml.
+ void SaveCustomCommands();
+
+ ///
+ /// User-saved EEPROM unlock passwords (zone + 16-bit key), persisted in
+ /// eeprom_passwords.xml. Populated and edited by the Developer Tools page.
+ ///
+ ObservableCollection EepromPasswords { get; }
+
+ /// Persists to eeprom_passwords.xml.
+ void SaveEepromPasswords();
+
// ── Pump status definitions ───────────────────────────────────────────────
///
diff --git a/Services/IKwpService.cs b/Services/IKwpService.cs
index ea4e098..10c1f75 100644
--- a/Services/IKwpService.cs
+++ b/Services/IKwpService.cs
@@ -136,11 +136,66 @@ namespace HC_APTBS.Services
///
/// Attempts a fast immobilizer unlock by sending a KWP custom command
- /// over an existing K-Line session. Returns if the
- /// command was acknowledged (pump already unlocked),
- /// if it was rejected or no session is active.
+ /// over an existing K-Line session. The RAM address byte written by the
+ /// command is selected by : 1 → 0xA8,
+ /// 2 → 0xE8. Any other value is rejected and returns
+ /// .
///
- Task TryFastUnlockAsync();
+ /// Pump unlock variant (1 or 2).
+ ///
+ /// when the command was acknowledged,
+ /// on NAK, no active session, or unsupported type.
+ ///
+ Task TryFastUnlockAsync(int unlockType);
+
+ // ── Raw custom KWP packet (developer use) ─────────────────────────────────
+
+ ///
+ /// Sends a raw KWP1281 custom packet over the persistent K-Line session and
+ /// returns the bytes of every response packet. The supplied
+ /// is the title byte plus body — framing (length + counter + end byte) is added
+ /// by the lower-level transport.
+ ///
+ /// Returns an empty list when no session is active or the send fails.
+ /// Used by the Developer Tools page; never called from production paths.
+ ///
+ /// Packet bytes excluding length/counter/end framing.
+ /// Cancellation token.
+ /// Each response packet's full byte sequence (length…end inclusive).
+ Task> SendRawCustomAsync(byte[] payload, CancellationToken ct = default);
+
+ ///
+ /// Reads bytes from EEPROM starting at
+ /// over the persistent K-Line session (KWP ReadEeprom command 0x19).
+ /// Returns an empty list when no session is active or the ECU returns NAK.
+ /// Used by the Developer Tools page's dumper; max 13 bytes per call.
+ ///
+ Task> ReadEepromAsync(ushort address, byte count, CancellationToken ct = default);
+
+ ///
+ /// Reads bytes from ROM/EEPROM starting at
+ /// over the persistent K-Line session (KWP ReadRomEeprom command 0x03).
+ /// Valid range 0x0000–0x9FFF. Returns an empty list when no session is active or
+ /// the ECU returns NAK. Used by the Developer Tools page's dumper; max 13 bytes per call.
+ ///
+ Task> ReadRomEepromAsync(ushort address, byte count, CancellationToken ct = default);
+
+ // ── BIP status ────────────────────────────────────────────────────────────
+
+ ///
+ /// Reads the 16-bit BIP status word from ECU RAM address 0x0106
+ /// (ADR-S_BIP_HW_UW) over the persistent K-Line session.
+ /// Returns when no session is active or if the read fails.
+ ///
+ /// Cancellation token.
+ Task ReadBipStatusAsync(CancellationToken ct = default);
+
+ ///
+ /// Raised after a successful call with the
+ /// raw 16-bit BIP status word. Fires on a background thread;
+ /// consumers must marshal to the UI thread.
+ ///
+ event Action? BipStatusRead;
// ── Power cycle callbacks ─────────────────────────────────────────────────
diff --git a/Services/IUnlockService.cs b/Services/IUnlockService.cs
index 90f94ff..811b89c 100644
--- a/Services/IUnlockService.cs
+++ b/Services/IUnlockService.cs
@@ -26,6 +26,15 @@ namespace HC_APTBS.Services
///
event Action? PumpUnlocked;
+ ///
+ /// Raised by the background observer on each unlock→lock transition —
+ /// the symmetric counterpart to . Fires whenever
+ /// the CAN TestUnlock parameter transitions from an unlocked state to a
+ /// locked state (e.g. physical pump swap, power instability). Subscribers
+ /// must marshal to the UI thread themselves.
+ ///
+ event Action? PumpRelocked;
+
///
/// Latched state from the background observer. True when the observer has
/// verified the pump is unlocked; false when the observer is not running
diff --git a/Services/Impl/AutoTestOrchestrator.cs b/Services/Impl/AutoTestOrchestrator.cs
new file mode 100644
index 0000000..2f1f159
--- /dev/null
+++ b/Services/Impl/AutoTestOrchestrator.cs
@@ -0,0 +1,516 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using HC_APTBS.Models;
+
+namespace HC_APTBS.Services.Impl
+{
+ ///
+ /// Linear state-machine orchestrator for the Dashboard "Connect & Auto Test"
+ /// button. Coordinates existing services (,
+ /// , , ,
+ /// ) rather than re-implementing any protocol.
+ ///
+ /// Behavioural contract:
+ ///
+ /// - Linear phases: →
+ /// →
+ /// →
+ /// →
+ /// →
+ /// →
+ /// →
+ /// →
+ /// or .
+ /// - Connecting/Reading are skipped when the K-Line is already open and a pump
+ /// is already selected (fast-path for "re-run on the same pump").
+ /// - Unlocking is skipped when the selected pump's
+ /// is 0.
+ /// - When the oil-pump leak-check confirmation has not been disabled via
+ /// , the sequence aborts
+ /// with before the
+ /// relay is energised.
+ /// - Failure past
+ /// triggers ; earlier failures
+ /// close the K-Line and exit cleanly.
+ ///
+ ///
+ public sealed class AutoTestOrchestrator : IAutoTestOrchestrator
+ {
+ private readonly IKwpService _kwp;
+ private readonly ICanService _can;
+ private readonly IBenchService _bench;
+ private readonly IUnlockService _unlock;
+ private readonly IConfigurationService _config;
+ private readonly IAppLogger _log;
+ private readonly Func _hostFactory;
+ private const string LogId = "AutoTestOrch";
+
+ private CancellationTokenSource? _autoCts;
+ private AutoTestState _state = AutoTestState.Idle;
+
+ /// Latest test-phase name observed from .
+ private string? _latestPhaseDetail;
+
+ /// Raised once a failure has been reported; guards against duplicate emits.
+ private bool _failureReported;
+
+ ///
+ public AutoTestState State => _state;
+
+ ///
+ public event Action? StateChanged;
+
+ ///
+ public event Action? Failed;
+
+ ///
+ /// Creates an orchestrator wired to the core services. The
+ /// resolves the lazily so that the orchestrator can be
+ /// constructed by the DI container at the same time as MainViewModel (which
+ /// implements ) without creating a construction-order cycle.
+ ///
+ public AutoTestOrchestrator(
+ IKwpService kwp,
+ ICanService can,
+ IBenchService bench,
+ IUnlockService unlock,
+ IConfigurationService config,
+ IAppLogger log,
+ Func hostFactory)
+ {
+ _kwp = kwp;
+ _can = can;
+ _bench = bench;
+ _unlock = unlock;
+ _config = config;
+ _log = log;
+ _hostFactory = hostFactory;
+ }
+
+ ///
+ public void Cancel() => _autoCts?.Cancel();
+
+ ///
+ public async Task RunAsync(CancellationToken ct)
+ {
+ if (_state.IsRunning())
+ {
+ _log.Warning(LogId, "RunAsync called while a sequence is already in progress");
+ return false;
+ }
+
+ _failureReported = false;
+ _latestPhaseDetail = null;
+
+ _autoCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ var token = _autoCts.Token;
+
+ // ── Abort watchers ────────────────────────────────────────────────────
+ // Subscribe these up-front so any mid-sequence hardware drop-out trips
+ // the CTS immediately. Unsubscribed in the finally block.
+
+ void OnBenchLiveness(bool alive)
+ {
+ if (alive) return;
+ if (_state == AutoTestState.Idle || _state == AutoTestState.Preflight) return;
+ ReportFailure(AutoTestFailureReason.BenchCanLost, "Bench CAN liveness lost");
+ _autoCts?.Cancel();
+ }
+
+ void OnKLineState(KLineConnectionState st)
+ {
+ if (st != KLineConnectionState.Failed) return;
+ // Only treat as loss when K-Line is actually required by the current phase.
+ if (_state is AutoTestState.ConnectingKLine
+ or AutoTestState.ReadingPump
+ or AutoTestState.Unlocking)
+ {
+ ReportFailure(AutoTestFailureReason.KLineLost, "K-Line session dropped");
+ _autoCts?.Cancel();
+ }
+ }
+
+ // Snapshot the alarm mask on entry; any transition that flips a critical bit
+ // to "set" aborts the run. Uses the bench alarm parameter directly so we stay
+ // decoupled from DashboardAlarmsViewModel.
+ int initialMask = ReadAlarmMask();
+ int criticalMask = BuildCriticalAlarmBitmask();
+
+ _can.BenchLivenessChanged += OnBenchLiveness;
+ _kwp.KLineStateChanged += OnKLineState;
+ _bench.PhaseChanged += OnPhaseChanged;
+
+ using var alarmWatch = StartAlarmWatchdog(token, initialMask, criticalMask);
+
+ try
+ {
+ // ── Preflight ────────────────────────────────────────────────────
+ SetState(AutoTestState.Preflight);
+ if (!_can.IsConnected)
+ {
+ ReportFailure(AutoTestFailureReason.PreflightDenied, "CAN bus not connected");
+ return false;
+ }
+ if ((ReadAlarmMask() & criticalMask) != 0)
+ {
+ ReportFailure(AutoTestFailureReason.PreflightDenied, "Critical alarm already active");
+ return false;
+ }
+
+ token.ThrowIfCancellationRequested();
+
+ var host = _hostFactory();
+
+ bool klineAlreadyOpen =
+ _kwp.KLineState == KLineConnectionState.Connected &&
+ host.CurrentPump != null;
+
+ // ── ConnectingKLine ──────────────────────────────────────────────
+ if (!klineAlreadyOpen)
+ {
+ SetState(AutoTestState.ConnectingKLine);
+ string? port = _kwp.DetectKLinePort();
+ if (string.IsNullOrEmpty(port))
+ {
+ ReportFailure(AutoTestFailureReason.KLineConnectFailed,
+ "FTDI adapter not found");
+ return false;
+ }
+
+ // ConnectAsync opens the session and starts the keep-alive loop.
+ // If the session is already open the service returns immediately.
+ try
+ {
+ await _kwp.ConnectAsync(port, token).ConfigureAwait(true);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ ReportFailure(AutoTestFailureReason.KLineConnectFailed,
+ $"ConnectAsync failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ // ── ReadingPump ──────────────────────────────────────────────────
+ if (!klineAlreadyOpen)
+ {
+ SetState(AutoTestState.ReadingPump);
+ int version = host.CurrentPump?.KwpVersion ?? 0;
+ string? port = _kwp.ConnectedPort ?? _kwp.DetectKLinePort();
+ if (string.IsNullOrEmpty(port))
+ {
+ ReportFailure(AutoTestFailureReason.KLineConnectFailed,
+ "FTDI adapter disappeared before read");
+ return false;
+ }
+
+ // Forward ReadAllInfoAsync percentage ticks to the snackbar.
+ void OnProgress(int pct, string _) =>
+ RaiseStateChanged(AutoTestState.ReadingPump, pct.ToString());
+ _kwp.ProgressChanged += OnProgress;
+
+ System.Collections.Generic.Dictionary info;
+ try
+ {
+ info = await _kwp.ReadAllInfoAsync(port, version, token).ConfigureAwait(true);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ ReportFailure(AutoTestFailureReason.ReadFailed,
+ $"ReadAllInfoAsync threw: {ex.Message}");
+ return false;
+ }
+ finally
+ {
+ _kwp.ProgressChanged -= OnProgress;
+ }
+
+ if (!info.TryGetValue(KlineKeys.Result, out var result) || result != "1")
+ {
+ ReportFailure(AutoTestFailureReason.ReadFailed,
+ info.TryGetValue(KlineKeys.ConnectError, out var err) ? err : "Read result=0");
+ return false;
+ }
+
+ // The KwpService.PumpIdentified event fires mid-read on the background
+ // thread and is marshalled to the UI thread by PumpIdentificationViewModel,
+ // which sets SelectedPumpId → CurrentPump via a synchronous side-effect
+ // chain. After await completes, CurrentPump is therefore populated —
+ // unless the K-Line pump ID was not in pumps.xml.
+ if (host.CurrentPump == null)
+ {
+ ReportFailure(AutoTestFailureReason.PumpNotRecognized,
+ info.TryGetValue(KlineKeys.PumpId, out var kid)
+ ? $"Pump ID '{kid}' not in database"
+ : "Pump ID not recognised");
+ return false;
+ }
+ }
+
+ var pump = host.CurrentPump;
+ if (pump == null)
+ {
+ ReportFailure(AutoTestFailureReason.PumpNotRecognized, "No pump selected");
+ return false;
+ }
+
+ token.ThrowIfCancellationRequested();
+
+ // ── Unlocking ────────────────────────────────────────────────────
+ // When the pump was auto-selected during the read, MainViewModel.OnPumpChanged
+ // already started UnlockService.UnlockAsync AND the 1 s observer in the
+ // background. We wait on whichever fires first:
+ // - PumpUnlocked (observer confirmed the CAN TestUnlock param flipped),
+ // - UnlockCompleted (the service's own UnlockAsync finished).
+ // The observer race-guards against the case where the pump auto-unlocks
+ // (fast unlock shortcut or an external manual unlock) before we subscribe.
+ if (pump.UnlockType != 0)
+ {
+ SetState(AutoTestState.Unlocking);
+
+ // Race-guard short-circuit: if the observer already latched an
+ // unlocked state (fast unlock finished while we were still doing
+ // the K-Line read), skip straight past the Unlocking wait.
+ if (_unlock.IsPumpUnlocked)
+ {
+ RaiseStateChanged(AutoTestState.Unlocking, "Pump already unlocked");
+ }
+ else
+ {
+ var unlockTcs = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void OnUnlockStatus(string msg) =>
+ RaiseStateChanged(AutoTestState.Unlocking, msg);
+ void OnUnlockCompleted(bool success) => unlockTcs.TrySetResult(success);
+ // Observer fires as soon as the CAN TestUnlock parameter reports
+ // unlocked — this covers fast unlock and external unlocks that
+ // would otherwise only be observed when UnlockAsync itself finishes.
+ void OnPumpUnlocked() => unlockTcs.TrySetResult(true);
+
+ _unlock.StatusChanged += OnUnlockStatus;
+ _unlock.UnlockCompleted += OnUnlockCompleted;
+ _unlock.PumpUnlocked += OnPumpUnlocked;
+
+ using var ctReg = token.Register(() => unlockTcs.TrySetCanceled());
+
+ bool unlocked;
+ try
+ {
+ // Re-check after subscribing to close the subscribe-vs-fire race.
+ if (_unlock.IsPumpUnlocked)
+ unlockTcs.TrySetResult(true);
+
+ unlocked = await unlockTcs.Task.ConfigureAwait(true);
+ }
+ finally
+ {
+ _unlock.StatusChanged -= OnUnlockStatus;
+ _unlock.UnlockCompleted -= OnUnlockCompleted;
+ _unlock.PumpUnlocked -= OnPumpUnlocked;
+ }
+
+ if (!unlocked)
+ {
+ ReportFailure(AutoTestFailureReason.UnlockFailed, "Unlock verification failed");
+ return false;
+ }
+ }
+ }
+
+ token.ThrowIfCancellationRequested();
+
+ // ── TurningOnBench ───────────────────────────────────────────────
+ // Past this point any failure must request an emergency stop.
+ SetState(AutoTestState.TurningOnBench);
+ _bench.SetRelay(RelayNames.Electronic, true);
+ _bench.SetRpm(0);
+
+ token.ThrowIfCancellationRequested();
+
+ // ── StartingOilPump ──────────────────────────────────────────────
+ // Delegate to the UI host: handles already-on short-circuit, the
+ // autoskip setting, and the leak-check dialog. Returns false only
+ // when the operator actively cancels the confirmation dialog.
+ SetState(AutoTestState.StartingOilPump);
+ bool oilPumpReady = await _hostFactory()
+ .EnsureOilPumpOnAsync(_config.Settings.AutoTestSkipsOilPumpConfirm)
+ .ConfigureAwait(true);
+ if (!oilPumpReady)
+ {
+ ReportFailure(AutoTestFailureReason.OilPumpNotConfirmed,
+ string.Empty);
+ return false;
+ }
+
+ token.ThrowIfCancellationRequested();
+
+ // ── StartingTest / Running ───────────────────────────────────────
+ SetState(AutoTestState.StartingTest);
+
+ var testTcs = new TaskCompletionSource<(bool interrupted, bool success)>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void OnTestStarted() => SetState(AutoTestState.Running, _latestPhaseDetail);
+ void OnTestFinished(bool interrupted, bool success)
+ => testTcs.TrySetResult((interrupted, success));
+ void OnVerbose(string msg) => RaiseStateChanged(AutoTestState.Running, msg);
+
+ _bench.TestStarted += OnTestStarted;
+ _bench.TestFinished += OnTestFinished;
+ _bench.VerboseMessage += OnVerbose;
+
+ try
+ {
+ // RunTestsAsync runs its sequence on a background task internally;
+ // we wait on TestFinished so we observe success/interruption state.
+ await _bench.RunTestsAsync(pump, token).ConfigureAwait(true);
+
+ // RunTestsAsync returns once the background task completes, but the
+ // TestFinished event is the authoritative source for interrupted/success.
+ var result = await testTcs.Task.ConfigureAwait(true);
+
+ if (result.interrupted)
+ {
+ ReportFailure(AutoTestFailureReason.TestInterrupted, "Test interrupted");
+ return false;
+ }
+ if (!result.success)
+ {
+ ReportFailure(AutoTestFailureReason.TestFailed, "Test failed");
+ return false;
+ }
+ }
+ finally
+ {
+ _bench.TestStarted -= OnTestStarted;
+ _bench.TestFinished -= OnTestFinished;
+ _bench.VerboseMessage -= OnVerbose;
+ }
+
+ SetState(AutoTestState.Completed);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ if (!_failureReported)
+ ReportFailure(AutoTestFailureReason.UserCancelled, "Cancelled");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"Unexpected exception: {ex}");
+ if (!_failureReported)
+ ReportFailure(AutoTestFailureReason.Unexpected, ex.Message);
+ return false;
+ }
+ finally
+ {
+ _can.BenchLivenessChanged -= OnBenchLiveness;
+ _kwp.KLineStateChanged -= OnKLineState;
+ _bench.PhaseChanged -= OnPhaseChanged;
+
+ // E-stop only if we failed past bench-on.
+ if (_state.IsPastBenchOn() && _state != AutoTestState.Completed)
+ {
+ try { _bench.RequestEmergencyStop("Auto-test aborted"); }
+ catch (Exception ex) { _log.Error(LogId, $"E-stop failed: {ex.Message}"); }
+ }
+
+ if (_state != AutoTestState.Completed && _state != AutoTestState.Aborted)
+ SetState(AutoTestState.Aborted);
+
+ _autoCts?.Dispose();
+ _autoCts = null;
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private void SetState(AutoTestState next, string? detail = null)
+ {
+ _state = next;
+ RaiseStateChanged(next, detail);
+ }
+
+ private void RaiseStateChanged(AutoTestState s, string? detail)
+ => StateChanged?.Invoke(s, detail);
+
+ private void ReportFailure(AutoTestFailureReason reason, string message)
+ {
+ if (_failureReported) return;
+ _failureReported = true;
+ _log.Warning(LogId, $"Failed: {reason} — {message}");
+ Failed?.Invoke(reason, message);
+ }
+
+ private void OnPhaseChanged(string phaseName)
+ {
+ _latestPhaseDetail = phaseName;
+ if (_state == AutoTestState.Running)
+ RaiseStateChanged(AutoTestState.Running, phaseName);
+ }
+
+ private int ReadAlarmMask()
+ {
+ try { return (int)_bench.ReadBenchParameter(BenchParameterNames.Alarms); }
+ catch { return 0; }
+ }
+
+ private int BuildCriticalAlarmBitmask()
+ {
+ int mask = 0;
+ foreach (var a in _config.Settings.Alarms)
+ if (a.IsCritical) mask |= 1 << a.Bit;
+ return mask;
+ }
+
+ ///
+ /// Starts a lightweight task that polls the bench alarm word and aborts the
+ /// run if any critical bit transitions from clear to set.
+ ///
+ private IDisposable StartAlarmWatchdog(CancellationToken token, int initialMask, int criticalMask)
+ {
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
+ int lastMask = initialMask;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ await Task.Delay(250, cts.Token).ConfigureAwait(false);
+ int now = ReadAlarmMask();
+ int newlySet = now & ~lastMask;
+ lastMask = now;
+ if ((newlySet & criticalMask) != 0)
+ {
+ ReportFailure(AutoTestFailureReason.AlarmTriggered,
+ "Critical alarm transitioned active");
+ _autoCts?.Cancel();
+ return;
+ }
+ }
+ }
+ catch (OperationCanceledException) { /* expected */ }
+ }, cts.Token);
+
+ return new Disposer(cts);
+ }
+
+ private sealed class Disposer : IDisposable
+ {
+ private readonly CancellationTokenSource _cts;
+ public Disposer(CancellationTokenSource cts) { _cts = cts; }
+ public void Dispose()
+ {
+ try { _cts.Cancel(); } catch { }
+ _cts.Dispose();
+ }
+ }
+ }
+}
diff --git a/Services/Impl/ConfigurationService.cs b/Services/Impl/ConfigurationService.cs
index 933cef9..e96ce63 100644
--- a/Services/Impl/ConfigurationService.cs
+++ b/Services/Impl/ConfigurationService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -22,13 +23,15 @@ namespace HC_APTBS.Services.Impl
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".HC_APTBS", "config");
- private string ConfigXml => Path.Combine(ConfigFolder, "config.xml");
- private string PumpsXml => Path.Combine(ConfigFolder, "pumps.xml");
- private string BenchXml => Path.Combine(ConfigFolder, "bench.xml");
- private string SensorsXml => Path.Combine(ConfigFolder, "sensors.xml");
- private string ClientsXml => Path.Combine(ConfigFolder, "clients.xml");
- private string AlarmsXml => Path.Combine(ConfigFolder, "alarms.xml");
- private string StatusXml => Path.Combine(ConfigFolder, "status.xml");
+ private string ConfigXml => Path.Combine(ConfigFolder, "config.xml");
+ private string PumpsXml => Path.Combine(ConfigFolder, "pumps.xml");
+ private string BenchXml => Path.Combine(ConfigFolder, "bench.xml");
+ private string SensorsXml => Path.Combine(ConfigFolder, "sensors.xml");
+ private string ClientsXml => Path.Combine(ConfigFolder, "clients.xml");
+ private string AlarmsXml => Path.Combine(ConfigFolder, "alarms.xml");
+ private string StatusXml => Path.Combine(ConfigFolder, "status.xml");
+ private string CustomCommandsXml => Path.Combine(ConfigFolder, "custom_commands.xml");
+ private string EepromPasswordsXml => Path.Combine(ConfigFolder, "eeprom_passwords.xml");
private readonly IAppLogger _log;
private const string LogId = "ConfigurationService";
@@ -38,7 +41,21 @@ namespace HC_APTBS.Services.Impl
private AppSettings? _settings;
private BenchConfiguration? _bench;
private SortedDictionary? _clients;
+ private ObservableCollection? _customCommands;
+ private ObservableCollection? _eepromPasswords;
+ // Keyed by StatusID; shared by both status.xml and the pumps.xml fallback.
+ // Two distinct pumps that happen to share the same StatusID integer get the same
+ // table — acceptable given that the 9 known tables are pump-family-shared (same
+ // design as the legacy runtime). Future per-pump-override seam: key on (pumpId, statusId).
private readonly Dictionary _statusCache = new();
+ // Lazily populated on first fallback lookup; null until first pumps.xml parse attempt.
+ private Dictionary? _palabrasStatusCache;
+
+ // Alias indexes: K-Line pumpID alias -> canonical pump ID, and ModelRef alias -> canonical pump ID.
+ // Built once from blocks across all elements; both null until first lookup.
+ // Invalidated (set to null) whenever an alias or pump is persisted.
+ private Dictionary? _klineAliasIndex;
+ private Dictionary? _modelRefAliasIndex;
// ── Constructor ───────────────────────────────────────────────────────────
@@ -89,6 +106,7 @@ namespace HC_APTBS.Services.Impl
new XElement("MaxRpm", Settings.MaxRpm),
new XElement("RightRelayValue", Settings.RightRelayValue),
new XElement("DefaultIgnoreTin", Settings.DefaultIgnoreTin),
+ new XElement("AutoTestSkipsOilPumpConfirm", Settings.AutoTestSkipsOilPumpConfirm),
new XElement("LastRotationDir", Settings.LastRotationDirection),
new XElement("DaysKeepLogs", Settings.DaysKeepLogs),
new XElement("CompanyName", Settings.CompanyName),
@@ -229,6 +247,17 @@ namespace HC_APTBS.Services.Impl
// PumpID child element — GetPumpIds() finds these via Descendants("PumpID").
xpump.Add(new XElement("PumpID", pump.Id));
+ // ── Serialise (equivalence detection) ──
+ if (pump.KlineAliases.Count > 0 || pump.ModelRefAliases.Count > 0)
+ {
+ var xaliasesOut = new XElement("Aliases");
+ foreach (var a in pump.KlineAliases)
+ xaliasesOut.Add(new XElement("KlineId", a));
+ foreach (var a in pump.ModelRefAliases)
+ xaliasesOut.Add(new XElement("ModelRef", a));
+ xpump.Add(xaliasesOut);
+ }
+
// ── Serialise (pump CAN params use legacy P1–P6 format) ──
if (pump.ParametersByName.Count > 0)
{
@@ -255,9 +284,9 @@ namespace HC_APTBS.Services.Impl
{
var b = pump.BipStatus.Bits[i];
xbip.Add(new XElement("Bit",
- new XAttribute("index", i.ToString(CultureInfo.InvariantCulture)),
+ new XAttribute("index", b.Index.ToString(CultureInfo.InvariantCulture)),
new XAttribute("enabled", b.Enabled.ToString().ToLowerInvariant()),
- new XAttribute("pattern", "0x" + b.HexPattern.ToString("X4", CultureInfo.InvariantCulture)),
+ new XAttribute("hex", "0x" + b.HexPattern.ToString("X4", CultureInfo.InvariantCulture)),
new XAttribute("reaction", b.Reaction.ToString(CultureInfo.InvariantCulture)),
new XAttribute("specialFunction", b.SpecialFunction.ToString(CultureInfo.InvariantCulture)),
b.Description));
@@ -282,6 +311,7 @@ namespace HC_APTBS.Services.Impl
pumpsNode.Add(xpump);
xdoc.Save(PumpsXml);
+ InvalidateAliasIndexes();
_log.Info(LogId, $"SavePump({pump.Id}) — saved to pumps.xml.");
}
catch (Exception ex)
@@ -290,6 +320,149 @@ namespace HC_APTBS.Services.Impl
}
}
+ // ── IConfigurationService: Pump equivalence / alias lookup ────────────────
+
+ ///
+ public string? FindPumpIdByKlineAlias(string klinePumpId)
+ {
+ if (string.IsNullOrWhiteSpace(klinePumpId)) return null;
+ EnsureAliasIndexes();
+ return _klineAliasIndex!.TryGetValue(klinePumpId.Trim(), out var canonical)
+ ? canonical : null;
+ }
+
+ ///
+ public string? FindPumpIdByModelRef(string modelRef)
+ {
+ if (string.IsNullOrWhiteSpace(modelRef)) return null;
+ EnsureAliasIndexes();
+ return _modelRefAliasIndex!.TryGetValue(modelRef.Trim(), out var canonical)
+ ? canonical : null;
+ }
+
+ ///
+ public void AddKlineAlias(string canonicalPumpId, string klineAlias)
+ {
+ if (string.IsNullOrWhiteSpace(canonicalPumpId) || string.IsNullOrWhiteSpace(klineAlias))
+ return;
+
+ string source = File.Exists(PumpsXml) ? PumpsXml
+ : File.Exists(ConfigXml) ? ConfigXml
+ : null!;
+ if (source == null)
+ {
+ _log.Warning(LogId, $"AddKlineAlias: no pumps.xml/config.xml found.");
+ return;
+ }
+
+ try
+ {
+ var xdoc = XDocument.Load(source);
+ var xpump = xdoc.XPathSelectElement($"/Config/Pumps/Pump[@id='{canonicalPumpId}']");
+ if (xpump == null)
+ {
+ _log.Warning(LogId, $"AddKlineAlias: pump '{canonicalPumpId}' not found.");
+ return;
+ }
+
+ var trimmed = klineAlias.Trim();
+ var xaliases = xpump.Element("Aliases");
+ if (xaliases == null)
+ {
+ xaliases = new XElement("Aliases");
+ xpump.Add(xaliases);
+ }
+ else
+ {
+ foreach (var existing in xaliases.Elements("KlineId"))
+ {
+ if (string.Equals(existing.Value.Trim(), trimmed, StringComparison.OrdinalIgnoreCase))
+ return; // already present
+ }
+ }
+
+ xaliases.Add(new XElement("KlineId", trimmed));
+ xdoc.Save(source);
+ InvalidateAliasIndexes();
+ _log.Info(LogId, $"AddKlineAlias: '{trimmed}' -> '{canonicalPumpId}' persisted.");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"AddKlineAlias({canonicalPumpId}, {klineAlias}) failed: {ex.Message}");
+ }
+ }
+
+ private void InvalidateAliasIndexes()
+ {
+ _klineAliasIndex = null;
+ _modelRefAliasIndex = null;
+ }
+
+ ///
+ /// Builds the K-Line and ModelRef alias indexes by scanning every <Pump>
+ /// element in pumps.xml/config.xml for an <Aliases> block. Cheap enough
+ /// for a one-shot scan: dozens of pumps, a handful of aliases each.
+ ///
+ private void EnsureAliasIndexes()
+ {
+ if (_klineAliasIndex != null && _modelRefAliasIndex != null) return;
+
+ var klineMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var modelRefMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ string source = File.Exists(PumpsXml) ? PumpsXml
+ : File.Exists(ConfigXml) ? ConfigXml
+ : null!;
+ if (source == null)
+ {
+ _klineAliasIndex = klineMap;
+ _modelRefAliasIndex = modelRefMap;
+ return;
+ }
+
+ try
+ {
+ var xdoc = XDocument.Load(source);
+ foreach (var xpump in xdoc.Descendants("Pump"))
+ {
+ var canonical = xpump.Attribute("id")?.Value;
+ if (string.IsNullOrWhiteSpace(canonical)) continue;
+
+ var xaliases = xpump.Element("Aliases");
+ if (xaliases == null) continue;
+
+ foreach (var xkid in xaliases.Elements("KlineId"))
+ {
+ var alias = xkid.Value?.Trim();
+ if (string.IsNullOrEmpty(alias)) continue;
+ if (!klineMap.ContainsKey(alias))
+ klineMap[alias] = canonical;
+ else
+ _log.Warning(LogId,
+ $"Duplicate KlineId alias '{alias}' (already mapped to '{klineMap[alias]}', ignoring duplicate under '{canonical}').");
+ }
+
+ foreach (var xmref in xaliases.Elements("ModelRef"))
+ {
+ var alias = xmref.Value?.Trim();
+ if (string.IsNullOrEmpty(alias)) continue;
+ if (!modelRefMap.ContainsKey(alias))
+ modelRefMap[alias] = canonical;
+ else
+ _log.Warning(LogId,
+ $"Duplicate ModelRef alias '{alias}' (already mapped to '{modelRefMap[alias]}', ignoring duplicate under '{canonical}').");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"EnsureAliasIndexes failed: {ex.Message}");
+ }
+
+ _klineAliasIndex = klineMap;
+ _modelRefAliasIndex = modelRefMap;
+ }
+
// ── IConfigurationService: Clients ────────────────────────────────────────
///
@@ -320,6 +493,65 @@ namespace HC_APTBS.Services.Impl
}
}
+ // ── IConfigurationService: Developer libraries ────────────────────────────
+
+ ///
+ public ObservableCollection CustomCommands
+ {
+ get
+ {
+ if (_customCommands == null) LoadCustomCommands();
+ return _customCommands!;
+ }
+ }
+
+ ///
+ public void SaveCustomCommands()
+ {
+ try
+ {
+ var root = new XElement("CustomCommands");
+ foreach (var cmd in CustomCommands)
+ root.Add(new XElement("command",
+ new XAttribute("name", cmd.Name ?? string.Empty),
+ new XAttribute("hex", cmd.HexBytes ?? string.Empty)));
+ new XDocument(root).Save(CustomCommandsXml);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"SaveCustomCommands failed: {ex.Message}");
+ }
+ }
+
+ ///
+ public ObservableCollection EepromPasswords
+ {
+ get
+ {
+ if (_eepromPasswords == null) LoadEepromPasswords();
+ return _eepromPasswords!;
+ }
+ }
+
+ ///
+ public void SaveEepromPasswords()
+ {
+ try
+ {
+ var root = new XElement("EepromPasswords");
+ foreach (var pw in EepromPasswords)
+ root.Add(new XElement("password",
+ new XAttribute("name", pw.Name ?? string.Empty),
+ new XAttribute("zone", pw.Zone.ToString("X2", CultureInfo.InvariantCulture)),
+ new XAttribute("key", pw.Key.ToString("X4", CultureInfo.InvariantCulture))));
+ new XDocument(root).Save(EepromPasswordsXml);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"SaveEepromPasswords failed: {ex.Message}");
+ }
+ }
+
// ── IConfigurationService: Sensors ────────────────────────────────────────
///
@@ -365,18 +597,33 @@ namespace HC_APTBS.Services.Impl
if (_statusCache.TryGetValue(statusId, out var cached))
return cached;
+ // Palabras (pumps.xml) is authoritative — matches legacy runtime which read
+ // /Config/Palabras/PumpStatus[@StatusID='N'] exclusively. status.xml is a
+ // new-system artifact and in practice carries stale/partial definitions that
+ // collide on shared StatusIDs (e.g. ID=5 labelled EMPF3 in status.xml but
+ // CAN-PSGTEST in palabras). Only fall back to status.xml when palabras is
+ // missing the id entirely.
+ var def = LoadPumpStatusFromPumpsXml(statusId);
+
+ if (def == null)
+ def = LoadPumpStatusFromXml(StatusXml, statusId);
+
+ if (def != null)
+ _statusCache[statusId] = def;
+
+ return def;
+ }
+
+ /// Parses a <PumpStatus StatusID="N"> element from any XML file.
+ private PumpStatusDefinition? LoadPumpStatusFromXml(string xmlPath, int statusId)
+ {
try
{
- if (!File.Exists(StatusXml))
- {
- _log.Error(LogId, $"LoadPumpStatus: {StatusXml} not found.");
- return null;
- }
+ if (!File.Exists(xmlPath)) return null;
- var xdoc = XDocument.Load(StatusXml);
+ var xdoc = XDocument.Load(xmlPath);
if (xdoc.Root == null) return null;
- // Search for in the document.
XElement? xStatus = null;
foreach (var el in xdoc.Root.Descendants("PumpStatus"))
{
@@ -387,52 +634,107 @@ namespace HC_APTBS.Services.Impl
}
}
- if (xStatus == null)
- {
- _log.Error(LogId, $"LoadPumpStatus: StatusID={statusId} not found in status.xml.");
- return null;
- }
-
- var def = new PumpStatusDefinition
- {
- Id = statusId,
- Name = xStatus.Attribute("name")?.Value ?? "-"
- };
-
- foreach (var xState in xStatus.Elements("State"))
- {
- var bit = new StatusBit
- {
- Bit = int.Parse(xState.Attribute("bit")?.Value ?? "0"),
- Enabled = string.Equals(
- xState.Attribute("enabled")?.Value, "true",
- StringComparison.OrdinalIgnoreCase)
- };
-
- foreach (var xVal in xState.Elements("StateValue"))
- {
- bit.Values.Add(new StatusBitValue
- {
- State = int.Parse(xVal.Attribute("value")?.Value ?? "0", CultureInfo.InvariantCulture),
- Color = xVal.Attribute("color")?.Value ?? "26C200",
- Description = xVal.Value.Trim(),
- Reaction = int.Parse(xVal.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture)
- });
- }
-
- def.Bits.Add(bit);
- }
-
- _statusCache[statusId] = def;
- return def;
+ if (xStatus == null) return null;
+ return ParsePumpStatusElement(xStatus, statusId);
}
catch (Exception ex)
{
- _log.Error(LogId, $"LoadPumpStatus({statusId}) failed: {ex.Message}");
+ _log.Error(LogId, $"LoadPumpStatusFromXml({xmlPath}, {statusId}) failed: {ex.Message}");
return null;
}
}
+ ///
+ /// Lazily loads all <PumpStatus> entries from the <Palabras>
+ /// block in pumps.xml and returns the one matching .
+ /// The orphan block lives outside </Pumps> and is not touched by the
+ /// pump loader — this is the only path that reads it.
+ ///
+ private PumpStatusDefinition? LoadPumpStatusFromPumpsXml(int statusId)
+ {
+ // Build the cache on first call.
+ if (_palabrasStatusCache == null)
+ {
+ _palabrasStatusCache = new Dictionary();
+ try
+ {
+ if (!File.Exists(PumpsXml)) return null;
+
+ var xdoc = XDocument.Load(PumpsXml);
+ var palabras = xdoc.Root?.Element("Palabras");
+ if (palabras == null)
+ {
+ // Try top-level sibling — the block is outside .
+ palabras = xdoc.Descendants("Palabras").FirstOrDefault();
+ }
+
+ if (palabras == null)
+ {
+ _log.Error(LogId, "LoadPumpStatusFromPumpsXml: block not found in pumps.xml.");
+ return null;
+ }
+
+ foreach (var el in palabras.Descendants("PumpStatus"))
+ {
+ if (!int.TryParse(el.Attribute("StatusID")?.Value, out var id)) continue;
+ var parsed = ParsePumpStatusElement(el, id);
+ if (parsed != null)
+ _palabrasStatusCache[id] = parsed;
+ else
+ _log.Error(LogId, $"LoadPumpStatusFromPumpsXml: malformed PumpStatus StatusID={id} in pumps.xml.");
+ }
+
+ _log.Info(LogId, $"Loaded {_palabrasStatusCache.Count} pump-status tables from pumps.xml .");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"LoadPumpStatusFromPumpsXml parse failed: {ex.Message}");
+ return null;
+ }
+ }
+
+ _palabrasStatusCache.TryGetValue(statusId, out var def);
+ if (def == null)
+ _log.Error(LogId, $"LoadPumpStatus: StatusID={statusId} not found in status.xml or pumps.xml .");
+ return def;
+ }
+
+ /// Converts a <PumpStatus> XML element into a .
+ private static PumpStatusDefinition ParsePumpStatusElement(XElement xStatus, int statusId)
+ {
+ var def = new PumpStatusDefinition
+ {
+ Id = statusId,
+ Name = xStatus.Attribute("name")?.Value ?? "-"
+ };
+
+ foreach (var xState in xStatus.Elements("State"))
+ {
+ var bit = new StatusBit
+ {
+ Bit = int.Parse(xState.Attribute("bit")?.Value ?? "0"),
+ Enabled = string.Equals(
+ xState.Attribute("enabled")?.Value, "true",
+ StringComparison.OrdinalIgnoreCase)
+ };
+
+ foreach (var xVal in xState.Elements("StateValue"))
+ {
+ bit.Values.Add(new StatusBitValue
+ {
+ State = int.Parse(xVal.Attribute("value")?.Value ?? "0", CultureInfo.InvariantCulture),
+ Color = xVal.Attribute("color")?.Value ?? "26C200",
+ Description = xVal.Value.Trim(),
+ Reaction = int.Parse(xVal.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture)
+ });
+ }
+
+ def.Bits.Add(bit);
+ }
+
+ return def;
+ }
+
// ── Private loaders ───────────────────────────────────────────────────────
private void LoadSettings()
@@ -466,6 +768,7 @@ namespace HC_APTBS.Services.Impl
TryInt(r, "MaxRpm", v => _settings.MaxRpm = v);
TryBool(r, "RightRelayValue", v => _settings.RightRelayValue = v);
TryBool(r, "DefaultIgnoreTin", v => _settings.DefaultIgnoreTin = v);
+ TryBool(r, "AutoTestSkipsOilPumpConfirm", v => _settings.AutoTestSkipsOilPumpConfirm = v);
TryInt(r, "LastRotationDir", v => _settings.LastRotationDirection = (short)v);
TryInt(r, "DaysKeepLogs", v => _settings.DaysKeepLogs = v);
TryString(r, "CompanyName", v => _settings.CompanyName = v);
@@ -599,6 +902,63 @@ namespace HC_APTBS.Services.Impl
}
}
+ private void LoadCustomCommands()
+ {
+ _customCommands = new ObservableCollection();
+ if (!File.Exists(CustomCommandsXml)) return;
+ try
+ {
+ var xdoc = XDocument.Load(CustomCommandsXml);
+ foreach (var xe in xdoc.Root!.Elements("command"))
+ {
+ var name = xe.Attribute("name")?.Value ?? string.Empty;
+ var hex = xe.Attribute("hex")?.Value ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(hex))
+ {
+ _log.Warning(LogId, "LoadCustomCommands: skipping empty element.");
+ continue;
+ }
+ _customCommands.Add(new CustomCommand { Name = name, HexBytes = hex });
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"LoadCustomCommands failed: {ex.Message}");
+ }
+ }
+
+ private void LoadEepromPasswords()
+ {
+ _eepromPasswords = new ObservableCollection();
+ if (!File.Exists(EepromPasswordsXml)) return;
+ try
+ {
+ var xdoc = XDocument.Load(EepromPasswordsXml);
+ foreach (var xe in xdoc.Root!.Elements("password"))
+ {
+ var name = xe.Attribute("name")?.Value ?? string.Empty;
+ var zoneStr = xe.Attribute("zone")?.Value ?? "0";
+ var keyStr = xe.Attribute("key")?.Value ?? "0";
+
+ if (!byte.TryParse(zoneStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte zone))
+ {
+ _log.Warning(LogId, $"LoadEepromPasswords: bad zone '{zoneStr}' in — skipping.");
+ continue;
+ }
+ if (!ushort.TryParse(keyStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ushort key))
+ {
+ _log.Warning(LogId, $"LoadEepromPasswords: bad key '{keyStr}' in — skipping.");
+ continue;
+ }
+ _eepromPasswords.Add(new EepromPassword { Name = name, Zone = zone, Key = key });
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"LoadEepromPasswords failed: {ex.Message}");
+ }
+ }
+
private void LoadAlarms()
{
_settings ??= new AppSettings();
@@ -729,6 +1089,27 @@ namespace HC_APTBS.Services.Impl
}
}
+ // ── Parse (optional — equivalence detection) ────────────────
+ // Per-pump equivalence map: alternative K-Line pumpIDs and ModelReference
+ // strings that should resolve to this canonical pump. See plan in
+ // .claude/plans/in-the-pump-identification-velvety-meteor.md.
+ var xaliases = xpump.Element("Aliases");
+ if (xaliases != null)
+ {
+ foreach (var xkid in xaliases.Elements("KlineId"))
+ {
+ var alias = xkid.Value?.Trim();
+ if (!string.IsNullOrEmpty(alias))
+ pump.KlineAliases.Add(alias);
+ }
+ foreach (var xmref in xaliases.Elements("ModelRef"))
+ {
+ var alias = xmref.Value?.Trim();
+ if (!string.IsNullOrEmpty(alias))
+ pump.ModelRefAliases.Add(alias);
+ }
+ }
+
// ── Parse ─────────────────────────────────────────────────────
var xtests = xpump.Element("Tests");
if (xtests != null)
@@ -752,7 +1133,8 @@ namespace HC_APTBS.Services.Impl
{
try
{
- var patternStr = xbit.Attribute("pattern")?.Value ?? "0";
+ // Accept both "hex" (import script) and legacy "pattern" attribute names.
+ var patternStr = (xbit.Attribute("hex") ?? xbit.Attribute("pattern"))?.Value ?? "0";
if (patternStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
patternStr = patternStr.Substring(2);
@@ -765,6 +1147,7 @@ namespace HC_APTBS.Services.Impl
bipDef.Bits.Add(new BipStatusDefinition
{
+ Index = int.Parse(xbit.Attribute("index")?.Value ?? bipDef.Bits.Count.ToString(), CultureInfo.InvariantCulture),
Enabled = !string.Equals(xbit.Attribute("enabled")?.Value, "false",
StringComparison.OrdinalIgnoreCase),
HexPattern = ushort.Parse(patternStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture),
diff --git a/Services/Impl/KwpService.cs b/Services/Impl/KwpService.cs
index b060996..9446c7d 100644
--- a/Services/Impl/KwpService.cs
+++ b/Services/Impl/KwpService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Infrastructure.Kwp;
@@ -57,6 +58,9 @@ namespace HC_APTBS.Services.Impl
///
public event Action? DfiRead;
+ ///
+ public event Action? BipStatusRead;
+
///
public event Action? PumpDisconnectRequested;
@@ -245,7 +249,10 @@ namespace HC_APTBS.Services.Impl
// Notify subscribers immediately so the pump definition and its
// tests can start loading while the K-Line read continues.
if (!string.IsNullOrEmpty(ident))
+ {
+ _log.Info(LogId, $"PumpIdentified fired: '{ident}'");
PumpIdentified?.Invoke(ident);
+ }
Report(55, "Reading customer change index...");
kwp.KeepAlive();
@@ -380,7 +387,10 @@ namespace HC_APTBS.Services.Impl
result[KlineKeys.PumpId] = ident;
if (!string.IsNullOrEmpty(ident))
+ {
+ _log.Info(LogId, $"PumpIdentified fired (session reuse): '{ident}'");
PumpIdentified?.Invoke(ident);
+ }
Report(55, "Reading customer change index...");
kwp.KeepAlive();
@@ -614,11 +624,24 @@ namespace HC_APTBS.Services.Impl
// ── IKwpService: fast immobilizer unlock ──────────────────────────────────
///
- public async Task TryFastUnlockAsync()
+ public async Task TryFastUnlockAsync(int unlockType)
{
+ byte ramByte = unlockType switch
+ {
+ 1 => 0xA8,
+ 2 => 0xE8,
+ _ => 0x00
+ };
+
+ if (ramByte == 0x00)
+ {
+ _log.Info(LogId, $"TryFastUnlock: unsupported unlockType={unlockType} — skipping");
+ return false;
+ }
+
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
- _log.Info(LogId, "TryFastUnlock: no active K-Line session — skipping");
+ _log.Info(LogId, $"TryFastUnlock(type={unlockType}): no active K-Line session — skipping");
return false;
}
@@ -627,19 +650,19 @@ namespace HC_APTBS.Services.Impl
_busLock.Wait();
try
{
- _log.Info(LogId, "TryFastUnlock: sending unlock command over K-Line");
+ _log.Info(LogId, $"TryFastUnlock(type={unlockType}): sending unlock command (ram=0x{ramByte:X2}) over K-Line");
var packets = _sessionKwp!.SendCustom(
- new List { 0x02, 0x88, 0x02, 0x03, 0xA8, 0x01, 0x00 });
+ new List { 0x02, 0x88, 0x02, 0x03, ramByte, 0x01, 0x00 });
bool nak = packets.Count == 1
&& packets[0] is NakPacket;
- _log.Info(LogId, $"TryFastUnlock: {(nak ? "NAK — pump rejected" : "ACK — pump unlocked")}");
+ _log.Info(LogId, $"TryFastUnlock(type={unlockType}): {(nak ? "NAK — pump rejected" : "ACK — pump unlocked")}");
return !nak;
}
catch (Exception ex)
{
- _log.Warning(LogId, $"TryFastUnlock failed: {ex.Message}");
+ _log.Warning(LogId, $"TryFastUnlock(type={unlockType}) failed: {ex.Message}");
return false;
}
finally
@@ -649,6 +672,154 @@ namespace HC_APTBS.Services.Impl
});
}
+ // ── IKwpService: raw custom packet (developer tools) ──────────────────────
+
+ ///
+ public async Task> SendRawCustomAsync(byte[] payload, CancellationToken ct = default)
+ {
+ if (payload is null || payload.Length == 0)
+ {
+ _log.Info(LogId, "SendRawCustom: empty payload — skipping");
+ return Array.Empty();
+ }
+
+ if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
+ {
+ _log.Info(LogId, "SendRawCustom: no active K-Line session — skipping");
+ return Array.Empty();
+ }
+
+ return await Task.Run>(() =>
+ {
+ _busLock.Wait(ct);
+ try
+ {
+ var hex = string.Join(" ", payload.Select(b => b.ToString("X2")));
+ _log.Info(LogId, $"SendRawCustom: TX {hex}");
+ var packets = _sessionKwp!.SendCustom(payload.ToList());
+ var response = packets.Select(p => p.Bytes.ToArray()).ToArray();
+ _log.Info(LogId, $"SendRawCustom: RX {response.Length} packet(s)");
+ return response;
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"SendRawCustom failed: {ex.Message}");
+ return Array.Empty();
+ }
+ finally
+ {
+ _busLock.Release();
+ }
+ }, ct);
+ }
+
+ // ── IKwpService: typed read primitives (developer tools) ──────────────────
+
+ ///
+ public async Task> ReadEepromAsync(ushort address, byte count, CancellationToken ct = default)
+ {
+ if (count == 0) return Array.Empty();
+ if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
+ {
+ _log.Info(LogId, "ReadEeprom: no active K-Line session — skipping");
+ return Array.Empty();
+ }
+
+ return await Task.Run>(() =>
+ {
+ _busLock.Wait(ct);
+ try
+ {
+ var bytes = _sessionKwp!.ReadEeprom(address, count);
+ return bytes != null ? (IReadOnlyList)bytes : Array.Empty();
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"ReadEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
+ return Array.Empty();
+ }
+ finally
+ {
+ _busLock.Release();
+ }
+ }, ct);
+ }
+
+ ///
+ public async Task> ReadRomEepromAsync(ushort address, byte count, CancellationToken ct = default)
+ {
+ if (count == 0) return Array.Empty();
+ if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
+ {
+ _log.Info(LogId, "ReadRomEeprom: no active K-Line session — skipping");
+ return Array.Empty();
+ }
+
+ return await Task.Run>(() =>
+ {
+ _busLock.Wait(ct);
+ try
+ {
+ var bytes = _sessionKwp!.ReadRomEeprom(address, count);
+ return bytes != null ? (IReadOnlyList)bytes : Array.Empty();
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"ReadRomEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
+ return Array.Empty();
+ }
+ finally
+ {
+ _busLock.Release();
+ }
+ }, ct);
+ }
+
+ // ── IKwpService: BIP status ───────────────────────────────────────────────
+
+ ///
+ public async Task ReadBipStatusAsync(CancellationToken ct = default)
+ {
+ if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
+ return null;
+
+ return await Task.Run(() =>
+ {
+ _busLock.Wait(ct);
+ try
+ {
+ // ReadEeprom (0x19), 2 bytes, at RAM address 0x0106 (ADR-S_BIP_HW_UW).
+ // Byte order is little-endian, consistent with ReadCustomerChangeAddress.
+ var packets = _sessionKwp!.SendCustom(
+ new List { (byte)PacketCommand.ReadEeprom, 0x02, 0x01, 0x06 });
+
+ foreach (var pkt in packets)
+ {
+ if (pkt is ReadEepromResponsePacket && pkt.Body.Count >= 2)
+ {
+ ushort word = (ushort)((pkt.Body[1] << 8) | pkt.Body[0]);
+ _log.Debug(LogId, $"ReadBipStatus: 0x{word:X4}");
+ BipStatusRead?.Invoke(word);
+ return (ushort?)word;
+ }
+ }
+
+ _log.Warning(LogId, "ReadBipStatus: no ReadEepromResponse received");
+ return null;
+ }
+ catch (OperationCanceledException) { return null; }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"ReadBipStatus failed: {ex.Message}");
+ return null;
+ }
+ finally
+ {
+ _busLock.Release();
+ }
+ }, ct);
+ }
+
// ── IKwpService: device detection ────────────────────────────────────────
///
diff --git a/Services/Impl/LocalizationService.cs b/Services/Impl/LocalizationService.cs
index 14b2bc6..26495b9 100644
--- a/Services/Impl/LocalizationService.cs
+++ b/Services/Impl/LocalizationService.cs
@@ -1,5 +1,6 @@
using System;
using System.Windows;
+using HC_APTBS.Infrastructure.Logging;
namespace HC_APTBS.Services.Impl
{
@@ -14,11 +15,19 @@ namespace HC_APTBS.Services.Impl
///
public sealed class LocalizationService : ILocalizationService
{
+ private const string LogId = "LOCALIZATION";
private const string EspUri = "pack://application:,,,/Resources/Strings.es.xaml";
private const string EngUri = "pack://application:,,,/Resources/Strings.en.xaml";
private readonly IConfigurationService _config;
- private ResourceDictionary? _currentDictionary;
+ private readonly IAppLogger _log;
+
+ // Single, persistent slot in Application.Resources.MergedDictionaries whose
+ // Source we re-point on every language change. Mutating an existing
+ // dictionary's Source triggers WPF's DynamicResource invalidation reliably,
+ // whereas removing-and-adding entries can leave stale resolved values
+ // depending on the dictionary's parent/owner state.
+ private readonly ResourceDictionary _stringsSlot = new();
///
public string CurrentLanguage { get; private set; } = "ESP";
@@ -30,9 +39,14 @@ namespace HC_APTBS.Services.Impl
/// Initialises the localization service and loads the language
/// stored in .
///
- public LocalizationService(IConfigurationService config)
+ public LocalizationService(IConfigurationService config, IAppLogger log)
{
_config = config;
+ _log = log;
+
+ // Mount the persistent slot once. From here on we only update its Source.
+ Application.Current.Resources.MergedDictionaries.Add(_stringsSlot);
+
// Load persisted language without saving (already persisted).
LoadDictionary(_config.Settings.Language);
}
@@ -41,8 +55,12 @@ namespace HC_APTBS.Services.Impl
public void SetLanguage(string languageCode)
{
var code = NormaliseCode(languageCode);
+ _log.Info(LogId, $"SetLanguage('{languageCode}') -> normalised='{code}', current='{CurrentLanguage}'");
if (code == CurrentLanguage)
+ {
+ _log.Info(LogId, "SetLanguage: already current, no-op.");
return;
+ }
LoadDictionary(code);
@@ -66,15 +84,20 @@ namespace HC_APTBS.Services.Impl
var code = NormaliseCode(languageCode);
var uri = code == "ENG" ? EngUri : EspUri;
- var dict = new ResourceDictionary { Source = new Uri(uri, UriKind.Absolute) };
+ try
+ {
+ _stringsSlot.Source = new Uri(uri, UriKind.Absolute);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"LoadDictionary({code}) failed to load {uri}: {ex.Message}");
+ return;
+ }
- var merged = Application.Current.Resources.MergedDictionaries;
- if (_currentDictionary != null)
- merged.Remove(_currentDictionary);
-
- merged.Add(dict);
- _currentDictionary = dict;
CurrentLanguage = code;
+
+ _log.Info(LogId,
+ $"LoadDictionary({code}): {uri} loaded with {_stringsSlot.Count} keys.");
}
private static string NormaliseCode(string code) =>
diff --git a/Services/Impl/UnlockService.cs b/Services/Impl/UnlockService.cs
index 61800d6..03de6aa 100644
--- a/Services/Impl/UnlockService.cs
+++ b/Services/Impl/UnlockService.cs
@@ -51,6 +51,9 @@ namespace HC_APTBS.Services.Impl
///
public event Action? PumpUnlocked;
+ ///
+ public event Action? PumpRelocked;
+
///
public bool IsPumpUnlocked => _isPumpUnlocked;
@@ -189,6 +192,7 @@ namespace HC_APTBS.Services.Impl
_isPumpUnlocked = unlocked;
_log.Info(LogId, $"Observer: pump {pump.Id} transitioned {(unlocked ? "LOCKED → UNLOCKED" : "UNLOCKED → LOCKED")}");
if (unlocked) PumpUnlocked?.Invoke();
+ else PumpRelocked?.Invoke();
}
// ── Persistent CAN senders ───────────────────────────────────────────────
@@ -391,7 +395,7 @@ namespace HC_APTBS.Services.Impl
_log.Info(LogId, "Attempting K-Line fast unlock (timer shortcut)...");
StatusChanged?.Invoke("Fast unlock attempt...");
- bool ack = await _kwp.TryFastUnlockAsync().ConfigureAwait(false);
+ bool ack = await _kwp.TryFastUnlockAsync(pump.UnlockType).ConfigureAwait(false);
if (!ack)
{
_log.Info(LogId, "Fast unlock NAK or failed — continuing normal wait");
diff --git a/ViewModels/BenchControlViewModel.cs b/ViewModels/BenchControlViewModel.cs
index 0731c4c..a5da3d8 100644
--- a/ViewModels/BenchControlViewModel.cs
+++ b/ViewModels/BenchControlViewModel.cs
@@ -119,6 +119,35 @@ namespace HC_APTBS.ViewModels
}
_bench.SetRelay(RelayNames.OilPump, value);
+
+ // The dialog's ShowDialog call runs a nested dispatcher message pump.
+ // While it was blocking, RefreshFromTick may have fired and written
+ // _isOilPumpOn back to the stale relay.State value (false, because
+ // SetRelay above hadn't run yet). Re-assert the backing field now
+ // that relay.State is committed so IsOilPumpOn reports the correct
+ // value to callers downstream (e.g. TestsPageViewModel.StartTestAsync
+ // which guards on it right after this setter returns).
+ // See docs/gotcha-oil-pump-dialog-race.md.
+ if (_isOilPumpOn != value)
+ {
+ _isOilPumpOn = value;
+ OnPropertyChanged(nameof(IsOilPumpOn));
+ }
+ }
+
+ ///
+ /// Energises the oil-pump relay and flags without
+ /// presenting the leak-check confirmation dialog. Used by the Dashboard
+ /// "Connect & Auto Test" flow when the operator has opted in via
+ /// . Writes the backing
+ /// field directly to avoid re-entering .
+ ///
+ public void TurnOilPumpOnSilent()
+ {
+ if (_isOilPumpOn) return;
+ _bench.SetRelay(RelayNames.OilPump, true);
+ _isOilPumpOn = true;
+ OnPropertyChanged(nameof(IsOilPumpOn));
}
// ── RPM commands ──────────────────────────────────────────────────────────
@@ -230,12 +259,23 @@ namespace HC_APTBS.ViewModels
// ── Refresh (called from MainViewModel timer tick) ────────────────────────
///
- /// Updates live counter readback from CAN.
+ /// Updates live counter readback from CAN, and mirrors the oil-pump relay
+ /// state so this VM's stays in sync even when the
+ /// relay is toggled outside the manual Bench page (e.g. the Dashboard
+ /// auto-test orchestrator). Writes through the backing field to avoid
+ /// re-triggering the confirmation dialog in .
/// Called on the UI thread from .
///
public void RefreshFromTick()
{
BenchCounterValue = _bench.ReadBenchParameter(BenchParameterNames.BenchCounter);
+
+ bool relayOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
+ if (_isOilPumpOn != relayOn)
+ {
+ _isOilPumpOn = relayOn;
+ OnPropertyChanged(nameof(IsOilPumpOn));
+ }
}
}
}
diff --git a/ViewModels/BipDisplayViewModel.cs b/ViewModels/BipDisplayViewModel.cs
new file mode 100644
index 0000000..d02f639
--- /dev/null
+++ b/ViewModels/BipDisplayViewModel.cs
@@ -0,0 +1,166 @@
+using System.Collections.ObjectModel;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using HC_APTBS.Models;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// Represents one row in the BIP-STATUS display.
+ ///
+ public sealed partial class BipRowViewModel : ObservableObject
+ {
+ /// Zero-based index of this BIP definition entry.
+ public int Index { get; init; }
+
+ /// 16-bit nibble pattern from the CFG (displayed as "0xXXXX").
+ public string HexPattern { get; init; } = string.Empty;
+
+ /// Raw hex value — used to re-resolve the description on language change.
+ public ushort RawHex { get; init; }
+
+ /// SpecialFunction from the CFG — part of the localization key.
+ public int SpecialFunction { get; init; }
+
+ /// Original XML text, used as fallback when no resource key matches.
+ public string FallbackDescription { get; init; } = string.Empty;
+
+ /// Human-readable description shown on match; updated on language switch.
+ [ObservableProperty] private string _description = string.Empty;
+
+ /// HTML hex colour for the status indicator: green = inactive, red = match detected.
+ [ObservableProperty] private string _color = "#26C200";
+
+ /// True when this BIP pattern currently matches the captured word.
+ [ObservableProperty] private bool _isActive;
+ }
+
+ ///
+ /// ViewModel for the BIP-STATUS display user control.
+ ///
+ ///
+ /// Only populated for PSG5-PI pumps (those whose
+ /// is non-null). When is false the view hides itself.
+ ///
+ ///
+ public sealed partial class BipDisplayViewModel : ObservableObject
+ {
+ private readonly ILocalizationService _loc;
+
+ // ── Properties ────────────────────────────────────────────────────────────
+
+ /// True when the current pump has BIP definitions; controls view visibility.
+ [ObservableProperty] private bool _hasDefinition;
+
+ /// Last raw BIP word received from the ECU (displayed as hex for diagnostics).
+ [ObservableProperty] private string _rawValue = "–";
+
+ /// Ordered rows for the BIP definition table.
+ public ObservableCollection Rows { get; } = new();
+
+ // ── Construction ──────────────────────────────────────────────────────────
+
+ ///
+ /// Initializes the view model and subscribes to language-change notifications
+ /// so that row descriptions update automatically when the operator switches language.
+ ///
+ public BipDisplayViewModel(ILocalizationService loc)
+ {
+ _loc = loc;
+ _loc.LanguageChanged += RefreshDescriptions;
+ }
+
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ ///
+ /// Loads the BIP definition for the selected pump and resets the display.
+ /// Pass to hide the control (non-PSG5-PI pump selected).
+ /// Must be called on the UI thread.
+ ///
+ public void LoadDefinition(PumpBipDefinition? bipDef)
+ {
+ Rows.Clear();
+ RawValue = "–";
+
+ if (bipDef == null || bipDef.Bits.Count == 0)
+ {
+ HasDefinition = false;
+ return;
+ }
+
+ foreach (var d in bipDef.Bits)
+ {
+ Rows.Add(new BipRowViewModel
+ {
+ Index = d.Index,
+ HexPattern = $"0x{d.HexPattern:X4}",
+ RawHex = d.HexPattern,
+ SpecialFunction = d.SpecialFunction,
+ FallbackDescription = d.Description,
+ Description = ResolveDescription(d.HexPattern, d.SpecialFunction, d.Description),
+ Color = "#26C200",
+ IsActive = false
+ });
+ }
+
+ HasDefinition = true;
+ }
+
+ ///
+ /// Updates the display with a newly captured BIP status word.
+ /// Marks matching (and enabled) rows as active.
+ /// Must be called on the UI thread.
+ ///
+ /// Current pump's BIP definition.
+ /// Raw 16-bit value read from ECU RAM 0x0106.
+ public void UpdateBipWord(PumpBipDefinition bipDef, ushort rawWord)
+ {
+ RawValue = $"0x{rawWord:X4}";
+
+ for (int i = 0; i < Rows.Count && i < bipDef.Bits.Count; i++)
+ {
+ var def = bipDef.Bits[i];
+ var row = Rows[i];
+
+ // Bitmask match: all pattern bits must be set in rawWord.
+ bool matches = def.Enabled && (rawWord & def.HexPattern) == def.HexPattern;
+ row.IsActive = matches;
+ row.Color = matches ? "#FF1E1E" : "#26C200";
+ }
+ }
+
+ /// Resets all rows to inactive / green without clearing the definitions.
+ public void Reset()
+ {
+ foreach (var row in Rows)
+ {
+ row.IsActive = false;
+ row.Color = "#26C200";
+ }
+ RawValue = "–";
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Returns the localized description for a BIP entry keyed by (hex, specialFunction),
+ /// falling back to the raw XML text when no resource key is found.
+ ///
+ private static string ResolveDescription(ushort hex, int sf, string fallback)
+ {
+ var key = $"Pump.Bip.Desc.{hex:X4}.{sf}";
+ return Application.Current.Resources[key]?.ToString() ?? fallback;
+ }
+
+ ///
+ /// Re-resolves all row descriptions against the now-active resource dictionary.
+ /// Called by .
+ ///
+ private void RefreshDescriptions()
+ {
+ foreach (var row in Rows)
+ row.Description = ResolveDescription(row.RawHex, row.SpecialFunction, row.FallbackDescription);
+ }
+ }
+}
diff --git a/ViewModels/DfiManageViewModel.cs b/ViewModels/DfiManageViewModel.cs
index 6bd5254..184c035 100644
--- a/ViewModels/DfiManageViewModel.cs
+++ b/ViewModels/DfiManageViewModel.cs
@@ -99,6 +99,16 @@ namespace HC_APTBS.ViewModels
});
}
+ ///
+ /// Clears the DFI display and slider so the previous pump's value is not
+ /// shown stale until a new K-Line read populates it. Called on pump change.
+ ///
+ public void Reset()
+ {
+ CurrentDfi = 0;
+ SliderRaw = 0;
+ }
+
// ── Commands ──────────────────────────────────────────────────────────────
/// Reads the current DFI value from the ECU over K-Line.
diff --git a/ViewModels/Dialogs/AutoTestProgressViewModel.cs b/ViewModels/Dialogs/AutoTestProgressViewModel.cs
new file mode 100644
index 0000000..15c6e27
--- /dev/null
+++ b/ViewModels/Dialogs/AutoTestProgressViewModel.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels.Dialogs
+{
+ ///
+ /// ViewModel for the Dashboard "Connect & Auto Test" snackbar.
+ /// Receives transitions and failure reasons from the
+ /// orchestrator, exposes snackbar-friendly bindings (progress, phase text, success),
+ /// and forwards the Cancel click to the orchestrator's CTS.
+ /// Modelled on .
+ ///
+ public sealed partial class AutoTestProgressViewModel : ObservableObject, IDisposable
+ {
+ private readonly IAutoTestOrchestrator _orchestrator;
+ private readonly ILocalizationService _loc;
+
+ /// Creates the ViewModel and subscribes to orchestrator events.
+ public AutoTestProgressViewModel(IAutoTestOrchestrator orchestrator, ILocalizationService loc)
+ {
+ _orchestrator = orchestrator;
+ _loc = loc;
+
+ _typeLabel = _loc.GetString("AutoTest.TypeLabel");
+ _phaseText = _loc.GetString("AutoTest.State.Preflight");
+ _isCancellable = true;
+
+ _orchestrator.StateChanged += OnStateChanged;
+ _orchestrator.Failed += OnFailed;
+ }
+
+ // ── Observable properties ────────────────────────────────────────────────
+
+ /// Progress percentage (0–100). Meaningful during Unlocking and test Running phases.
+ [ObservableProperty] private int _progress;
+
+ /// Leading label (localised "Auto Test").
+ [ObservableProperty] private string _typeLabel;
+
+ /// Current phase description shown in the snackbar.
+ [ObservableProperty] private string _phaseText;
+
+ /// Terminal result text — populated on Completed/Aborted.
+ [ObservableProperty] private string _resultText = string.Empty;
+
+ /// True once the sequence reaches Completed or Aborted.
+ [NotifyCanExecuteChangedFor(nameof(CloseCommand))]
+ [ObservableProperty] private bool _isComplete;
+
+ /// True while the Cancel button should be enabled (all non-terminal states).
+ [NotifyCanExecuteChangedFor(nameof(CancelCommand))]
+ [ObservableProperty] private bool _isCancellable;
+
+ /// Tri-state: null while running, true = success, false = failure.
+ [ObservableProperty] private bool? _isSuccess;
+
+ // ── Commands ─────────────────────────────────────────────────────────────
+
+ /// Cancels the orchestrator's current sequence.
+ [RelayCommand(CanExecute = nameof(IsCancellable))]
+ private void Cancel() => _orchestrator.Cancel();
+
+ /// Closes the snackbar (emits ).
+ [RelayCommand(CanExecute = nameof(IsComplete))]
+ private void Close() => RequestClose?.Invoke();
+
+ // ── Events ───────────────────────────────────────────────────────────────
+
+ /// Raised when the snackbar should close itself (after success / user dismiss).
+ public event Action? RequestClose;
+
+ // ── Orchestrator event handlers ──────────────────────────────────────────
+
+ private void OnStateChanged(AutoTestState state, string? detail)
+ {
+ Application.Current?.Dispatcher?.Invoke(() =>
+ {
+ switch (state)
+ {
+ case AutoTestState.Preflight:
+ PhaseText = _loc.GetString("AutoTest.State.Preflight");
+ break;
+ case AutoTestState.ConnectingKLine:
+ PhaseText = _loc.GetString("AutoTest.State.Connecting");
+ break;
+ case AutoTestState.ReadingPump:
+ PhaseText = _loc.GetString("AutoTest.State.Reading");
+ if (!string.IsNullOrEmpty(detail) && int.TryParse(detail, out int pct))
+ Progress = pct;
+ break;
+ case AutoTestState.Unlocking:
+ PhaseText = string.IsNullOrEmpty(detail)
+ ? _loc.GetString("AutoTest.State.Unlocking")
+ : string.Format(_loc.GetString("AutoTest.State.UnlockingWithDetail"), detail);
+ break;
+ case AutoTestState.TurningOnBench:
+ PhaseText = _loc.GetString("AutoTest.State.BenchOn");
+ break;
+ case AutoTestState.StartingOilPump:
+ PhaseText = _loc.GetString("AutoTest.State.OilPump");
+ break;
+ case AutoTestState.StartingTest:
+ PhaseText = _loc.GetString("AutoTest.State.TestStart");
+ break;
+ case AutoTestState.Running:
+ PhaseText = string.IsNullOrEmpty(detail)
+ ? _loc.GetString("AutoTest.State.Running")
+ : string.Format(_loc.GetString("AutoTest.State.RunningWithPhase"), detail);
+ break;
+ case AutoTestState.Completed:
+ PhaseText = _loc.GetString("AutoTest.State.Completed");
+ ResultText = PhaseText;
+ Progress = 100;
+ IsComplete = true;
+ IsCancellable = false;
+ IsSuccess = true;
+ break;
+ case AutoTestState.Aborted:
+ // Detail populated by OnFailed; still terminal.
+ IsComplete = true;
+ IsCancellable = false;
+ IsSuccess = false;
+ break;
+ }
+ });
+ }
+
+ private void OnFailed(AutoTestFailureReason reason, string message)
+ {
+ Application.Current?.Dispatcher?.Invoke(() =>
+ {
+ string key = "AutoTest.Failure." + reason;
+ string localised = _loc.GetString(key);
+ if (string.IsNullOrEmpty(localised) || localised == key)
+ localised = reason.ToString();
+
+ ResultText = string.IsNullOrEmpty(message)
+ ? localised
+ : $"{localised}: {message}";
+ PhaseText = string.Format(_loc.GetString("AutoTest.State.Aborted"), ResultText);
+ IsComplete = true;
+ IsCancellable = false;
+ IsSuccess = false;
+ });
+ }
+
+ // ── IDisposable ──────────────────────────────────────────────────────────
+
+ /// Unsubscribes from orchestrator events.
+ public void Dispose()
+ {
+ _orchestrator.StateChanged -= OnStateChanged;
+ _orchestrator.Failed -= OnFailed;
+ }
+ }
+}
diff --git a/ViewModels/Dialogs/UnlockProgressViewModel.cs b/ViewModels/Dialogs/UnlockProgressViewModel.cs
index 6ee63f6..0457cf0 100644
--- a/ViewModels/Dialogs/UnlockProgressViewModel.cs
+++ b/ViewModels/Dialogs/UnlockProgressViewModel.cs
@@ -37,8 +37,16 @@ namespace HC_APTBS.ViewModels.Dialogs
_elapsedTime = "00:00";
_isCancellable = true;
- _unlockService.StatusChanged += OnStatusChanged;
- _unlockService.UnlockCompleted += OnUnlockCompleted;
+ _unlockService.StatusChanged += OnStatusChanged;
+ _unlockService.UnlockCompleted += OnUnlockCompleted;
+ _unlockService.PumpRelocked += OnPumpRelocked;
+ // PumpUnlocked fires as soon as the CAN TestUnlock parameter flips —
+ // regardless of which code path caused the unlock (fast unlock, Phase 1
+ // flood finishing, external manual unlock). This lets the dialog flip
+ // to its success state the instant the hardware confirms unlock, rather
+ // than waiting for UnlockService.UnlockAsync to reach its final
+ // verification step.
+ _unlockService.PumpUnlocked += OnPumpUnlocked;
}
// ── Observable properties ────────────────────────────────────────────────
@@ -69,6 +77,10 @@ namespace HC_APTBS.ViewModels.Dialogs
/// Tri-state result: null = in progress, true = success, false = failure.
[ObservableProperty] private bool? _isSuccess;
+ /// True when the pump is currently LOCKED and the operator can retry the unlock.
+ [NotifyCanExecuteChangedFor(nameof(RetryCommand))]
+ [ObservableProperty] private bool _canRetry;
+
// ── Commands ─────────────────────────────────────────────────────────────
/// Cancels the unlock sequence (only available during Phase 1).
@@ -89,11 +101,22 @@ namespace HC_APTBS.ViewModels.Dialogs
RequestClose?.Invoke();
}
+ /// Requests a new unlock attempt (only available when complete and pump is LOCKED).
+ [RelayCommand(CanExecute = nameof(CanRetry))]
+ private void Retry()
+ {
+ CanRetry = false;
+ RequestRetry?.Invoke();
+ }
+
// ── Events ───────────────────────────────────────────────────────────────
/// Raised when the dialog should close itself.
public event Action? RequestClose;
+ /// Raised when the operator presses Retry — parent should restart the unlock sequence.
+ public event Action? RequestRetry;
+
// ── Service event handlers ───────────────────────────────────────────────
private void OnStatusChanged(string msg)
@@ -137,6 +160,43 @@ namespace HC_APTBS.ViewModels.Dialogs
IsCancellable = false;
IsSuccess = success;
ResultText = success ? _loc.GetString("Dialog.Unlock.Unlocked") : _loc.GetString("Dialog.Unlock.Failed");
+ // Enable Retry when the unlock finished but the pump is still LOCKED.
+ CanRetry = !_unlockService.IsPumpUnlocked;
+ });
+ }
+
+ ///
+ /// Observer says the pump is now unlocked. Flip the dialog to the
+ /// success state immediately so the operator sees a responsive UI; the
+ /// later UnlockCompleted(true) event is idempotent and leaves this state
+ /// intact. If UnlockCompleted later arrives with failure=false, that
+ /// would overwrite — but that combination (observer unlocks then service
+ /// reports failure) is not a real scenario in the current state machine.
+ ///
+ private void OnPumpUnlocked()
+ {
+ Application.Current?.Dispatcher?.Invoke(() =>
+ {
+ if (IsComplete) return;
+ IsComplete = true;
+ IsCancellable = false;
+ IsSuccess = true;
+ CanRetry = false;
+ ResultText = _loc.GetString("Dialog.Unlock.Unlocked");
+ });
+ }
+
+ ///
+ /// Observer says the pump re-locked after a previously successful unlock.
+ /// If the snackbar is still visible (not dismissed), light up the Retry button
+ /// so the operator has a manual fallback without needing to reselect the pump.
+ ///
+ private void OnPumpRelocked()
+ {
+ Application.Current?.Dispatcher?.Invoke(() =>
+ {
+ if (IsComplete)
+ CanRetry = true;
});
}
@@ -145,8 +205,10 @@ namespace HC_APTBS.ViewModels.Dialogs
/// Unsubscribes from service events to prevent leaks.
public void Dispose()
{
- _unlockService.StatusChanged -= OnStatusChanged;
+ _unlockService.StatusChanged -= OnStatusChanged;
_unlockService.UnlockCompleted -= OnUnlockCompleted;
+ _unlockService.PumpUnlocked -= OnPumpUnlocked;
+ _unlockService.PumpRelocked -= OnPumpRelocked;
}
}
}
diff --git a/ViewModels/GraphicIndicatorViewModel.cs b/ViewModels/GraphicIndicatorViewModel.cs
index a1a4230..70a9972 100644
--- a/ViewModels/GraphicIndicatorViewModel.cs
+++ b/ViewModels/GraphicIndicatorViewModel.cs
@@ -4,9 +4,10 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace HC_APTBS.ViewModels
{
///
- /// Represents the vertical graphic result indicator for a single receive parameter
- /// within a phase card. Displays expected value, tolerance bounds, and live/final
- /// measurement result as a vertical progress bar.
+ /// Vertical min/max/target gauge for a single receive parameter on a phase card.
+ /// Ticks continuously while the phase is active (conditioning + measurement) and
+ /// locks to the final pass/fail colour via once the
+ /// phase ends.
///
public sealed partial class GraphicIndicatorViewModel : ObservableObject
{
@@ -20,8 +21,9 @@ namespace HC_APTBS.ViewModels
[ObservableProperty] private double _tolerance;
///
- /// Current live measurement value, updated in real-time during the measurement phase.
- /// Triggers recalculation of and .
+ /// Current live measurement value. Updated every refresh tick by
+ /// so the bar moves
+ /// through conditioning as well as measurement.
///
[ObservableProperty] private double _currentValue;
@@ -37,6 +39,12 @@ namespace HC_APTBS.ViewModels
/// True once a measurement has been recorded for this indicator.
[ObservableProperty] private bool _hasValue;
+ /// True after the owning phase completes; freezes the fill colour.
+ [ObservableProperty] private bool _isPhaseCompleted;
+
+ /// Pass/fail outcome of the owning phase. Only meaningful when is true.
+ [ObservableProperty] private bool _phasePassed;
+
/// Lower tolerance bound: - .
public double MinBound => ExpectedValue - Tolerance;
@@ -46,6 +54,15 @@ namespace HC_APTBS.ViewModels
/// Formatted display string for the current value.
public string DisplayValue => HasValue ? CurrentValue.ToString("F1") : "---";
+ /// Top of the in-tolerance band, as a percent of the bar height (0 = top, 100 = bottom).
+ public double ToleranceBandTopPercent => Tolerance > 0 ? 20.0 : 50.0;
+
+ /// Height of the in-tolerance band, as a percent of the bar height.
+ public double ToleranceBandHeightPercent => Tolerance > 0 ? 60.0 : 0.0;
+
+ /// Position of the target line, as a percent of the bar height (symmetric around expected).
+ public double ExpectedMarkerPercent => 50.0;
+
// ── Recalculation on value change ─────────────────────────────────────────
partial void OnCurrentValueChanged(double value)
@@ -67,9 +84,22 @@ namespace HC_APTBS.ViewModels
{
OnPropertyChanged(nameof(MinBound));
OnPropertyChanged(nameof(MaxBound));
+ OnPropertyChanged(nameof(ToleranceBandTopPercent));
+ OnPropertyChanged(nameof(ToleranceBandHeightPercent));
if (HasValue) RecalculateProgress(CurrentValue);
}
+ ///
+ /// Applies a runtime tolerance update (e.g. after DFI auto-adjust) without
+ /// touching the live . Raises change notifications
+ /// for all dependent computed properties.
+ ///
+ public void ApplyTolerance(double expected, double tolerance)
+ {
+ ExpectedValue = expected;
+ Tolerance = tolerance;
+ }
+
///
/// Computes the progress bar fill percentage using the same algorithm as the
/// original GraphicResultDisplay. The display range extends 20% beyond the
@@ -108,6 +138,8 @@ namespace HC_APTBS.ViewModels
ProgressPercent = 0;
IsWithinTolerance = true;
HasValue = false;
+ IsPhaseCompleted = false;
+ PhasePassed = false;
OnPropertyChanged(nameof(DisplayValue));
}
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index a82c56e..1afe6e7 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -30,7 +30,11 @@ namespace HC_APTBS.ViewModels
/// Application configuration: safety limits, PID, motor, report, K-Line, language.
Settings = 4,
/// Session-only history of completed test runs with detail view and PDF export.
- Results = 5
+ Results = 5,
+#if DEVELOPER_TOOLS
+ /// Developer Tools page: raw K-Line / KWP custom command console. Debug builds only.
+ Developer = 6
+#endif
}
///
@@ -174,7 +178,7 @@ namespace HC_APTBS.ViewModels
/// ViewModel for the BIP-STATUS display (PSG5-PI pumps only).
/// is false for non-PSG5-PI pumps.
///
- public BipDisplayViewModel BipDisplay { get; } = new();
+ public BipDisplayViewModel BipDisplay { get; }
/// ViewModel for the Dashboard's active-alarm list.
public DashboardAlarmsViewModel DashboardAlarms { get; }
@@ -202,6 +206,11 @@ namespace HC_APTBS.ViewModels
/// Results navigation page VM (session-only test-run history).
public ResultsPageViewModel ResultsPage { get; private set; } = null!;
+#if DEVELOPER_TOOLS
+ /// Developer Tools page VM. Debug builds only — excluded from consumer Release builds.
+ public Pages.DeveloperPageViewModel DeveloperPage { get; private set; } = null!;
+#endif
+
// ── Navigation state ──────────────────────────────────────────────────────
/// Currently selected top-level navigation page.
@@ -245,6 +254,7 @@ namespace HC_APTBS.ViewModels
AngleDisplay = new AngleDisplayViewModel(configService);
DashboardAlarms = new DashboardAlarmsViewModel(configService.Settings.Alarms);
DtcList = new DtcListViewModel(kwpService, localizationService, logger);
+ BipDisplay = new BipDisplayViewModel(localizationService);
// Page ViewModels are thin façades over the child VMs above; they hold a
// reference back to this coordinator so page XAML can bind MainViewModel-owned
// values via {Binding Root.X}.
@@ -252,9 +262,12 @@ namespace HC_APTBS.ViewModels
BenchPage = new BenchPageViewModel(this, benchService, configService);
PumpPage = new PumpPageViewModel(this, DtcList);
TestsPage = new TestsPageViewModel(this, configService, localizationService);
- SettingsPage = new SettingsPageViewModel(configService, localizationService);
+ SettingsPage = new SettingsPageViewModel(configService, localizationService, logger);
SettingsPage.SettingsSaved += OnSettingsSaved;
ResultsPage = new ResultsPageViewModel(this, pdfService, configService, localizationService, logger);
+#if DEVELOPER_TOOLS
+ DeveloperPage = new Pages.DeveloperPageViewModel(this, kwpService, configService, logger);
+#endif
// React to pump changes from the identification child VM.
PumpIdentification.PumpChanged += OnPumpChanged;
@@ -373,6 +386,12 @@ namespace HC_APTBS.ViewModels
_unlock.UnlockCompleted += success => App.Current.Dispatcher.Invoke(
() => _lastUnlockSucceeded = success);
+ // Re-trigger unlock on any UNLOCKED → LOCKED transition (pump swap, power glitch, etc.)
+ _unlock.PumpRelocked += OnPumpRelocked;
+
+ // Safety-net: if a K-Line read completes and the pump is still LOCKED, re-run unlock.
+ PumpIdentification.KlineReadCompleted += OnKlineReadCompleted;
+
// KWP pump power-cycle callbacks
kwpService.PumpDisconnectRequested += OnKwpDisconnectPump;
kwpService.PumpReconnectRequested += OnKwpReconnectPump;
@@ -424,6 +443,7 @@ namespace HC_APTBS.ViewModels
PumpControl.IsPreInAvailable = pump.HasPreInjection;
PumpControl.IsEnabled = true;
PumpControl.Reset();
+ DfiViewModel.Reset();
_log.Info(LogId, $"OnPumpChanged: slider gate opened for {pump.Id}");
// Cancel any in-flight "wait for CAN liveness then unlock" gate from
@@ -613,12 +633,74 @@ namespace HC_APTBS.ViewModels
_unlockCts = new CancellationTokenSource();
CurrentUnlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
CurrentUnlockVm.RequestClose += CloseUnlockDialog;
+ CurrentUnlockVm.RequestRetry += () => RestartUnlockForSameSelection(pump);
// Start unlock in background — ViewModel tracks via event subscriptions.
_unlockTask = _unlock.UnlockAsync(pump, _unlockCts.Token);
_ = _unlockTask.ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnFaulted);
}
+ ///
+ /// Handles the UNLOCKED → LOCKED transition raised by the unlock observer on the CAN
+ /// read thread. Re-runs the unlock flow against the current pump without touching CAN
+ /// parameter registrations, the test panel, or bench senders (the pump model is unchanged).
+ ///
+ private void OnPumpRelocked()
+ {
+ App.Current.Dispatcher.BeginInvoke(new Action(() =>
+ {
+ var pump = _previousPump;
+ if (pump == null || pump.UnlockType == 0) return;
+
+ // Skip if an unlock is already in-flight — the LOCKED frames that arrive
+ // during Phase 1 of an ongoing unlock would otherwise cause infinite restarts.
+ if (_unlockTask != null && !_unlockTask.IsCompleted) return;
+
+ _log.Warning(LogId, $"Pump {pump.Id} transitioned UNLOCKED → LOCKED — re-triggering unlock");
+ RestartUnlockForSameSelection(pump);
+ }));
+ }
+
+ ///
+ /// Handles K-Line read completion. If the pump requires unlock and the observer reports
+ /// LOCKED, re-runs the unlock flow. This is a safety net for the first-contact window
+ /// where the CAN observer may not yet have received a frame from the new pump.
+ ///
+ private void OnKlineReadCompleted(string pumpId, string serial)
+ {
+ var pump = _previousPump;
+ if (pump == null || !string.Equals(pump.Id, pumpId, StringComparison.OrdinalIgnoreCase)) return;
+ if (pump.UnlockType == 0) return;
+ if (_unlock.IsPumpUnlocked) return;
+
+ // Skip if an unlock is already running.
+ if (_unlockTask != null && !_unlockTask.IsCompleted) return;
+
+ _log.Info(LogId, $"K-Line read completed on {pumpId}; observer reports LOCKED — re-triggering unlock");
+ RestartUnlockForSameSelection(pump);
+ }
+
+ ///
+ /// Tears down the active unlock state and re-runs the liveness-wait → unlock pipeline
+ /// against the already-selected pump. Used when the pump re-locks without a model change
+ /// (physical swap of a same-ID unit, power instability, etc.).
+ ///
+ private void RestartUnlockForSameSelection(PumpDefinition pump)
+ {
+ _pumpLivenessCts?.Cancel();
+ _pumpLivenessCts?.Dispose();
+ _pumpLivenessCts = null;
+
+ _unlockCts?.Cancel();
+
+ _unlock.StopSenders();
+ _unlock.StopObserver();
+ _lastUnlockSucceeded = false;
+
+ _pumpLivenessCts = new CancellationTokenSource();
+ _ = WaitForPumpCanThenUnlockAsync(pump, _pumpLivenessCts.Token);
+ }
+
///
/// Dismisses the unlock snackbar and disposes its ViewModel. Does NOT stop
/// the persistent CAN senders — those continue running until the next pump
diff --git a/ViewModels/Pages/DashboardDevicesViewModel.cs b/ViewModels/Pages/DashboardDevicesViewModel.cs
index c471580..7e5b1e6 100644
--- a/ViewModels/Pages/DashboardDevicesViewModel.cs
+++ b/ViewModels/Pages/DashboardDevicesViewModel.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.ObjectModel;
-using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -42,10 +40,28 @@ namespace HC_APTBS.ViewModels.Pages
/// True when this device is the currently active connection.
[ObservableProperty] private bool _isConnected;
+ /// True when the device session is in a failed state (K-Line only).
+ [ObservableProperty] private bool _isFailed;
+
/// False for the Bench placeholder, which cannot be clicked.
public bool IsEnabled { get; init; } = true;
}
+ ///
+ /// Snackbar state for an in-flight CAN connect/disconnect transition.
+ ///
+ public sealed partial class DeviceTransitionViewModel : ObservableObject
+ {
+ /// Localised message shown to the user.
+ [ObservableProperty] private string _message = "";
+
+ /// True while the operation is running (spinner visible).
+ [ObservableProperty] private bool _isBusy;
+
+ /// Null while running; true on success; false on failure.
+ [ObservableProperty] private bool? _isSuccess;
+ }
+
///
/// ViewModel for the Devices column on the Dashboard.
///
@@ -69,6 +85,9 @@ namespace HC_APTBS.ViewModels.Pages
/// Single bench-controller placeholder row.
public ObservableCollection BenchDevices { get; } = new();
+ /// Active snackbar VM for an in-flight CAN connect/disconnect; null when no transition.
+ [ObservableProperty] private DeviceTransitionViewModel? _transition;
+
public DashboardDevicesViewModel(MainViewModel root, ICanService can, IKwpService kwp)
{
_root = root;
@@ -136,7 +155,9 @@ namespace HC_APTBS.ViewModels.Pages
? dev.SerialNumber
: $"{dev.Description} ({dev.SerialNumber})",
IsConnected = connected,
+ IsFailed = failed,
StateLabel = GetKLineStateLabel(connected, failed),
+ IsEnabled = false, // K-Line rows are display-only; session is owned by AutoTestOrchestrator
});
}
}
@@ -168,62 +189,74 @@ namespace HC_APTBS.ViewModels.Pages
{
if (item is null || !item.IsEnabled) return;
- bool testRunning = _root.IsTestRunning;
- bool sessionActive = item.IsConnected;
+ // K-Line rows are non-clickable (IsEnabled=false), so they never reach this point.
+ // Sessions for K-Line are started/stopped exclusively by AutoTestOrchestrator.
+ if (item.Kind != DeviceKind.Can) return;
- if (testRunning)
- {
- if (!Confirm(Str("Devices.Confirm.Title"), Str("Devices.Confirm.Body.TestRunning")))
- return;
- }
- else if (sessionActive)
- {
- string body = string.Format(Str("Devices.Confirm.Body.Active"),
- item.Kind == DeviceKind.Can ? "CAN" : "K-Line");
- if (!Confirm(Str("Devices.Confirm.Title"), body))
- return;
- }
+ // A running test owns the CAN bus; never let the user yank it mid-run.
+ if (_root.IsTestRunning &&
+ !Confirm(Str("Devices.Confirm.Title"), Str("Devices.Confirm.Body.TestRunning")))
+ return;
- switch (item.Kind)
- {
- case DeviceKind.Can:
- await ToggleCanAsync(item);
- break;
-
- case DeviceKind.KLine:
- await ToggleKLineAsync(item);
- break;
- }
+ await ToggleCanAsync(item);
}
private async Task ToggleCanAsync(DeviceItem item)
{
- if (item.IsConnected)
- {
- _root.DisconnectCanCommand.Execute(null);
- }
- else
- {
- try { _can.SelectedChannel = item.CanHandle; }
- catch { return; }
- _root.ConnectCanCommand.Execute(null);
- }
- await Task.Delay(600); // allow liveness event propagation
- RefreshCanDevices();
- }
+ bool connecting = !item.IsConnected;
- private async Task ToggleKLineAsync(DeviceItem item)
- {
- if (item.IsConnected)
+ var t = new DeviceTransitionViewModel
{
- _kwp.Disconnect();
- }
- else
+ Message = Str(connecting
+ ? "Dashboard.Devices.Snackbar.Connecting"
+ : "Dashboard.Devices.Snackbar.Disconnecting"),
+ IsBusy = true,
+ };
+ Transition = t;
+
+ // ConnectCan/DisconnectCan mutate observable properties that fan out to
+ // CanExecuteChanged listeners on Buttons — those DPs are UI-thread-affine,
+ // so the commands must run on the Dispatcher even though they block briefly
+ // on the PCAN handle. Yield once so the snackbar paints before we block.
+ await Task.Yield();
+
+ bool ok;
+ try
{
- try { await _kwp.ConnectAsync(item.Id, CancellationToken.None); }
- catch { /* ConnectAsync throws on init failure — leave state as-is */ }
+ if (connecting)
+ {
+ try { _can.SelectedChannel = item.CanHandle; }
+ catch { Transition = null; return; }
+
+ _root.ConnectCanCommand.Execute(null);
+ ok = _root.IsCanConnected;
+ }
+ else
+ {
+ _root.DisconnectCanCommand.Execute(null);
+ ok = !_root.IsCanConnected;
+ }
}
- RefreshKLineDevices();
+ catch
+ {
+ ok = false;
+ }
+
+ t.IsBusy = false;
+ t.IsSuccess = ok;
+ t.Message = Str(ok
+ ? (connecting ? "Dashboard.Devices.Snackbar.Connected"
+ : "Dashboard.Devices.Snackbar.Disconnected")
+ : "Dashboard.Devices.Snackbar.Failed");
+
+ RefreshCanDevices();
+
+ // Auto-dismiss after ~2 s; only clear if a fresh transition has not replaced this one.
+ _ = Task.Delay(2000).ContinueWith(_ =>
+ App.Current.Dispatcher.Invoke(() =>
+ {
+ if (Transition == t) Transition = null;
+ }));
}
// ── State change wiring ───────────────────────────────────────────────────
diff --git a/ViewModels/Pages/DeveloperPageViewModel.cs b/ViewModels/Pages/DeveloperPageViewModel.cs
new file mode 100644
index 0000000..315bf1a
--- /dev/null
+++ b/ViewModels/Pages/DeveloperPageViewModel.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Models;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels.Pages
+{
+ ///
+ /// ViewModel for the Developer Tools navigation page — exposes a raw KWP/K-Line
+ /// custom-command console for hardware development and debugging.
+ ///
+ ///
+ /// Compiled into Debug builds only. The project's
+ /// DEVELOPER_TOOLS compile-time symbol gates every reference to this
+ /// type, and the page files are Compile Remove'd from Release builds
+ /// in HC_APTBS.csproj, so this page does not appear at all in
+ /// consumer builds.
+ ///
+ ///
+ public sealed partial class DeveloperPageViewModel : ObservableObject
+ {
+ private const string LogId = nameof(DeveloperPageViewModel);
+
+ private readonly IKwpService _kwp;
+ private readonly IAppLogger _log;
+
+ /// Root coordinator — exposes K-Line state for status binding.
+ public MainViewModel Root { get; }
+
+ // ── Input / output state ──────────────────────────────────────────────────
+
+ ///
+ /// Hex bytes typed by the developer. Whitespace, commas and dashes are
+ /// accepted as separators; e.g. "18 00 03 FF FF" or "18-00-03-FF-FF".
+ ///
+ [ObservableProperty] private string _hexInput = string.Empty;
+
+ /// Single-line status message (parse error, send result, …).
+ [ObservableProperty] private string _statusText = string.Empty;
+
+ /// True while a send is in flight — disables the Send button.
+ [ObservableProperty] private bool _isBusy;
+
+ /// True when the underlying K-Line session is open.
+ [ObservableProperty] private bool _isSessionOpen;
+
+ /// Time-stamped record of every TX/RX packet exchanged on this page.
+ public ObservableCollection Log { get; } = new();
+
+ // ── Child VMs ─────────────────────────────────────────────────────────────
+
+ /// Pump identification card VM, reused from the singleton on .
+ public ViewModels.PumpIdentificationViewModel Identification => Root.PumpIdentification;
+
+ /// ROM / EEPROM dump card.
+ public DeveloperToolsDumpViewModel Dump { get; }
+
+ /// Saved KWP custom commands library.
+ public DeveloperToolsCommandsViewModel Commands { get; }
+
+ /// EEPROM unlock password library.
+ public DeveloperToolsPasswordsViewModel Passwords { get; }
+
+ public DeveloperPageViewModel(
+ MainViewModel root,
+ IKwpService kwp,
+ IConfigurationService config,
+ IAppLogger log)
+ {
+ Root = root;
+ _kwp = kwp;
+ _log = log;
+
+ IsSessionOpen = root.KLineState == KLineConnectionState.Connected;
+ root.PropertyChanged += OnRootPropertyChanged;
+
+ Dump = new DeveloperToolsDumpViewModel(this, kwp, log);
+ Commands = new DeveloperToolsCommandsViewModel(this, kwp, config, log);
+ Passwords = new DeveloperToolsPasswordsViewModel(this, kwp, config, log);
+ }
+
+ private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(MainViewModel.KLineState))
+ {
+ IsSessionOpen = Root.KLineState == KLineConnectionState.Connected;
+ SendCommand.NotifyCanExecuteChanged();
+ }
+ }
+
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Sends the hex payload as a raw KWP custom packet over the persistent session.
+ [RelayCommand(CanExecute = nameof(CanSend))]
+ private async Task SendAsync()
+ {
+ if (!TryParseHex(HexInput, out var bytes, out var error))
+ {
+ StatusText = $"Parse error: {error}";
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ var hex = FormatHex(bytes);
+ StatusText = $"Sending {bytes.Length} byte(s)…";
+ AppendLog(DeveloperLogDirection.Tx, hex);
+ _log.Info(LogId, $"TX {hex}");
+
+ var responses = await _kwp.SendRawCustomAsync(bytes, CancellationToken.None);
+
+ if (responses.Count == 0)
+ {
+ AppendLog(DeveloperLogDirection.Info, "(no response)");
+ StatusText = "No response packets.";
+ return;
+ }
+
+ foreach (var pkt in responses)
+ {
+ var rxHex = FormatHex(pkt);
+ AppendLog(DeveloperLogDirection.Rx, rxHex);
+ _log.Info(LogId, $"RX {rxHex}");
+ }
+ StatusText = $"Received {responses.Count} packet(s).";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Send failed: {ex.Message}";
+ AppendLog(DeveloperLogDirection.Info, $"ERROR: {ex.Message}");
+ _log.Warning(LogId, $"SendAsync failed: {ex.Message}");
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private bool CanSend() => !IsBusy && IsSessionOpen;
+
+ /// Clears the on-screen log (does not affect AppLogger output).
+ [RelayCommand]
+ private void ClearLog()
+ {
+ Log.Clear();
+ StatusText = string.Empty;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Appends a row to the shared transaction log. Exposed as internal so
+ /// the page's child VMs (dump, command library, password library) can stream
+ /// their own TX/RX rows into the same scrolling log.
+ ///
+ internal void AppendLog(DeveloperLogDirection dir, string text)
+ {
+ // Keep memory bounded — drop oldest entries when the list grows large.
+ const int max = 500;
+ if (Log.Count >= max) Log.RemoveAt(0);
+ Log.Add(new DeveloperLogEntry(DateTime.Now, dir, text));
+ }
+
+ /// Internal accessor so child VMs can push status messages.
+ internal void SetStatus(string text) => StatusText = text;
+
+ ///
+ /// Formats a byte array as a space-separated uppercase hex string. Internal so
+ /// child VMs can produce log rows that match the parent's formatting.
+ ///
+ internal static string FormatHex(byte[] bytes) =>
+ string.Join(" ", bytes.Select(b => b.ToString("X2")));
+
+ ///
+ /// Same formatter for any read-only sequence (e.g.
+ /// returned by KWP read primitives).
+ ///
+ internal static string FormatHex(System.Collections.Generic.IReadOnlyList bytes)
+ {
+ var sb = new System.Text.StringBuilder(bytes.Count * 3);
+ for (int i = 0; i < bytes.Count; i++)
+ {
+ if (i > 0) sb.Append(' ');
+ sb.Append(bytes[i].ToString("X2"));
+ }
+ return sb.ToString();
+ }
+
+ ///
+ /// Parses a hex byte sequence. Accepts spaces, commas, dashes, and any
+ /// combination as separators. Each token must be 1–2 hex digits.
+ ///
+ internal static bool TryParseHex(string input, out byte[] bytes, out string error)
+ {
+ bytes = Array.Empty();
+ error = string.Empty;
+
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ error = "input is empty";
+ return false;
+ }
+
+ var separators = new[] { ' ', ',', '-', '\t', '\r', '\n' };
+ var tokens = input.Split(separators, StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length == 0)
+ {
+ error = "no hex bytes found";
+ return false;
+ }
+
+ var result = new byte[tokens.Length];
+ for (int i = 0; i < tokens.Length; i++)
+ {
+ var t = tokens[i];
+ if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
+ if (t.Length is < 1 or > 2)
+ {
+ error = $"token #{i + 1} '{tokens[i]}' must be 1–2 hex digits";
+ return false;
+ }
+ if (!byte.TryParse(t, System.Globalization.NumberStyles.HexNumber,
+ System.Globalization.CultureInfo.InvariantCulture, out byte b))
+ {
+ error = $"token #{i + 1} '{tokens[i]}' is not valid hex";
+ return false;
+ }
+ result[i] = b;
+ }
+
+ bytes = result;
+ return true;
+ }
+ }
+
+ /// Log entry direction — TX (sent), RX (received), or Info (status).
+ public enum DeveloperLogDirection { Tx, Rx, Info }
+
+ /// One row in the Developer Tools transaction log.
+ public sealed record DeveloperLogEntry(DateTime Timestamp, DeveloperLogDirection Direction, string Hex)
+ {
+ /// Pre-formatted line text for binding into a single TextBlock.
+ public string Display
+ {
+ get
+ {
+ var sb = new StringBuilder();
+ sb.Append('[').Append(Timestamp.ToString("HH:mm:ss.fff")).Append(']').Append(' ');
+ sb.Append(Direction switch
+ {
+ DeveloperLogDirection.Tx => "TX",
+ DeveloperLogDirection.Rx => "RX",
+ _ => " "
+ });
+ sb.Append(' ').Append(Hex);
+ return sb.ToString();
+ }
+ }
+ }
+}
diff --git a/ViewModels/Pages/DeveloperToolsCommandsViewModel.cs b/ViewModels/Pages/DeveloperToolsCommandsViewModel.cs
new file mode 100644
index 0000000..275d69b
--- /dev/null
+++ b/ViewModels/Pages/DeveloperToolsCommandsViewModel.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Models;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels.Pages
+{
+ ///
+ /// Developer Tools — saved KWP custom commands library. Wraps the persistence
+ /// surface in , exposes commands to send the
+ /// selected entry, save the parent's current hex input as a new entry, and
+ /// delete entries.
+ ///
+ /// Compiled into Debug builds only — see HC_APTBS.csproj.
+ ///
+ public sealed partial class DeveloperToolsCommandsViewModel : ObservableObject
+ {
+ private readonly DeveloperPageViewModel _parent;
+ private readonly IKwpService _kwp;
+ private readonly IConfigurationService _config;
+ private readonly IAppLogger _log;
+ private const string LogId = nameof(DeveloperToolsCommandsViewModel);
+
+ public DeveloperToolsCommandsViewModel(
+ DeveloperPageViewModel parent,
+ IKwpService kwp,
+ IConfigurationService config,
+ IAppLogger log)
+ {
+ _parent = parent;
+ _kwp = kwp;
+ _config = config;
+ _log = log;
+ }
+
+ /// Persistent collection from .
+ public ObservableCollection Items => _config.CustomCommands;
+
+ /// Currently selected list entry. Drives Send / Delete enable state.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendSelectedCommand))]
+ [NotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand))]
+ private CustomCommand? _selected;
+
+ /// Name typed into the "Save current as…" input.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SaveCurrentCommand))]
+ private string _newName = string.Empty;
+
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Sends the selected library entry over the persistent K-Line session.
+ [RelayCommand(CanExecute = nameof(CanSendSelected))]
+ private async Task SendSelectedAsync()
+ {
+ if (Selected is null) return;
+
+ if (!DeveloperPageViewModel.TryParseHex(Selected.HexBytes, out var bytes, out var error))
+ {
+ _parent.SetStatus($"'{Selected.Name}' has invalid hex: {error}");
+ return;
+ }
+
+ var hex = DeveloperPageViewModel.FormatHex(bytes);
+ _parent.AppendLog(DeveloperLogDirection.Tx, $"[{Selected.Name}] {hex}");
+ _parent.SetStatus($"Sending '{Selected.Name}' ({bytes.Length} byte(s))…");
+
+ try
+ {
+ var responses = await _kwp.SendRawCustomAsync(bytes, CancellationToken.None);
+ if (responses.Count == 0)
+ {
+ _parent.AppendLog(DeveloperLogDirection.Info, "(no response)");
+ _parent.SetStatus("No response packets.");
+ return;
+ }
+ foreach (var pkt in responses)
+ _parent.AppendLog(DeveloperLogDirection.Rx, DeveloperPageViewModel.FormatHex(pkt));
+ _parent.SetStatus($"Received {responses.Count} packet(s).");
+ }
+ catch (Exception ex)
+ {
+ _parent.AppendLog(DeveloperLogDirection.Info, $"ERROR: {ex.Message}");
+ _parent.SetStatus($"Send failed: {ex.Message}");
+ _log.Warning(LogId, $"SendSelected failed: {ex.Message}");
+ }
+ }
+
+ private bool CanSendSelected() => Selected is not null;
+
+ ///
+ /// Saves the parent VM's current HexInput as a new entry under
+ /// . Validates that hex parses before persisting.
+ ///
+ [RelayCommand(CanExecute = nameof(CanSaveCurrent))]
+ private void SaveCurrent()
+ {
+ var name = (NewName ?? string.Empty).Trim();
+ if (name.Length == 0)
+ {
+ _parent.SetStatus("Enter a name before saving the current command.");
+ return;
+ }
+
+ var hex = (_parent.HexInput ?? string.Empty).Trim();
+ if (!DeveloperPageViewModel.TryParseHex(hex, out var bytes, out var error))
+ {
+ _parent.SetStatus($"Cannot save '{name}': {error}");
+ return;
+ }
+
+ var normalized = DeveloperPageViewModel.FormatHex(bytes);
+ Items.Add(new CustomCommand { Name = name, HexBytes = normalized });
+ _config.SaveCustomCommands();
+
+ _parent.AppendLog(DeveloperLogDirection.Info, $"SAVED command '{name}' = {normalized}");
+ _parent.SetStatus($"Saved '{name}'.");
+ NewName = string.Empty;
+ }
+
+ private bool CanSaveCurrent() => !string.IsNullOrWhiteSpace(NewName);
+
+ /// Removes the selected entry from the library and persists.
+ [RelayCommand(CanExecute = nameof(CanDeleteSelected))]
+ private void DeleteSelected()
+ {
+ if (Selected is null) return;
+ var name = Selected.Name;
+ Items.Remove(Selected);
+ _config.SaveCustomCommands();
+ _parent.AppendLog(DeveloperLogDirection.Info, $"DELETED command '{name}'");
+ _parent.SetStatus($"Deleted '{name}'.");
+ Selected = null;
+ }
+
+ private bool CanDeleteSelected() => Selected is not null;
+ }
+}
diff --git a/ViewModels/Pages/DeveloperToolsDumpViewModel.cs b/ViewModels/Pages/DeveloperToolsDumpViewModel.cs
new file mode 100644
index 0000000..b5d2949
--- /dev/null
+++ b/ViewModels/Pages/DeveloperToolsDumpViewModel.cs
@@ -0,0 +1,366 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels.Pages
+{
+ /// Memory region selector for the dump card.
+ public enum DumpRegion
+ {
+ /// ROM range 0x0000–0x9FFF, read via KWP ReadRomEeprom (0x03).
+ Rom,
+ /// EEPROM range 0x00–0xFF, read via KWP ReadEeprom (0x19).
+ /// Note: only offsets 0x00–0xBF are valid per 256-byte block.
+ Eeprom,
+ }
+
+ ///
+ /// Developer Tools — ROM / EEPROM dump card. Iterates a user-supplied address
+ /// range in 13-byte chunks aligned to 256-byte block boundaries (mirroring the
+ /// legacy DumpRom / DumpEeprom routines in
+ /// docs/dump functions.txt) and writes the result to
+ /// %UserProfile%\.HC_APTBS\dumps\{ident}_{swver1}_{start:X4}-{end:X4}.bin.
+ ///
+ /// Compiled into Debug builds only — see HC_APTBS.csproj.
+ ///
+ public sealed partial class DeveloperToolsDumpViewModel : ObservableObject
+ {
+ private const int MaxChunk = 13;
+ private const int BlockSize = 0x0100;
+ // EEPROM has only 0x00–0xBF readable per 256-byte block via the unauth path;
+ // the legacy DumpEeprom routine treats this as the "valid bytes per block" cap.
+ private const int EepromValidBytesPerBlock = 0x00C0;
+
+ private readonly DeveloperPageViewModel _parent;
+ private readonly IKwpService _kwp;
+ private readonly IAppLogger _log;
+ private const string LogId = nameof(DeveloperToolsDumpViewModel);
+
+ ///
+ /// Threshold above which an in-flight dump shows the prominent overlay banner
+ /// (instead of just the inline progress bar). 0x0A00 = 2560 bytes; below that,
+ /// reads complete in a few seconds and don't need the bigger indicator.
+ ///
+ public const int LargeDumpByteThreshold = 0x0A00;
+
+ public DeveloperToolsDumpViewModel(DeveloperPageViewModel parent, IKwpService kwp, IAppLogger log)
+ {
+ _parent = parent;
+ _kwp = kwp;
+ _log = log;
+
+ // Sensible default: full ROM dump.
+ StartAddressHex = "0000";
+ EndAddressHex = "9FFF";
+ }
+
+ // ── Inputs ────────────────────────────────────────────────────────────────
+
+ /// Region radio: ROM or EEPROM. Updates address-range bounds + defaults.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DumpCommand))]
+ private DumpRegion _region = DumpRegion.Rom;
+
+ /// Start address in hex (no 0x prefix required).
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DumpCommand))]
+ private string _startAddressHex = string.Empty;
+
+ /// End address in hex (inclusive).
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DumpCommand))]
+ private string _endAddressHex = string.Empty;
+
+ /// Per-byte progress, 0..1, for the progress bar.
+ [ObservableProperty] private double _progress;
+
+ /// True while a dump is in flight — disables Dump button.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DumpCommand))]
+ private bool _isDumping;
+
+ ///
+ /// True while a dump is in flight AND the requested range is larger than
+ /// . Drives the page-level banner overlay
+ /// so the operator sees clearly that a long dump is running.
+ ///
+ [ObservableProperty] private bool _isLargeDumpInProgress;
+
+ /// Total bytes the current dump intends to read.
+ [ObservableProperty] private int _totalBytes;
+
+ /// Bytes received so far for the current dump.
+ [ObservableProperty] private int _bytesCollected;
+
+ /// Address of the next byte to read (for the banner display).
+ [ObservableProperty] private int _currentAddress;
+
+ /// Status / hint text shown under the inputs.
+ [ObservableProperty] private string _statusText = string.Empty;
+
+ /// True when the ROM region is selected. Two-way bindable for radio buttons.
+ public bool IsRomSelected
+ {
+ get => Region == DumpRegion.Rom;
+ set { if (value) Region = DumpRegion.Rom; }
+ }
+
+ /// True when the EEPROM region is selected. Two-way bindable for radio buttons.
+ public bool IsEepromSelected
+ {
+ get => Region == DumpRegion.Eeprom;
+ set { if (value) Region = DumpRegion.Eeprom; }
+ }
+
+ partial void OnRegionChanged(DumpRegion value)
+ {
+ // Reset to typical full-range defaults so the user doesn't accidentally
+ // dump a ROM-sized range out of EEPROM and get NAKs all the way.
+ switch (value)
+ {
+ case DumpRegion.Rom:
+ StartAddressHex = "0000";
+ EndAddressHex = "9FFF";
+ break;
+ case DumpRegion.Eeprom:
+ StartAddressHex = "0000";
+ EndAddressHex = "00BF";
+ break;
+ }
+
+ OnPropertyChanged(nameof(IsRomSelected));
+ OnPropertyChanged(nameof(IsEepromSelected));
+ }
+
+ // ── Command ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Iterates the selected range in 13-byte chunks aligned to 256-byte blocks
+ /// and writes the bytes to disk. Filename built from the current pump
+ /// identifier and SW ver 1 read by the K-Line session.
+ ///
+ [RelayCommand(CanExecute = nameof(CanDump))]
+ private async Task DumpAsync()
+ {
+ if (!TryParseAddresses(out int startAddr, out int endAddr, out string parseError))
+ {
+ StatusText = parseError;
+ return;
+ }
+
+ var ident = (_parent.Root.PumpIdentification.KlinePumpId ?? string.Empty).Trim();
+ var swVer1 = (_parent.Root.PumpIdentification.KlineSwVersion1 ?? string.Empty).Trim();
+ if (ident.Length == 0 || swVer1.Length == 0)
+ {
+ StatusText = "Read pump identification first — ident and SW ver 1 are required for the filename.";
+ return;
+ }
+
+ var dir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".HC_APTBS", "dumps");
+ try { Directory.CreateDirectory(dir); }
+ catch (Exception ex)
+ {
+ StatusText = $"Could not create dump folder: {ex.Message}";
+ return;
+ }
+
+ var fileName = $"{Sanitize(ident)}_{Sanitize(swVer1)}_{startAddr:X4}-{endAddr:X4}.bin";
+ var fullPath = Path.Combine(dir, fileName);
+
+ int totalBytes = endAddr - startAddr + 1;
+ TotalBytes = totalBytes;
+ BytesCollected = 0;
+ CurrentAddress = startAddr;
+ IsDumping = true;
+ IsLargeDumpInProgress = totalBytes > LargeDumpByteThreshold;
+ Progress = 0;
+
+ var buffer = new List(totalBytes);
+ int collected = 0;
+
+ try
+ {
+ _parent.AppendLog(DeveloperLogDirection.Info,
+ $"DUMP {Region} 0x{startAddr:X4}-0x{endAddr:X4} ({totalBytes} bytes) → {fileName}");
+
+ int addr = startAddr;
+ while (addr <= endAddr)
+ {
+ CurrentAddress = addr;
+ int blockBase = addr & 0xFF00;
+ int offsetInBlock = addr & 0x00FF;
+
+ int blockEndAbs;
+ if (Region == DumpRegion.Eeprom)
+ {
+ if (offsetInBlock >= EepromValidBytesPerBlock)
+ {
+ // Skip the unreadable tail of this block.
+ addr = blockBase + BlockSize;
+ continue;
+ }
+ blockEndAbs = blockBase + EepromValidBytesPerBlock - 1;
+ }
+ else
+ {
+ blockEndAbs = blockBase + BlockSize - 1;
+ }
+
+ int maxReadableAbs = Math.Min(endAddr, blockEndAbs);
+ while (addr <= maxReadableAbs)
+ {
+ int remaining = maxReadableAbs - addr + 1;
+ byte len = (byte)Math.Min(MaxChunk, remaining);
+
+ IReadOnlyList chunk = Region == DumpRegion.Rom
+ ? await _kwp.ReadRomEepromAsync((ushort)addr, len)
+ : await _kwp.ReadEepromAsync((ushort)addr, len);
+
+ if (chunk.Count == 0)
+ {
+ StatusText = $"Read failed at 0x{addr:X4} (NAK or no session). Saving partial dump.";
+ _parent.AppendLog(DeveloperLogDirection.Info,
+ $"DUMP aborted at 0x{addr:X4} — {collected}/{totalBytes} bytes captured.");
+ break;
+ }
+
+ buffer.AddRange(chunk);
+ addr += chunk.Count;
+ collected += chunk.Count;
+ BytesCollected = collected;
+ CurrentAddress = addr;
+ Progress = (double)collected / totalBytes;
+ }
+
+ if (addr <= endAddr && (addr & 0x00FF) == 0)
+ {
+ // Already advanced to the next block — keep going.
+ continue;
+ }
+ if (addr <= endAddr)
+ {
+ // We broke out of the inner loop early due to a read failure.
+ break;
+ }
+ }
+
+ if (buffer.Count > 0)
+ {
+ File.WriteAllBytes(fullPath, buffer.ToArray());
+ _parent.AppendLog(DeveloperLogDirection.Info,
+ $"DUMP saved {buffer.Count} byte(s) → {fullPath}");
+ StatusText = buffer.Count == totalBytes
+ ? $"Dump complete: {buffer.Count} bytes saved."
+ : $"Partial dump: {buffer.Count}/{totalBytes} bytes saved.";
+ }
+ else
+ {
+ StatusText = "Dump produced no bytes — nothing was written.";
+ _parent.AppendLog(DeveloperLogDirection.Info, "DUMP produced no bytes — skipped file write.");
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"DumpAsync failed: {ex.Message}");
+ StatusText = $"Dump failed: {ex.Message}";
+ _parent.AppendLog(DeveloperLogDirection.Info, $"DUMP error: {ex.Message}");
+ }
+ finally
+ {
+ IsDumping = false;
+ IsLargeDumpInProgress = false;
+ }
+
+ // Modal confirmation so the operator can't miss completion of a long dump.
+ var savedToText = buffer.Count > 0 ? $"\n\nFile: {fullPath}" : string.Empty;
+ var summary = $"{StatusText}{savedToText}";
+ var image = buffer.Count == totalBytes
+ ? System.Windows.MessageBoxImage.Information
+ : System.Windows.MessageBoxImage.Warning;
+ System.Windows.Application.Current?.Dispatcher.BeginInvoke(() =>
+ System.Windows.MessageBox.Show(
+ summary,
+ "Dump finished",
+ System.Windows.MessageBoxButton.OK,
+ image));
+ }
+
+ private bool CanDump()
+ {
+ if (IsDumping) return false;
+ if (string.IsNullOrWhiteSpace(StartAddressHex) || string.IsNullOrWhiteSpace(EndAddressHex)) return false;
+ return TryParseAddresses(out _, out _, out _);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private bool TryParseAddresses(out int startAddr, out int endAddr, out string error)
+ {
+ startAddr = 0;
+ endAddr = 0;
+ error = string.Empty;
+
+ if (!TryParseHexAddress(StartAddressHex, out startAddr))
+ {
+ error = $"Bad start address '{StartAddressHex}'.";
+ return false;
+ }
+ if (!TryParseHexAddress(EndAddressHex, out endAddr))
+ {
+ error = $"Bad end address '{EndAddressHex}'.";
+ return false;
+ }
+ if (startAddr > endAddr)
+ {
+ error = "Invalid range: start > end.";
+ return false;
+ }
+
+ int min, max;
+ switch (Region)
+ {
+ case DumpRegion.Rom:
+ min = 0x0000;
+ max = 0x9FFF;
+ break;
+ case DumpRegion.Eeprom:
+ min = 0x0000;
+ max = 0x00FF;
+ break;
+ default:
+ error = "Unknown region.";
+ return false;
+ }
+ if (startAddr < min || endAddr > max)
+ {
+ error = $"Range outside {Region} bounds (0x{min:X4}–0x{max:X4}).";
+ return false;
+ }
+ return true;
+ }
+
+ private static bool TryParseHexAddress(string text, out int value)
+ {
+ value = 0;
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ var t = text.Trim();
+ if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
+ return int.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
+ }
+
+ private static string Sanitize(string s)
+ {
+ var chars = s.Select(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-' ? c : '_').ToArray();
+ return new string(chars);
+ }
+ }
+}
diff --git a/ViewModels/Pages/DeveloperToolsPasswordsViewModel.cs b/ViewModels/Pages/DeveloperToolsPasswordsViewModel.cs
new file mode 100644
index 0000000..f07cfb2
--- /dev/null
+++ b/ViewModels/Pages/DeveloperToolsPasswordsViewModel.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Models;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels.Pages
+{
+ ///
+ /// Developer Tools — EEPROM unlock password library. Persisted via
+ /// .
+ ///
+ /// "Apply" sends the standard KWP unlock packet
+ /// [0x18 0x00 Zone KeyHi KeyLo] over the persistent K-Line session
+ /// (per docs/kline_eeprom_spec.md) and logs ACK/NAK in the parent's
+ /// transaction log. "Add" pushes a new entry built from the small inline editor.
+ ///
+ /// Compiled into Debug builds only — see HC_APTBS.csproj.
+ ///
+ public sealed partial class DeveloperToolsPasswordsViewModel : ObservableObject
+ {
+ private readonly DeveloperPageViewModel _parent;
+ private readonly IKwpService _kwp;
+ private readonly IConfigurationService _config;
+ private readonly IAppLogger _log;
+ private const string LogId = nameof(DeveloperToolsPasswordsViewModel);
+
+ public DeveloperToolsPasswordsViewModel(
+ DeveloperPageViewModel parent,
+ IKwpService kwp,
+ IConfigurationService config,
+ IAppLogger log)
+ {
+ _parent = parent;
+ _kwp = kwp;
+ _config = config;
+ _log = log;
+ }
+
+ /// Persistent collection from .
+ public ObservableCollection Items => _config.EepromPasswords;
+
+ /// Currently selected entry. Drives Apply / Delete enable state.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ApplySelectedCommand))]
+ [NotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand))]
+ private EepromPassword? _selected;
+
+ // ── Inline "Add new" editor ──────────────────────────────────────────────
+
+ /// Display name for the new entry.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddCommand))]
+ private string _newName = string.Empty;
+
+ /// Zone byte for the new entry, hex (e.g. "03").
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddCommand))]
+ private string _newZoneHex = "00";
+
+ /// 16-bit key for the new entry, hex (e.g. "00FF").
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddCommand))]
+ private string _newKeyHex = "0000";
+
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Sends [0x18 0x00 Zone KeyHi KeyLo] for the selected password.
+ [RelayCommand(CanExecute = nameof(CanApplySelected))]
+ private async Task ApplySelectedAsync()
+ {
+ if (Selected is null) return;
+
+ byte[] payload =
+ {
+ 0x18,
+ 0x00,
+ Selected.Zone,
+ (byte)(Selected.Key >> 8),
+ (byte)(Selected.Key & 0xFF),
+ };
+ var hex = DeveloperPageViewModel.FormatHex(payload);
+ _parent.AppendLog(DeveloperLogDirection.Tx, $"[Apply '{Selected.Name}'] {hex}");
+ _parent.SetStatus($"Applying '{Selected.Name}' (zone 0x{Selected.Zone:X2}, key 0x{Selected.Key:X4})…");
+
+ try
+ {
+ var responses = await _kwp.SendRawCustomAsync(payload, CancellationToken.None);
+ if (responses.Count == 0)
+ {
+ _parent.AppendLog(DeveloperLogDirection.Info, "(no response)");
+ _parent.SetStatus("No response — session may not be open.");
+ return;
+ }
+ foreach (var pkt in responses)
+ _parent.AppendLog(DeveloperLogDirection.Rx, DeveloperPageViewModel.FormatHex(pkt));
+
+ // Heuristic: a NAK is a single 3-byte packet with title 0x0A;
+ // anything else is treated as success at the protocol layer.
+ bool nak = responses.Count == 1 && responses[0].Length >= 3 && responses[0][2] == 0x0A;
+ _parent.SetStatus(nak
+ ? $"'{Selected.Name}' rejected (NAK)."
+ : $"'{Selected.Name}' accepted.");
+ }
+ catch (Exception ex)
+ {
+ _parent.AppendLog(DeveloperLogDirection.Info, $"ERROR: {ex.Message}");
+ _parent.SetStatus($"Apply failed: {ex.Message}");
+ _log.Warning(LogId, $"ApplySelected failed: {ex.Message}");
+ }
+ }
+
+ private bool CanApplySelected() => Selected is not null;
+
+ /// Adds a new entry from the inline editor and persists.
+ [RelayCommand(CanExecute = nameof(CanAdd))]
+ private void Add()
+ {
+ var name = (NewName ?? string.Empty).Trim();
+ if (name.Length == 0)
+ {
+ _parent.SetStatus("Enter a name before adding a password.");
+ return;
+ }
+ if (!TryParseHexByte(NewZoneHex, out byte zone))
+ {
+ _parent.SetStatus($"Bad zone hex '{NewZoneHex}'.");
+ return;
+ }
+ if (!TryParseHexUshort(NewKeyHex, out ushort key))
+ {
+ _parent.SetStatus($"Bad key hex '{NewKeyHex}'.");
+ return;
+ }
+
+ Items.Add(new EepromPassword { Name = name, Zone = zone, Key = key });
+ _config.SaveEepromPasswords();
+ _parent.AppendLog(DeveloperLogDirection.Info,
+ $"ADDED password '{name}' (zone 0x{zone:X2}, key 0x{key:X4})");
+ _parent.SetStatus($"Added '{name}'.");
+
+ NewName = string.Empty;
+ NewZoneHex = "00";
+ NewKeyHex = "0000";
+ }
+
+ private bool CanAdd() =>
+ !string.IsNullOrWhiteSpace(NewName)
+ && TryParseHexByte(NewZoneHex, out _)
+ && TryParseHexUshort(NewKeyHex, out _);
+
+ /// Removes the selected entry from the library and persists.
+ [RelayCommand(CanExecute = nameof(CanDeleteSelected))]
+ private void DeleteSelected()
+ {
+ if (Selected is null) return;
+ var name = Selected.Name;
+ Items.Remove(Selected);
+ _config.SaveEepromPasswords();
+ _parent.AppendLog(DeveloperLogDirection.Info, $"DELETED password '{name}'");
+ _parent.SetStatus($"Deleted '{name}'.");
+ Selected = null;
+ }
+
+ private bool CanDeleteSelected() => Selected is not null;
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static bool TryParseHexByte(string text, out byte value)
+ {
+ value = 0;
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ var t = text.Trim();
+ if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
+ return byte.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
+ }
+
+ private static bool TryParseHexUshort(string text, out ushort value)
+ {
+ value = 0;
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ var t = text.Trim();
+ if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
+ return ushort.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
+ }
+ }
+}
diff --git a/ViewModels/Pages/PumpPageViewModel.cs b/ViewModels/Pages/PumpPageViewModel.cs
index a91c942..4ff5553 100644
--- a/ViewModels/Pages/PumpPageViewModel.cs
+++ b/ViewModels/Pages/PumpPageViewModel.cs
@@ -1,5 +1,7 @@
+using System;
using System.ComponentModel;
-using System.Windows;
+using System.Windows.Media;
+using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models;
using HC_APTBS.ViewModels.Dialogs;
@@ -40,12 +42,17 @@ namespace HC_APTBS.ViewModels.Pages
/// Second pump status display — Empf3 word.
public StatusDisplayViewModel StatusDisplay2 => Root.StatusDisplay2;
+ /// BIP-STATUS display (PSG5-PI pumps only; hidden via HasDefinition for others).
+ public BipDisplayViewModel BipDisplay => Root.BipDisplay;
+
/// Current immobilizer unlock VM. Null when no unlock is in progress.
public UnlockProgressViewModel? UnlockVm => Root.CurrentUnlockVm;
/// Real-time RPM chart (120-sample rolling window).
public SingleFlowChartViewModel RpmChart { get; }
+ private readonly DispatcherTimer _rpmChartTimer;
+
// ── Banner flags (derived from Root state) ────────────────────────────────
/// True when a pump has been loaded from the database.
@@ -67,14 +74,38 @@ namespace HC_APTBS.ViewModels.Pages
{
Root = root;
DtcList = dtcList;
- RpmChart = new SingleFlowChartViewModel("RPM", new SKColor(0x21, 0x96, 0xF3), maxSamples: 120);
+ RpmChart = new SingleFlowChartViewModel(
+ "RPM",
+ new SKColor(0x21, 0x96, 0xF3),
+ maxSamples: 120,
+ smoothScroll: true);
+
+ _rpmChartTimer = new DispatcherTimer
+ {
+ Interval = HzToInterval(root.Config.Settings.RpmChartUpdateHz)
+ };
+ _rpmChartTimer.Tick += (_, _) => RpmChart.AddValue(Root.PumpRpm);
+ _rpmChartTimer.Start();
+
+ // Per-frame viewport slide — produces smooth continuous leftward motion independent
+ // of data cadence. Safe to leave subscribed for the VM's lifetime (one DateTime.UtcNow
+ // and two property sets per render frame).
+ CompositionTarget.Rendering += OnRenderFrame;
RefreshDerivedFlags();
Root.PropertyChanged += OnRootPropertyChanged;
+ Root.SettingsSaved += OnSettingsSaved;
Root.PumpIdentification.PumpChanged += _ => RefreshDerivedFlags();
}
+ private void OnRenderFrame(object? sender, EventArgs e)
+ {
+ int hz = Root.Config.Settings.RpmChartUpdateHz;
+ if (hz <= 0) hz = 15;
+ RpmChart.UpdateViewport(DateTime.UtcNow, 1000.0 / hz);
+ }
+
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
@@ -87,13 +118,17 @@ namespace HC_APTBS.ViewModels.Pages
case nameof(MainViewModel.CurrentUnlockVm):
OnPropertyChanged(nameof(UnlockVm));
break;
-
- case nameof(MainViewModel.PumpRpm):
- Application.Current.Dispatcher.Invoke(() => RpmChart.AddValue(Root.PumpRpm));
- break;
}
}
+ private void OnSettingsSaved()
+ {
+ _rpmChartTimer.Interval = HzToInterval(Root.Config.Settings.RpmChartUpdateHz);
+ }
+
+ private static System.TimeSpan HzToInterval(int hz) =>
+ System.TimeSpan.FromMilliseconds(hz > 0 ? 1000.0 / hz : 1000.0 / 15);
+
private void RefreshDerivedFlags()
{
IsPumpSelected = Root.CurrentPump != null;
diff --git a/ViewModels/Pages/SettingsPageViewModel.cs b/ViewModels/Pages/SettingsPageViewModel.cs
index 120fa8e..2ea5f4c 100644
--- a/ViewModels/Pages/SettingsPageViewModel.cs
+++ b/ViewModels/Pages/SettingsPageViewModel.cs
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Windows;
using HC_APTBS.Infrastructure.Kwp;
+using HC_APTBS.Infrastructure.Logging;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
@@ -21,6 +22,7 @@ namespace HC_APTBS.ViewModels.Pages
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
+ private readonly IAppLogger _log;
///
/// Raised after successfully persists settings.
@@ -54,6 +56,12 @@ namespace HC_APTBS.ViewModels.Pages
[ObservableProperty] private double _tolerancePfpExtension = 0.1;
[ObservableProperty] private bool _defaultIgnoreTin = true;
+ ///
+ /// When true, the Dashboard "Connect & Auto Test" flow bypasses the oil-pump
+ /// leak-check dialog. Operator opts in once; does not affect manual controls.
+ ///
+ [ObservableProperty] private bool _autoTestSkipsOilPumpConfirm;
+
// ── PID ───────────────────────────────────────────────────────────────
[ObservableProperty] private double _pidP = 0.1;
@@ -87,15 +95,18 @@ namespace HC_APTBS.ViewModels.Pages
[ObservableProperty] private int _refreshPumpParamsMs = 4;
[ObservableProperty] private int _blinkIntervalMs = 1000;
[ObservableProperty] private int _flasherIntervalMs = 800;
+ [ObservableProperty] private int _rpmChartUpdateHz = 15;
// ── Constructor ───────────────────────────────────────────────────────
/// Configuration service for loading/saving settings.
/// Localization service for language switching.
- public SettingsPageViewModel(IConfigurationService configService, ILocalizationService localizationService)
+ /// Application logger.
+ public SettingsPageViewModel(IConfigurationService configService, ILocalizationService localizationService, IAppLogger logger)
{
_config = configService;
_loc = localizationService;
+ _log = logger;
LoadFromConfig();
EnumerateFtdiDevices();
@@ -107,6 +118,7 @@ namespace HC_APTBS.ViewModels.Pages
[RelayCommand]
private void Save()
{
+ _log.Info("SETTINGSVM", $"Save invoked. SelectedLanguage='{SelectedLanguage}', loc.CurrentLanguage='{_loc.CurrentLanguage}'");
var s = _config.Settings;
// General
@@ -120,6 +132,7 @@ namespace HC_APTBS.ViewModels.Pages
s.ToleranceUpExtension = ToleranceUpExtension;
s.TolerancePfpExtension = TolerancePfpExtension;
s.DefaultIgnoreTin = DefaultIgnoreTin;
+ s.AutoTestSkipsOilPumpConfirm = AutoTestSkipsOilPumpConfirm;
// PID
s.PidP = PidP;
@@ -150,6 +163,7 @@ namespace HC_APTBS.ViewModels.Pages
s.RefreshPumpParamsMs = RefreshPumpParamsMs;
s.BlinkIntervalMs = BlinkIntervalMs;
s.FlasherIntervalMs = FlasherIntervalMs;
+ s.RpmChartUpdateHz = RpmChartUpdateHz;
// Language — switch if changed (also persists via LocalizationService)
if (SelectedLanguage != _loc.CurrentLanguage)
@@ -259,6 +273,7 @@ namespace HC_APTBS.ViewModels.Pages
ToleranceUpExtension = s.ToleranceUpExtension;
TolerancePfpExtension = s.TolerancePfpExtension;
DefaultIgnoreTin = s.DefaultIgnoreTin;
+ AutoTestSkipsOilPumpConfirm = s.AutoTestSkipsOilPumpConfirm;
// PID
PidP = s.PidP;
@@ -288,6 +303,7 @@ namespace HC_APTBS.ViewModels.Pages
RefreshPumpParamsMs = s.RefreshPumpParamsMs;
BlinkIntervalMs = s.BlinkIntervalMs;
FlasherIntervalMs = s.FlasherIntervalMs;
+ RpmChartUpdateHz = s.RpmChartUpdateHz;
// Deep-copy the RPM-voltage relation table
Relations.Clear();
diff --git a/ViewModels/Pages/TestsPageViewModel.cs b/ViewModels/Pages/TestsPageViewModel.cs
index ff53861..f5fe0e5 100644
--- a/ViewModels/Pages/TestsPageViewModel.cs
+++ b/ViewModels/Pages/TestsPageViewModel.cs
@@ -1,55 +1,38 @@
+using System;
+using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
+using System.Threading.Tasks;
using System.Windows;
+using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
- /// Wrapper VM exposing when the wizard is in the Plan step.
- public sealed class PlanStateViewModel
- {
- /// Shared test panel (enable/disable phases).
- public TestPanelViewModel TestPanel { get; }
-
- /// Creates a new Plan-state wrapper around the shared test panel.
- public PlanStateViewModel(TestPanelViewModel testPanel) => TestPanel = testPanel;
- }
-
- /// Wrapper VM exposing when the wizard is in the Running step.
- public sealed class RunningStateViewModel
- {
- /// Shared test panel (live phase updates).
- public TestPanelViewModel TestPanel { get; }
-
- /// Creates a new Running-state wrapper around the shared test panel.
- public RunningStateViewModel(TestPanelViewModel testPanel) => TestPanel = testPanel;
- }
-
///
- /// Orchestrator view-model for the Tests navigation page.
+ /// Single-page Tests orchestrator.
///
- /// Drives the Plan → Preconditions → Running → Done wizard defined in
- /// docs/ui-structure.md §4. Exposes , which the
- /// view's ContentControl routes through typed DataTemplates to the four
- /// step views. Commands (, ,
- /// , ,
- /// ) form the wizard's state-machine edges.
- ///
- /// Observes to perform the
- /// Preconditions→Running (on true) and Running→Done (on false) transitions
- /// automatically, so the page stays in sync regardless of which control fired
- /// the underlying start/stop command.
+ /// Replaces the former Plan → Preconditions → Running → Done wizard with
+ /// one status-bar-driven page. The body is always the test-section cards; the
+ /// status bar narrates the current state (idle / blocked / running / complete)
+ /// and the action bar exposes the contextual primary action (Start, Abort, or
+ /// Report + Clear). A PASSED / FAILED snackbar overlay auto-dismisses a few
+ /// seconds after the test finishes; the Report / Clear-data buttons stay
+ /// available on the action bar until the operator clears the results.
///
public sealed partial class TestsPageViewModel : ObservableObject
{
+ private const int SnackbarDurationMs = 5000;
+
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
+ private DispatcherTimer? _snackbarTimer;
+
/// Root ViewModel — owns services, live readings, and global commands.
public MainViewModel Root { get; }
@@ -59,119 +42,101 @@ namespace HC_APTBS.ViewModels.Pages
/// Measurement results table (per-phase pass/fail).
public ResultDisplayViewModel ResultDisplay => Root.ResultDisplay;
- /// Preconditions checklist — lazily instantiated on first entry into the step.
- public TestPreconditionsViewModel Preconditions { get; }
-
- /// Auth gate scoped to the Tests page (used by preconditions for auth-required tests).
+ /// Auth gate scoped to the Tests page (used when an enabled test has RequiresAuth).
public AuthGateViewModel TestAuth { get; }
- private readonly PlanStateViewModel _planVm;
- private readonly RunningStateViewModel _runningVm;
-
- ///
- /// Creates the Tests page orchestrator.
- ///
- /// Root coordinator.
- /// Configuration service (passed to the scoped auth gate).
- /// Localisation service.
+ /// Creates the Tests page orchestrator.
public TestsPageViewModel(MainViewModel root, IConfigurationService config, ILocalizationService loc)
{
Root = root;
_config = config;
_loc = loc;
- TestAuth = new AuthGateViewModel(config, loc);
- Preconditions = new TestPreconditionsViewModel(root, loc, Root.TestPanel, TestAuth);
- _planVm = new PlanStateViewModel(Root.TestPanel);
- _runningVm = new RunningStateViewModel(Root.TestPanel);
+ TestAuth = new AuthGateViewModel(config, loc);
- CurrentStateVm = _planVm;
+ Root.PropertyChanged += OnRootPropertyChanged;
+ Root.DashboardAlarms.PropertyChanged += OnAlarmsPropertyChanged;
+ Root.TestPanel.PropertyChanged += OnTestPanelPropertyChanged;
+ Root.TestPanel.Tests.CollectionChanged += OnTestSectionsChanged;
+ TestAuth.PropertyChanged += OnAuthPropertyChanged;
+ _loc.LanguageChanged += OnLanguageChanged;
- Root.PropertyChanged += OnRootPropertyChanged;
+ RebindPhaseEnabledWatchers();
+ RefreshAuthRequired();
+ Reevaluate();
}
- // ── State ─────────────────────────────────────────────────────────────────
+ // ── Runtime state ─────────────────────────────────────────────────────────
- /// Current wizard step.
+ /// Mirrors .
+ public bool IsTestRunning => Root.IsTestRunning;
+
+ /// Latched after a test completes; gates Report / Clear-data buttons.
[ObservableProperty]
- [NotifyCanExecuteChangedFor(nameof(NextCommand))]
- [NotifyCanExecuteChangedFor(nameof(BackCommand))]
- [NotifyCanExecuteChangedFor(nameof(AbortCommand))]
- [NotifyCanExecuteChangedFor(nameof(RunAgainCommand))]
- [NotifyCanExecuteChangedFor(nameof(ViewFullResultsCommand))]
- private TestFlowState _currentState = TestFlowState.Plan;
+ [NotifyCanExecuteChangedFor(nameof(ClearTestDataCommand))]
+ [NotifyPropertyChangedFor(nameof(ShowDoneSnackbar))]
+ private bool _hasCompletedResults;
- ///
- /// View-model currently rendered by the step ContentControl. Swaps to
- /// , ,
- /// , or this (for the Done step).
- ///
- [ObservableProperty] private object _currentStateVm;
+ /// True if the most recent completed run passed.
+ [ObservableProperty] private bool _lastRunPassed;
- /// Convenience flag for view styling — true while a test is actively running.
- public bool IsRunningStep => CurrentState == TestFlowState.Running;
+ /// Auto-dismiss flag for the Done snackbar overlay.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowDoneSnackbar))]
+ private bool _isSnackbarVisible;
- /// Convenience flag for view styling — true when the page is on the Done step.
- public bool IsDoneStep => CurrentState == TestFlowState.Done;
+ /// True while the PASSED / FAILED snackbar overlay is visible.
+ public bool ShowDoneSnackbar => HasCompletedResults && IsSnackbarVisible;
- partial void OnCurrentStateChanged(TestFlowState oldValue, TestFlowState newValue)
- {
- if (oldValue == TestFlowState.Preconditions && newValue != TestFlowState.Preconditions)
- Preconditions.Deactivate();
+ // ── Preconditions ─────────────────────────────────────────────────────────
- switch (newValue)
- {
- case TestFlowState.Plan:
- CurrentStateVm = _planVm;
- break;
- case TestFlowState.Preconditions:
- Preconditions.Activate();
- Preconditions.OnEnabledPhasesChanged();
- CurrentStateVm = Preconditions;
- break;
- case TestFlowState.Running:
- CurrentStateVm = _runningVm;
- break;
- case TestFlowState.Done:
- CurrentStateVm = this;
- break;
- }
+ /// True when at least one enabled test requires operator authentication.
+ [ObservableProperty] private bool _isAuthRequired;
- OnPropertyChanged(nameof(IsRunningStep));
- OnPropertyChanged(nameof(IsDoneStep));
- }
+ /// True when every required precondition passes.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(StartTestCommand))]
+ private bool _allPreconditionsPassed;
+
+ /// Localised blocker message shown on the status bar when Start is not available.
+ [ObservableProperty] private string _blockingReason = string.Empty;
+
+ /// Localised headline shown on the status bar (state summary).
+ [ObservableProperty] private string _statusHeadline = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
///
- /// Advances Plan → Preconditions. No-op if no phases are enabled (defensive — the
- /// Preconditions step would fail its auth-detection anyway so there's nothing to
- /// start). The phase-enable state is not observed live, so the button itself is
- /// only guarded by .
+ /// Starts the test sequence. Mirrors the former Preconditions step: if the oil
+ /// pump is off, turning it on triggers the leak-check confirmation dialog; a
+ /// cancelled confirmation aborts the start.
///
- [RelayCommand(CanExecute = nameof(CanNext))]
- private void Next()
+ [RelayCommand(CanExecute = nameof(CanStart))]
+ private async Task StartTestAsync()
{
- if (CurrentState != TestFlowState.Plan) return;
- if (!Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled))) return;
- CurrentState = TestFlowState.Preconditions;
+ if (!Root.BenchControl.IsOilPumpOn)
+ {
+ // Setter shows OilPumpConfirmDialog; reverts on cancel.
+ // BenchControlViewModel.OnIsOilPumpOnChanged re-asserts the
+ // backing field after SetRelay so the guard below reflects
+ // the user's choice even if RefreshFromTick fired during
+ // the dialog's nested dispatcher pump.
+ Root.BenchControl.IsOilPumpOn = true;
+ if (!Root.BenchControl.IsOilPumpOn)
+ return;
+ }
+
+ // CanStart already gated on every precondition (pump/CAN/alarms/auth/
+ // phases), so bypass Root.StartTestCommand.CanExecute — it can report
+ // false transiently right after the oil-pump relay toggles — and drive
+ // the async command directly. Awaited so any exception surfaces on
+ // the command's ExecutionTask instead of being swallowed.
+ await Root.StartTestCommand.ExecuteAsync(null);
}
- private bool CanNext() => CurrentState == TestFlowState.Plan;
+ private bool CanStart() => AllPreconditionsPassed && !IsTestRunning;
- /// Goes back Preconditions → Plan. Disabled during Running / Done.
- [RelayCommand(CanExecute = nameof(CanBack))]
- private void Back()
- {
- if (CurrentState == TestFlowState.Preconditions)
- CurrentState = TestFlowState.Plan;
- }
-
- private bool CanBack() => CurrentState == TestFlowState.Preconditions;
-
- ///
- /// Opens a confirmation dialog and, if accepted, delegates to .
- ///
+ /// Confirms then delegates to .
[RelayCommand(CanExecute = nameof(CanAbort))]
private void Abort()
{
@@ -190,44 +155,208 @@ namespace HC_APTBS.ViewModels.Pages
Root.StopTestCommand.Execute(null);
}
- private bool CanAbort() => CurrentState == TestFlowState.Running;
+ private bool CanAbort() => IsTestRunning;
- /// Resets the page for a fresh run without reloading the pump.
- [RelayCommand(CanExecute = nameof(CanRunAgain))]
- private void RunAgain()
+ /// Resets phase results and hides post-test affordances.
+ [RelayCommand(CanExecute = nameof(CanClearTestData))]
+ private void ClearTestData()
{
Root.TestPanel.ResetResults();
Root.ResultDisplay.Clear();
- CurrentState = TestFlowState.Plan;
+ HasCompletedResults = false;
+ IsSnackbarVisible = false;
+ StopSnackbarTimer();
+ Reevaluate();
}
- private bool CanRunAgain() => CurrentState == TestFlowState.Done;
+ private bool CanClearTestData() => HasCompletedResults && !IsTestRunning;
- /// Jumps to the Results navigation page.
- [RelayCommand(CanExecute = nameof(CanViewFullResults))]
- private void ViewFullResults()
+ /// Dismisses the pass/fail snackbar overlay (Report / Clear remain on the action bar).
+ [RelayCommand]
+ private void DismissSnackbar()
{
- Root.SelectedPage = AppPage.Results;
+ IsSnackbarVisible = false;
+ StopSnackbarTimer();
}
- private bool CanViewFullResults() => CurrentState == TestFlowState.Done;
+ // ── Evaluation ────────────────────────────────────────────────────────────
- // ── IsTestRunning → wizard state sync ─────────────────────────────────────
+ private void Reevaluate()
+ {
+ bool hasPump = Root.CurrentPump != null;
+ bool hasCan = Root.IsCanConnected;
+ bool noAlarms = !Root.DashboardAlarms.HasCritical;
+ bool authOk = !IsAuthRequired || TestAuth.IsAuthenticated;
+ bool anyPhase = Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled));
+
+ string blocker =
+ !hasPump ? _loc.GetString("Test.Precheck.Remediation.SelectPump") :
+ !hasCan ? _loc.GetString("Test.Precheck.Remediation.CheckCan") :
+ !noAlarms ? _loc.GetString("Test.Precheck.Remediation.ClearAlarms") :
+ !authOk ? _loc.GetString("Test.Precheck.Remediation.Authenticate") :
+ !anyPhase ? _loc.GetString("Test.Status.NoPhases") :
+ string.Empty;
+
+ BlockingReason = blocker;
+ AllPreconditionsPassed = blocker.Length == 0;
+
+ RefreshStatusHeadline();
+ }
+
+ private void RefreshStatusHeadline()
+ {
+ if (IsTestRunning)
+ StatusHeadline = _loc.GetString("Test.Status.Running");
+ else if (HasCompletedResults)
+ StatusHeadline = _loc.GetString(LastRunPassed ? "Test.Done.Passed" : "Test.Done.Failed");
+ else if (AllPreconditionsPassed)
+ StatusHeadline = _loc.GetString("Test.Status.Ready");
+ else
+ StatusHeadline = _loc.GetString("Test.Status.NotReady");
+ }
+
+ private void RefreshAuthRequired()
+ {
+ IsAuthRequired = Root.TestPanel.Tests
+ .Any(s => (s.Source?.RequiresAuth ?? false) && s.Phases.Any(p => p.IsEnabled));
+ }
+
+ // ── Event handlers ────────────────────────────────────────────────────────
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName != nameof(MainViewModel.IsTestRunning)) return;
+ switch (e.PropertyName)
+ {
+ case nameof(MainViewModel.CurrentPump):
+ case nameof(MainViewModel.IsCanConnected):
+ Reevaluate();
+ break;
+ case nameof(MainViewModel.IsTestRunning):
+ OnPropertyChanged(nameof(IsTestRunning));
+ StartTestCommand.NotifyCanExecuteChanged();
+ AbortCommand.NotifyCanExecuteChanged();
+ ClearTestDataCommand.NotifyCanExecuteChanged();
+ OnTestRunningChanged();
+ break;
+
+ case nameof(MainViewModel.LastTestSuccess):
+ LastRunPassed = Root.LastTestSuccess;
+ RefreshStatusHeadline();
+ break;
+ }
+ }
+
+ private void OnTestRunningChanged()
+ {
if (Root.IsTestRunning)
{
- if (CurrentState == TestFlowState.Preconditions)
- CurrentState = TestFlowState.Running;
+ // Starting a fresh run — hide any stale completion state.
+ HasCompletedResults = false;
+ IsSnackbarVisible = false;
+ StopSnackbarTimer();
}
else
{
- if (CurrentState == TestFlowState.Running)
- CurrentState = TestFlowState.Done;
+ // Just finished.
+ LastRunPassed = Root.LastTestSuccess;
+ HasCompletedResults = true;
+ IsSnackbarVisible = true;
+ StartSnackbarTimer();
}
+
+ RefreshStatusHeadline();
+ }
+
+ private void OnAlarmsPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(DashboardAlarmsViewModel.HasCritical))
+ Reevaluate();
+ }
+
+ private void OnAuthPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(AuthGateViewModel.IsAuthenticated))
+ Reevaluate();
+ }
+
+ private void OnTestPanelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(TestPanelViewModel.RemainingSeconds))
+ {
+ RefreshAuthRequired();
+ Reevaluate();
+ }
+ }
+
+ private void OnTestSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ RebindPhaseEnabledWatchers();
+ RefreshAuthRequired();
+ Reevaluate();
+ }
+
+ ///
+ /// TestPanel does not raise a dedicated event for individual phase toggles, so
+ /// we subscribe directly to every . Called once
+ /// at construction and again whenever the section collection is replaced.
+ ///
+ private void RebindPhaseEnabledWatchers()
+ {
+ foreach (var section in Root.TestPanel.Tests)
+ {
+ foreach (var phase in section.Phases)
+ {
+ phase.PropertyChanged -= OnPhaseCardPropertyChanged;
+ phase.PropertyChanged += OnPhaseCardPropertyChanged;
+ }
+ }
+ }
+
+ private void OnPhaseCardPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(PhaseCardViewModel.IsEnabled))
+ {
+ RefreshAuthRequired();
+ Reevaluate();
+ }
+ }
+
+ private void OnLanguageChanged() => Reevaluate();
+
+ ///
+ /// Public hook for the view to request a precondition recompute after the
+ /// user toggles phases on/off (TestPanel does not raise a dedicated event
+ /// for enabled-phase changes).
+ ///
+ public void OnEnabledPhasesChanged()
+ {
+ RefreshAuthRequired();
+ Reevaluate();
+ }
+
+ // ── Snackbar timer ────────────────────────────────────────────────────────
+
+ private void StartSnackbarTimer()
+ {
+ StopSnackbarTimer();
+ _snackbarTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(SnackbarDurationMs)
+ };
+ _snackbarTimer.Tick += (_, _) =>
+ {
+ IsSnackbarVisible = false;
+ StopSnackbarTimer();
+ };
+ _snackbarTimer.Start();
+ }
+
+ private void StopSnackbarTimer()
+ {
+ if (_snackbarTimer == null) return;
+ _snackbarTimer.Stop();
+ _snackbarTimer = null;
}
}
}
diff --git a/ViewModels/PhaseCardViewModel.cs b/ViewModels/PhaseCardViewModel.cs
index facbe74..840fe47 100644
--- a/ViewModels/PhaseCardViewModel.cs
+++ b/ViewModels/PhaseCardViewModel.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
@@ -93,6 +94,12 @@ namespace HC_APTBS.ViewModels
EnabledChanged?.Invoke(this);
}
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Inverts . Bound to the card's click gesture.
+ [RelayCommand]
+ private void ToggleEnabled() => IsEnabled = !IsEnabled;
+
// ── Public API ────────────────────────────────────────────────────────────
/// Resets execution state for a new test run.
diff --git a/ViewModels/PreconditionItemViewModel.cs b/ViewModels/PreconditionItemViewModel.cs
deleted file mode 100644
index 1834c70..0000000
--- a/ViewModels/PreconditionItemViewModel.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using HC_APTBS.Services;
-
-namespace HC_APTBS.ViewModels
-{
- ///
- /// A single row in the Tests-page preconditions checklist.
- /// Labels and remediation hints are localised strings resolved by the parent
- /// and refreshed whenever
- /// fires. The item itself only
- /// carries the currently-resolved text plus a navigation hook so the view can offer
- /// a "fix-it" link when the check fails.
- ///
- public sealed partial class PreconditionItemViewModel : ObservableObject
- {
- /// Stable identifier used by the parent VM to look items up when refreshing.
- public string Id { get; }
-
- /// Localised human-readable check label (e.g. "Oil pump ON").
- [ObservableProperty] private string _label = string.Empty;
-
- /// True when the associated runtime check currently passes.
- [ObservableProperty] private bool _isSatisfied;
-
- /// When false, this check is advisory only and does not block Start.
- [ObservableProperty] private bool _isRequired = true;
-
- /// Localised fix-it hint shown when the check fails (e.g. "Go to Bench → start oil pump").
- [ObservableProperty] private string _remediationText = string.Empty;
-
- /// When non-null, the remediation button navigates to this page.
- public AppPage? RemediationTargetPage { get; }
-
- /// True when the remediation action should be offered (failing + has a target page).
- public bool HasRemediation => !IsSatisfied && RemediationTargetPage.HasValue;
-
- private readonly MainViewModel _root;
-
- /// Stable identifier (used by the parent VM to patch state).
- /// Root view-model used to drive page navigation.
- /// Destination page when the fix-it link is clicked, or null when no page applies.
- /// When false this item is advisory only.
- public PreconditionItemViewModel(
- string id,
- MainViewModel root,
- AppPage? remediationTargetPage = null,
- bool isRequired = true)
- {
- Id = id;
- _root = root;
- RemediationTargetPage = remediationTargetPage;
- _isRequired = isRequired;
- }
-
- /// Navigates the shell to the remediation target page.
- [RelayCommand]
- private void NavigateToFix()
- {
- if (RemediationTargetPage.HasValue)
- _root.SelectedPage = RemediationTargetPage.Value;
- }
-
- partial void OnIsSatisfiedChanged(bool value) => OnPropertyChanged(nameof(HasRemediation));
- }
-}
diff --git a/ViewModels/PumpControlViewModel.cs b/ViewModels/PumpControlViewModel.cs
index 5b6b47a..27f6e40 100644
--- a/ViewModels/PumpControlViewModel.cs
+++ b/ViewModels/PumpControlViewModel.cs
@@ -137,12 +137,16 @@ namespace HC_APTBS.ViewModels
/// Resets all slider values to zero and restores default min/max/step.
public void Reset()
{
+ // Min/Max/Step must be reset BEFORE Value: WPF's Slider coerces Value into
+ // [Minimum, Maximum]. If a previous test auto-expanded MeMin above 0 (or the
+ // operator widened it via the settings popup), assigning Value=0 first would
+ // be silently clamped to the stale Min, and the slider would stay stuck there.
_suppressSend = true;
try
{
- FbkwValue = 0; FbkwMin = 0; FbkwMax = 100; FbkwStep = 10;
- MeValue = 0; MeMin = 0; MeMax = 100; MeStep = 10;
- PreInValue = 0; PreInMin = 0; PreInMax = 100; PreInStep = 10;
+ FbkwMin = 0; FbkwMax = 100; FbkwStep = 10; FbkwValue = 0;
+ MeMin = 0; MeMax = 100; MeStep = 10; MeValue = 0;
+ PreInMin = 0; PreInMax = 100; PreInStep = 10; PreInValue = 0;
}
finally
{
diff --git a/ViewModels/PumpIdentificationViewModel.cs b/ViewModels/PumpIdentificationViewModel.cs
index 408e686..8e0f526 100644
--- a/ViewModels/PumpIdentificationViewModel.cs
+++ b/ViewModels/PumpIdentificationViewModel.cs
@@ -52,9 +52,12 @@ namespace HC_APTBS.ViewModels
});
// Start loading the pump as soon as the identifier is read from ROM,
- // before the full K-Line read completes.
- _kwp.PumpIdentified += (pumpId) => App.Current.Dispatcher.Invoke(() =>
+ // before the full K-Line read completes. Use BeginInvoke so the K-Line
+ // background worker thread returns immediately and the read keeps progressing
+ // even if the UI thread is momentarily blocked (e.g. modal voltage warning).
+ _kwp.PumpIdentified += (pumpId) => App.Current.Dispatcher.BeginInvoke(() =>
{
+ _log.Info(LogId, $"PumpIdentified handler: '{pumpId}'");
KlinePumpId = pumpId;
AutoSelectPumpByKlineId(pumpId);
});
@@ -83,6 +86,13 @@ namespace HC_APTBS.ViewModels
///
public event Action? PumpChanged;
+ ///
+ /// Raised at the end of a successful K-Line read with the matched pump ID and
+ /// the ECU serial number (may be empty if the ECU did not return one). Used by
+ /// the parent ViewModel to detect a physical pump swap when the model ID is unchanged.
+ ///
+ public event Action? KlineReadCompleted;
+
/// Populates the pump ID list from the configuration database.
public void LoadPumpIds()
{
@@ -99,6 +109,7 @@ namespace HC_APTBS.ViewModels
private void LoadPump(string pumpId)
{
+ _log.Info(LogId, $"LoadPump: {pumpId}");
var pump = _config.LoadPump(pumpId);
if (pump == null)
{
@@ -217,12 +228,18 @@ namespace HC_APTBS.ViewModels
KlineConnectError = connectErr ?? string.Empty;
});
- // Pump auto-selection now happens via the PumpIdentified event
- // mid-read, so there is no need to call AutoSelectPumpByKlineId here.
+ // Pump auto-selection by pumpID/alias/substring already happened mid-read
+ // via the PumpIdentified event. If still unmatched, try ModelReference now
+ // that the full ECU text has been read (ModelRef arrives later than pumpID).
+ if (CurrentPump == null && !string.IsNullOrEmpty(modelRef))
+ App.Current.Dispatcher.Invoke(() => TryAutoSelectByModelRef(modelRef!));
// Attach K-Line info to the (now possibly auto-selected) pump.
if (CurrentPump != null)
CurrentPump.KlineInfo = info;
+
+ // Notify parent VM so it can detect physical pump swaps when the model ID is unchanged.
+ KlineReadCompleted?.Invoke(CurrentPump?.Id ?? string.Empty, serial ?? string.Empty);
}
finally
{
@@ -252,29 +269,75 @@ namespace HC_APTBS.ViewModels
///
/// Tries to match a K-Line pump identifier to a pump in the database and auto-select it.
- /// If the K-Line ID is directly in the pump list, select it. Otherwise, try to find
- /// a pump whose ID is contained in the K-Line identifier string.
+ /// Resolution order:
+ ///
+ /// - Exact match — K-Line ID equals a canonical pump ID.
+ /// - Alias match — K-Line ID is listed under a pump's <Aliases><KlineId> entries.
+ /// - Substring match — pump ID appears inside the K-Line ident string (legacy fallback for noisy ROM reads).
+ ///
+ /// ModelReference-based equivalence is handled separately after the full K-Line read completes
+ /// (see ).
///
private void AutoSelectPumpByKlineId(string klinePumpId)
{
- // Direct match — the K-Line ID is itself a pump ID in the database.
- if (PumpIds.Contains(klinePumpId))
+ var trimmed = (klinePumpId ?? string.Empty).Trim();
+ if (trimmed.Length == 0)
{
- App.Current.Dispatcher.Invoke(() => SelectedPumpId = klinePumpId);
+ _log.Warning(LogId, "AutoSelectPumpByKlineId: empty K-Line identifier.");
return;
}
- // Substring match — the K-Line ident string may contain the pump ID.
+ // 1. Direct match — the K-Line ID is itself a pump ID in the database.
foreach (var id in PumpIds)
{
- if (klinePumpId.Contains(id, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(id, trimmed, StringComparison.OrdinalIgnoreCase))
{
- App.Current.Dispatcher.Invoke(() => SelectedPumpId = id);
+ _log.Info(LogId, $"Auto-selected '{id}' from K-Line id '{trimmed}' (exact match).");
+ SelectedPumpId = id;
return;
}
}
- _log.Warning(LogId, $"K-Line pump ID '{klinePumpId}' not found in pump database.");
+ // 2. Alias match — the K-Line ID is registered as an alias under a canonical pump.
+ var aliased = _config.FindPumpIdByKlineAlias(trimmed);
+ if (!string.IsNullOrEmpty(aliased))
+ {
+ _log.Info(LogId, $"Auto-selected '{aliased}' from K-Line id '{trimmed}' (alias KlineId).");
+ SelectedPumpId = aliased;
+ return;
+ }
+
+ // 3. Substring match — the K-Line ident string may contain the pump ID.
+ foreach (var id in PumpIds)
+ {
+ if (trimmed.Contains(id, StringComparison.OrdinalIgnoreCase))
+ {
+ _log.Info(LogId, $"Auto-selected '{id}' from K-Line id '{trimmed}' (substring match).");
+ SelectedPumpId = id;
+ return;
+ }
+ }
+
+ _log.Warning(LogId,
+ $"K-Line pump ID '{trimmed}' not found in pump database ({PumpIds.Count} candidates).");
+ }
+
+ ///
+ /// Late-stage fallback used when the full K-Line read has completed and no pump was
+ /// matched by ID/alias/substring. Looks up the canonical pump ID by ModelReference alias
+ /// (e.g. ME190297C150). No-op if a pump is already selected.
+ ///
+ private void TryAutoSelectByModelRef(string modelRef)
+ {
+ if (CurrentPump != null) return;
+ var trimmed = (modelRef ?? string.Empty).Trim();
+ if (trimmed.Length == 0) return;
+
+ var canonical = _config.FindPumpIdByModelRef(trimmed);
+ if (string.IsNullOrEmpty(canonical)) return;
+
+ _log.Info(LogId, $"Auto-selected '{canonical}' from ModelRef '{trimmed}' (alias ModelRef).");
+ SelectedPumpId = canonical;
}
}
}
diff --git a/ViewModels/SingleFlowChartViewModel.cs b/ViewModels/SingleFlowChartViewModel.cs
index e2b7701..dc04213 100644
--- a/ViewModels/SingleFlowChartViewModel.cs
+++ b/ViewModels/SingleFlowChartViewModel.cs
@@ -12,14 +12,42 @@ namespace HC_APTBS.ViewModels
///
/// Reusable ViewModel for a single real-time scrolling line chart.
/// Backed by LiveChartsCore with a fixed-width sample window.
+ ///
+ /// Two modes:
+ ///
+ /// - Index-axis (default): of doubles; X = array index;
+ /// axis is static. Samples shuffle through fixed X slots on each .
+ /// - Smooth-scroll (opt-in): of
+ /// with X on a monotonic sample-index timeline. A host-driven per-frame hook calls
+ /// to slide the X-axis window, producing true continuous
+ /// leftward motion independent of data cadence.
+ ///
///
public sealed partial class SingleFlowChartViewModel : ObservableObject
{
private const int DefaultMaxSamples = 200;
+ private const int SmoothTrimMargin = 4;
- private readonly ObservableCollection _values = new();
+ private readonly bool _smoothScroll;
private readonly int _maxSamples;
+ // Index-axis mode storage.
+ private readonly ObservableCollection? _values;
+
+ // Smooth-scroll mode storage + timeline state.
+ // The series holds N committed points at integer X positions plus one trailing
+ // "endpoint" whose X/Y interpolate each render frame from the previous committed
+ // point toward the pending sample. On each new sample the endpoint snaps to its
+ // target (becoming committed) and a new endpoint is appended at the same spot.
+ // Net effect: the line tip slides continuously instead of hopping +1 in X per sample.
+ private readonly ObservableCollection? _points;
+ private long _nextCommitX;
+ private double _prevCommittedValue;
+ private double _pendingValue;
+ private DateTime _pendingArrivalUtc;
+ private int _sampleCount;
+ private ObservablePoint? _endpoint;
+
/// Chart title label.
[ObservableProperty] private string _title = string.Empty;
@@ -47,32 +75,69 @@ namespace HC_APTBS.ViewModels
/// Display title for the chart.
/// SKColor for the line series.
/// Maximum number of samples before the oldest is dropped.
- public SingleFlowChartViewModel(string title, SKColor lineColor, int maxSamples = DefaultMaxSamples)
+ ///
+ /// When true, store samples on a monotonic X timeline and expect the host to call
+ /// each render frame to slide the visible window.
+ /// When false (default), use the legacy index-axis behavior.
+ ///
+ public SingleFlowChartViewModel(string title, SKColor lineColor, int maxSamples = DefaultMaxSamples, bool smoothScroll = false)
{
- _title = title;
- _maxSamples = maxSamples;
+ _title = title;
+ _maxSamples = maxSamples;
+ _smoothScroll = smoothScroll;
- Series = new ISeries[]
+ if (smoothScroll)
{
- new LineSeries
+ _points = new ObservableCollection();
+ Series = new ISeries[]
{
- Values = _values,
- Fill = null,
- GeometrySize = 0,
- Stroke = new SolidColorPaint(lineColor, 2),
- LineSmoothness = 0,
- AnimationsSpeed = TimeSpan.Zero
- }
- };
+ new LineSeries
+ {
+ Values = _points,
+ Fill = null,
+ GeometrySize = 0,
+ Stroke = new SolidColorPaint(lineColor, 2),
+ LineSmoothness = 0,
+ AnimationsSpeed = TimeSpan.Zero
+ }
+ };
- XAxes = new Axis[]
- {
- new Axis
+ XAxes = new Axis[]
{
- IsVisible = false,
- AnimationsSpeed = TimeSpan.Zero
- }
- };
+ new Axis
+ {
+ IsVisible = false,
+ AnimationsSpeed = TimeSpan.Zero,
+ MinLimit = 0,
+ MaxLimit = maxSamples
+ }
+ };
+ }
+ else
+ {
+ _values = new ObservableCollection();
+ Series = new ISeries[]
+ {
+ new LineSeries
+ {
+ Values = _values,
+ Fill = null,
+ GeometrySize = 0,
+ Stroke = new SolidColorPaint(lineColor, 2),
+ LineSmoothness = 0,
+ AnimationsSpeed = TimeSpan.Zero
+ }
+ };
+
+ XAxes = new Axis[]
+ {
+ new Axis
+ {
+ IsVisible = false,
+ AnimationsSpeed = TimeSpan.Zero
+ }
+ };
+ }
YAxes = new Axis[]
{
@@ -91,10 +156,94 @@ namespace HC_APTBS.ViewModels
///
public void AddValue(double value)
{
- _values.Add(value);
CurrentValue = value;
- if (_values.Count > _maxSamples)
- _values.RemoveAt(0);
+
+ if (_smoothScroll)
+ {
+ _sampleCount++;
+
+ if (_sampleCount == 1)
+ {
+ // First sample — commit at X=0 and anchor the viewport so the point sits
+ // at the right edge. No endpoint yet; we need two samples before we can
+ // interpolate between previous and pending.
+ _points!.Add(new ObservablePoint(0, value));
+ _nextCommitX = 1;
+ _prevCommittedValue = value;
+ _pendingValue = value;
+ _pendingArrivalUtc = DateTime.UtcNow;
+ XAxes[0].MaxLimit = 0;
+ XAxes[0].MinLimit = -_maxSamples;
+ }
+ else if (_sampleCount == 2)
+ {
+ // Second sample — create the sliding endpoint at the previous committed
+ // point. UpdateViewport will interpolate it toward the pending target.
+ _pendingValue = value;
+ _pendingArrivalUtc = DateTime.UtcNow;
+ _endpoint = new ObservablePoint(_nextCommitX - 1, _prevCommittedValue);
+ _points!.Add(_endpoint);
+ }
+ else
+ {
+ // Third+ sample — finalize the existing endpoint at its target (making
+ // it a committed point), advance the commit index, then append a fresh
+ // endpoint at the just-finalized position. Because the endpoint is
+ // already at X = _nextCommitX after the last render frame with
+ // fraction≈1, the viewport continues from the same position without
+ // a visible hop.
+ _endpoint!.X = _nextCommitX;
+ _endpoint.Y = _pendingValue;
+ _prevCommittedValue = _pendingValue;
+ _nextCommitX++;
+
+ _pendingValue = value;
+ _pendingArrivalUtc = DateTime.UtcNow;
+ _endpoint = new ObservablePoint(_nextCommitX - 1, _prevCommittedValue);
+ _points!.Add(_endpoint);
+
+ // Keep a small margin past the visible window so the leftmost point
+ // doesn't pop as the viewport advances past the trim boundary.
+ while (_points.Count > _maxSamples + SmoothTrimMargin)
+ _points.RemoveAt(0);
+ }
+ }
+ else
+ {
+ _values!.Add(value);
+ if (_values.Count > _maxSamples)
+ _values.RemoveAt(0);
+ }
+ }
+
+ ///
+ /// Smooth-scroll mode only: slides the X-axis viewport so the rightmost visible edge
+ /// drifts continuously past the most recent sample, producing frame-rate smooth motion
+ /// independent of data cadence. Host calls this once per render frame (e.g. from
+ /// CompositionTarget.Rendering).
+ ///
+ /// Current time; pass .
+ ///
+ /// Expected inter-sample period in milliseconds (e.g. 1000/Hz). Used to interpolate
+ /// fractional progress between samples.
+ ///
+ public void UpdateViewport(DateTime nowUtc, double nominalPeriodMs)
+ {
+ if (!_smoothScroll || _endpoint == null || nominalPeriodMs <= 0)
+ return;
+
+ double fraction = (nowUtc - _pendingArrivalUtc).TotalMilliseconds / nominalPeriodMs;
+ if (fraction < 0) fraction = 0;
+ else if (fraction > 1) fraction = 1;
+
+ double slideX = (_nextCommitX - 1) + fraction;
+ double slideY = _prevCommittedValue + (_pendingValue - _prevCommittedValue) * fraction;
+
+ _endpoint.X = slideX;
+ _endpoint.Y = slideY;
+
+ XAxes[0].MaxLimit = slideX;
+ XAxes[0].MinLimit = slideX - _maxSamples;
}
///
@@ -116,11 +265,25 @@ namespace HC_APTBS.ViewModels
}
///
- /// Clears all sample data and tolerance bands.
+ /// Clears all sample data and tolerance bands. Resets the smooth-scroll timeline.
///
public void Clear()
{
- _values.Clear();
+ if (_smoothScroll)
+ {
+ _points!.Clear();
+ _nextCommitX = 0;
+ _prevCommittedValue = 0;
+ _pendingValue = 0;
+ _sampleCount = 0;
+ _endpoint = null;
+ XAxes[0].MinLimit = 0;
+ XAxes[0].MaxLimit = _maxSamples;
+ }
+ else
+ {
+ _values!.Clear();
+ }
Sections = Array.Empty();
}
}
diff --git a/ViewModels/TestPanelViewModel.cs b/ViewModels/TestPanelViewModel.cs
index 506099c..061a3f6 100644
--- a/ViewModels/TestPanelViewModel.cs
+++ b/ViewModels/TestPanelViewModel.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -157,7 +159,10 @@ namespace HC_APTBS.ViewModels
}
///
- /// Marks a phase as completed with the given pass/fail result.
+ /// Marks a phase as completed with the given pass/fail result. Also locks the
+ /// active phase's indicators to the pass/fail colour before the active-phase
+ /// reference is cleared, so the bar does not flash back to accent from a final
+ /// in-range oscillation sample.
///
/// Name of the completed phase.
/// True if the phase passed all criteria.
@@ -186,12 +191,16 @@ namespace HC_APTBS.ViewModels
}
if (_activePhaseCard?.Name == phaseName)
+ {
+ MarkActivePhaseCompleted(passed);
_activePhaseCard = null;
+ }
}
///
/// Updates the live measurement value on the graphic indicator for the currently
- /// active phase that has a matching receive parameter.
+ /// active phase that has a matching receive parameter. Called every refresh tick
+ /// so the bar moves continuously through conditioning and measurement.
///
/// CAN parameter name (e.g. "QDelivery").
/// Current measured value.
@@ -209,6 +218,45 @@ namespace HC_APTBS.ViewModels
}
}
+ ///
+ /// Applies a runtime tolerance/expected-value update (e.g. after DFI auto-adjust)
+ /// to the matching indicator on the active phase. Does not touch
+ /// — live values flow
+ /// through .
+ ///
+ public void ApplyToleranceUpdate(string paramName, double expected, double tolerance)
+ {
+ if (_activePhaseCard == null) return;
+
+ foreach (var indicator in _activePhaseCard.ResultIndicators)
+ {
+ if (indicator.ParameterName == paramName)
+ {
+ indicator.ApplyTolerance(expected, tolerance);
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Live-indicator list for the currently executing phase. Empty when no phase
+ /// is active. Consumers should iterate this once per refresh tick to push
+ /// current readings (see ).
+ ///
+ public IReadOnlyList ActivePhaseIndicators
+ => _activePhaseCard?.ResultIndicators as IReadOnlyList
+ ?? Array.Empty();
+
+ private void MarkActivePhaseCompleted(bool passed)
+ {
+ if (_activePhaseCard == null) return;
+ foreach (var indicator in _activePhaseCard.ResultIndicators)
+ {
+ indicator.PhasePassed = passed;
+ indicator.IsPhaseCompleted = true;
+ }
+ }
+
///
/// Applies a phase-timer tick from .
/// Updates the sub-section label, remaining/total seconds and computed progress.
diff --git a/ViewModels/TestPreconditionsViewModel.cs b/ViewModels/TestPreconditionsViewModel.cs
deleted file mode 100644
index ebb57b7..0000000
--- a/ViewModels/TestPreconditionsViewModel.cs
+++ /dev/null
@@ -1,280 +0,0 @@
-using System;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Linq;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using HC_APTBS.Models;
-using HC_APTBS.Services;
-
-namespace HC_APTBS.ViewModels
-{
- ///
- /// Preconditions checklist for the Tests page "Preconditions" wizard step.
- ///
- /// Aggregates the seven safety/readiness checks specified in
- /// docs/ui-structure.md §4b. Items auto-refresh whenever the underlying
- /// properties change;
- /// stays disabled until every required check passes.
- ///
- /// Subscriptions are established in and released in
- /// ; the parent view-model calls these as the wizard state
- /// transitions into/out of Preconditions so we do not do work during Plan/Running/Done.
- ///
- public sealed partial class TestPreconditionsViewModel : ObservableObject
- {
- // ── Stable item identifiers ───────────────────────────────────────────────
-
- private const string IdPump = "pump";
- private const string IdCan = "can";
- private const string IdKLine = "kline";
- private const string IdRpmZero = "rpmZero";
- private const string IdOilPump = "oilPump";
- private const string IdNoAlarms = "noAlarms";
- private const string IdAuth = "auth";
-
- // ── Resource keys ─────────────────────────────────────────────────────────
-
- private const string KeyLabelPump = "Test.Precheck.PumpSelected";
- private const string KeyLabelCan = "Test.Precheck.CanLive";
- private const string KeyLabelKLine = "Test.Precheck.KLineOpen";
- private const string KeyLabelRpmZero = "Test.Precheck.RpmZero";
- private const string KeyLabelOilPump = "Test.Precheck.OilPumpOn";
- private const string KeyLabelNoAlarms = "Test.Precheck.NoCriticalAlarms";
- private const string KeyLabelAuth = "Test.Precheck.UserAuth";
-
- private const string KeyRemPump = "Test.Precheck.Remediation.SelectPump";
- private const string KeyRemCan = "Test.Precheck.Remediation.CheckCan";
- private const string KeyRemKLine = "Test.Precheck.Remediation.OpenKLine";
- private const string KeyRemRpmZero = "Test.Precheck.Remediation.StopBench";
- private const string KeyRemOilPump = "Test.Precheck.Remediation.StartOilPump";
- private const string KeyRemNoAlarms = "Test.Precheck.Remediation.ClearAlarms";
- private const string KeyRemAuth = "Test.Precheck.Remediation.Authenticate";
-
- private readonly MainViewModel _root;
- private readonly ILocalizationService _loc;
- private readonly TestPanelViewModel _testPanel;
-
- private bool _subscribed;
-
- /// Rows rendered by the checklist view, in display order.
- public ObservableCollection Items { get; } = new();
-
- /// Gate used to authenticate the operator when a required test has .
- public AuthGateViewModel TestAuth { get; }
-
- /// True when the currently-enabled tests include at least one requiring authentication.
- [ObservableProperty] private bool _isAuthRequired;
-
- /// True when every required check passes — gates .
- [ObservableProperty]
- [NotifyCanExecuteChangedFor(nameof(StartTestCommand))]
- private bool _allPassed;
-
- /// Root VM — source of all live bench/ECU state.
- /// Localisation service for label refresh.
- /// Panel VM — used to discover which tests are enabled and whether any require auth.
- /// Auth gate scoped to the Tests page.
- public TestPreconditionsViewModel(
- MainViewModel root,
- ILocalizationService loc,
- TestPanelViewModel testPanel,
- AuthGateViewModel testAuth)
- {
- _root = root;
- _loc = loc;
- _testPanel = testPanel;
- TestAuth = testAuth;
-
- BuildItems();
- }
-
- // ── Lifecycle ─────────────────────────────────────────────────────────────
-
- ///
- /// Called by the parent when the wizard enters the Preconditions state.
- /// Subscribes to all live-state sources and evaluates once.
- ///
- public void Activate()
- {
- if (_subscribed) return;
-
- _root.PropertyChanged += OnRootPropertyChanged;
- _root.DashboardAlarms.PropertyChanged += OnAlarmsPropertyChanged;
- TestAuth.PropertyChanged += OnAuthPropertyChanged;
- _loc.LanguageChanged += OnLanguageChanged;
-
- _subscribed = true;
-
- RefreshAuthRequired();
- RebuildAuthItemVisibility();
- Reevaluate();
- }
-
- /// Called by the parent when the wizard leaves the Preconditions state.
- public void Deactivate()
- {
- if (!_subscribed) return;
-
- _root.PropertyChanged -= OnRootPropertyChanged;
- _root.DashboardAlarms.PropertyChanged -= OnAlarmsPropertyChanged;
- TestAuth.PropertyChanged -= OnAuthPropertyChanged;
- _loc.LanguageChanged -= OnLanguageChanged;
-
- _subscribed = false;
- }
-
- // ── Build ─────────────────────────────────────────────────────────────────
-
- private void BuildItems()
- {
- Items.Clear();
- Items.Add(new PreconditionItemViewModel(IdPump, _root, AppPage.Pump));
- Items.Add(new PreconditionItemViewModel(IdCan, _root, AppPage.Dashboard));
- Items.Add(new PreconditionItemViewModel(IdKLine, _root, AppPage.Pump));
- Items.Add(new PreconditionItemViewModel(IdRpmZero, _root, AppPage.Bench));
- Items.Add(new PreconditionItemViewModel(IdOilPump, _root, AppPage.Bench));
- Items.Add(new PreconditionItemViewModel(IdNoAlarms, _root, AppPage.Dashboard));
- // Auth item added on-demand (see RebuildAuthItemVisibility).
-
- RefreshLabels();
- }
-
- private void RebuildAuthItemVisibility()
- {
- var authItem = Items.FirstOrDefault(i => i.Id == IdAuth);
- if (IsAuthRequired && authItem == null)
- {
- Items.Add(new PreconditionItemViewModel(IdAuth, _root, remediationTargetPage: null));
- RefreshLabels();
- }
- else if (!IsAuthRequired && authItem != null)
- {
- Items.Remove(authItem);
- }
- }
-
- // ── Evaluation ────────────────────────────────────────────────────────────
-
- /// Recomputes every item's satisfied state and .
- public void Reevaluate()
- {
- foreach (var item in Items)
- item.IsSatisfied = EvaluateItem(item.Id);
-
- AllPassed = Items.All(i => !i.IsRequired || i.IsSatisfied);
- }
-
- private bool EvaluateItem(string id) => id switch
- {
- IdPump => _root.CurrentPump != null,
- IdCan => _root.IsCanConnected,
- IdKLine => _root.KLineState == KLineConnectionState.Connected,
- IdRpmZero => _root.BenchRpm == 0,
- IdOilPump => _root.IsOilPumpOn,
- IdNoAlarms => !_root.DashboardAlarms.HasCritical,
- IdAuth => TestAuth.IsAuthenticated,
- _ => true,
- };
-
- private void RefreshAuthRequired()
- {
- IsAuthRequired = _testPanel.Tests
- .Any(s => (s.Source?.RequiresAuth ?? false) && s.Phases.Any(p => p.IsEnabled));
- }
-
- // ── Labels ────────────────────────────────────────────────────────────────
-
- private void RefreshLabels()
- {
- foreach (var item in Items)
- {
- item.Label = _loc.GetString(LabelKeyFor(item.Id));
- item.RemediationText = _loc.GetString(RemediationKeyFor(item.Id));
- }
- }
-
- private static string LabelKeyFor(string id) => id switch
- {
- IdPump => KeyLabelPump,
- IdCan => KeyLabelCan,
- IdKLine => KeyLabelKLine,
- IdRpmZero => KeyLabelRpmZero,
- IdOilPump => KeyLabelOilPump,
- IdNoAlarms => KeyLabelNoAlarms,
- IdAuth => KeyLabelAuth,
- _ => id,
- };
-
- private static string RemediationKeyFor(string id) => id switch
- {
- IdPump => KeyRemPump,
- IdCan => KeyRemCan,
- IdKLine => KeyRemKLine,
- IdRpmZero => KeyRemRpmZero,
- IdOilPump => KeyRemOilPump,
- IdNoAlarms => KeyRemNoAlarms,
- IdAuth => KeyRemAuth,
- _ => string.Empty,
- };
-
- // ── Event handlers ────────────────────────────────────────────────────────
-
- private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
- {
- switch (e.PropertyName)
- {
- case nameof(MainViewModel.CurrentPump):
- case nameof(MainViewModel.IsCanConnected):
- case nameof(MainViewModel.KLineState):
- case nameof(MainViewModel.BenchRpm):
- case nameof(MainViewModel.IsOilPumpOn):
- Reevaluate();
- break;
- }
- }
-
- private void OnAlarmsPropertyChanged(object? sender, PropertyChangedEventArgs e)
- {
- if (e.PropertyName == nameof(DashboardAlarmsViewModel.HasCritical))
- Reevaluate();
- }
-
- private void OnAuthPropertyChanged(object? sender, PropertyChangedEventArgs e)
- {
- if (e.PropertyName == nameof(AuthGateViewModel.IsAuthenticated))
- Reevaluate();
- }
-
- private void OnLanguageChanged() => RefreshLabels();
-
- ///
- /// Called by the parent VM whenever the test-panel enabled-phase selection changes,
- /// so the auth item can be shown/hidden based on enabled tests' .
- ///
- public void OnEnabledPhasesChanged()
- {
- RefreshAuthRequired();
- RebuildAuthItemVisibility();
- Reevaluate();
- }
-
- partial void OnIsAuthRequiredChanged(bool value)
- {
- RebuildAuthItemVisibility();
- Reevaluate();
- }
-
- // ── Commands ──────────────────────────────────────────────────────────────
-
- /// Delegates to when is true.
- [RelayCommand(CanExecute = nameof(CanStart))]
- private void StartTest()
- {
- if (_root.StartTestCommand.CanExecute(null))
- _root.StartTestCommand.Execute(null);
- }
-
- private bool CanStart() => AllPassed;
- }
-}
diff --git a/ViewModels/TestSectionViewModel.cs b/ViewModels/TestSectionViewModel.cs
index 27cca73..24b2903 100644
--- a/ViewModels/TestSectionViewModel.cs
+++ b/ViewModels/TestSectionViewModel.cs
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
@@ -29,6 +30,9 @@ namespace HC_APTBS.ViewModels
/// Human-readable description of the test type.
[ObservableProperty] private string _description = string.Empty;
+ /// WPF-UI SymbolIcon name to show in the card header (Fluent Tests page).
+ [ObservableProperty] private string _iconSymbol = "Beaker24";
+
/// Conditioning time in seconds.
[ObservableProperty] private int _conditioningTimeSec;
@@ -81,6 +85,12 @@ namespace HC_APTBS.ViewModels
}
}
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Inverts . Bound to the section card's click gesture.
+ [RelayCommand]
+ private void ToggleAllPhases() => AllPhasesChecked = !AllPhasesChecked;
+
// ── Cascade: child → parent ──────────────────────────────────────────────
///
@@ -118,6 +128,7 @@ namespace HC_APTBS.ViewModels
{
TestName = test.Name,
Description = loc.GetString(MapDescriptionKey(test.Name)),
+ IconSymbol = MapIconSymbol(test.Name),
ConditioningTimeSec = test.ConditioningTimeSec,
MeasurementTimeSec = test.MeasurementTimeSec,
MeasurementsPerSecond = test.MeasurementsPerSecond,
@@ -177,5 +188,20 @@ namespace HC_APTBS.ViewModels
TestType.Pfp => "TestType.PreInjection",
_ => testName
};
+
+ ///
+ /// Maps a test type identifier to a WPF-UI SymbolIcon name used in the
+ /// Fluent Tests page card header.
+ ///
+ private static string MapIconSymbol(string testName) => testName switch
+ {
+ TestType.Wl => "Temperature24",
+ TestType.Dfi => "WrenchScrewdriver24",
+ TestType.F => "Drop24",
+ TestType.Svme => "Timeline24",
+ TestType.Up => "ArrowTrendingLines24",
+ TestType.Pfp => "Gauge24",
+ _ => "Beaker24"
+ };
}
}
diff --git a/Views/Dialogs/ConfirmDialog.xaml b/Views/Dialogs/ConfirmDialog.xaml
index b995a00..1d55384 100644
--- a/Views/Dialogs/ConfirmDialog.xaml
+++ b/Views/Dialogs/ConfirmDialog.xaml
@@ -42,9 +42,9 @@
-
-
diff --git a/Views/Dialogs/OilPumpConfirmDialog.xaml b/Views/Dialogs/OilPumpConfirmDialog.xaml
index c33f8df..be7d701 100644
--- a/Views/Dialogs/OilPumpConfirmDialog.xaml
+++ b/Views/Dialogs/OilPumpConfirmDialog.xaml
@@ -5,7 +5,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
Title="{DynamicResource Dialog.OilPump.Title}"
- Height="220" Width="440"
+ Height="260" Width="440"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner">
@@ -48,9 +48,9 @@
-
-
diff --git a/Views/Dialogs/RpmSafetyWarningDialog.xaml b/Views/Dialogs/RpmSafetyWarningDialog.xaml
index 41a2dc3..c0dce2e 100644
--- a/Views/Dialogs/RpmSafetyWarningDialog.xaml
+++ b/Views/Dialogs/RpmSafetyWarningDialog.xaml
@@ -5,7 +5,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
Title="{DynamicResource Dialog.RpmSafety.Title}"
- Height="260" Width="460"
+ Height="280" Width="460"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner">
@@ -54,9 +54,9 @@
-
-
diff --git a/Views/Pages/BenchPage.xaml b/Views/Pages/BenchPage.xaml
index d99feee..97f05e1 100644
--- a/Views/Pages/BenchPage.xaml
+++ b/Views/Pages/BenchPage.xaml
@@ -22,8 +22,8 @@
-
+
diff --git a/Views/Pages/DashboardPage.xaml b/Views/Pages/DashboardPage.xaml
index b540692..440e436 100644
--- a/Views/Pages/DashboardPage.xaml
+++ b/Views/Pages/DashboardPage.xaml
@@ -25,7 +25,7 @@
-
+
@@ -36,13 +36,13 @@
-
+
-
+
-
+
@@ -315,18 +315,38 @@
-
+
-
-
-
+ ToolTipService.ShowOnDisabled="True">
+
+
+
diff --git a/Views/Pages/DeveloperPage.xaml b/Views/Pages/DeveloperPage.xaml
new file mode 100644
index 0000000..6a7eb41
--- /dev/null
+++ b/Views/Pages/DeveloperPage.xaml
@@ -0,0 +1,420 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Pages/DeveloperPage.xaml.cs b/Views/Pages/DeveloperPage.xaml.cs
new file mode 100644
index 0000000..6b029b2
--- /dev/null
+++ b/Views/Pages/DeveloperPage.xaml.cs
@@ -0,0 +1,39 @@
+using System.Collections.Specialized;
+using System.Windows.Controls;
+using HC_APTBS.ViewModels.Pages;
+
+namespace HC_APTBS.Views.Pages
+{
+ ///
+ /// Code-behind for the Developer Tools page. Auto-scrolls the log to the
+ /// bottom when entries are appended so the latest TX/RX is always visible.
+ /// Compiled into Debug builds only — see HC_APTBS.csproj.
+ ///
+ public partial class DeveloperPage : UserControl
+ {
+ private DeveloperPageViewModel? _vm;
+
+ public DeveloperPage()
+ {
+ InitializeComponent();
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void OnDataContextChanged(object sender, System.Windows.DependencyPropertyChangedEventArgs e)
+ {
+ if (_vm is { } old)
+ ((INotifyCollectionChanged)old.Log).CollectionChanged -= OnLogChanged;
+
+ _vm = DataContext as DeveloperPageViewModel;
+
+ if (_vm is not null)
+ ((INotifyCollectionChanged)_vm.Log).CollectionChanged += OnLogChanged;
+ }
+
+ private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action == NotifyCollectionChangedAction.Add)
+ Dispatcher.BeginInvoke(() => LogScroller.ScrollToBottom());
+ }
+ }
+}
diff --git a/Views/Pages/PumpPage.xaml b/Views/Pages/PumpPage.xaml
index 32f3aaa..02f87a4 100644
--- a/Views/Pages/PumpPage.xaml
+++ b/Views/Pages/PumpPage.xaml
@@ -26,7 +26,7 @@
-
+
@@ -53,7 +53,7 @@
-
+
diff --git a/Views/Pages/SettingsPage.xaml b/Views/Pages/SettingsPage.xaml
index e96c794..1f1559e 100644
--- a/Views/Pages/SettingsPage.xaml
+++ b/Views/Pages/SettingsPage.xaml
@@ -62,6 +62,7 @@
+
+
+
@@ -298,6 +303,7 @@
+
+
+
+
diff --git a/Views/Pages/TestsPage.xaml b/Views/Pages/TestsPage.xaml
index 30b63f3..70e2fc2 100644
--- a/Views/Pages/TestsPage.xaml
+++ b/Views/Pages/TestsPage.xaml
@@ -3,201 +3,364 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:models="clr-namespace:HC_APTBS.Models"
+ xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:uc="clr-namespace:HC_APTBS.Views.UserControls"
xmlns:vm="clr-namespace:HC_APTBS.ViewModels"
xmlns:vmp="clr-namespace:HC_APTBS.ViewModels.Pages"
mc:Ignorable="d"
- d:DesignHeight="800" d:DesignWidth="1000"
+ d:DesignHeight="860" d:DesignWidth="1740"
d:DataContext="{d:DesignInstance Type=vmp:TestsPageViewModel, IsDesignTimeCreatable=False}">
-
-
-
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/AdvanceMonitorCard.xaml b/Views/UserControls/AdvanceMonitorCard.xaml
index ff574c2..3ad7a58 100644
--- a/Views/UserControls/AdvanceMonitorCard.xaml
+++ b/Views/UserControls/AdvanceMonitorCard.xaml
@@ -26,11 +26,11 @@
-
-
diff --git a/Views/UserControls/AutoTestSnackbarView.xaml b/Views/UserControls/AutoTestSnackbarView.xaml
new file mode 100644
index 0000000..c6564ff
--- /dev/null
+++ b/Views/UserControls/AutoTestSnackbarView.xaml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/AutoTestSnackbarView.xaml.cs b/Views/UserControls/AutoTestSnackbarView.xaml.cs
new file mode 100644
index 0000000..8b3c430
--- /dev/null
+++ b/Views/UserControls/AutoTestSnackbarView.xaml.cs
@@ -0,0 +1,59 @@
+using System;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+using HC_APTBS.ViewModels.Dialogs;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Shell-level snackbar that mirrors but binds to
+ /// . Auto-dismisses three seconds after a
+ /// successful run; a failed or cancelled run stays until the operator clicks Dismiss.
+ ///
+ public partial class AutoTestSnackbarView : UserControl
+ {
+ private DispatcherTimer? _autoHideTimer;
+
+ public AutoTestSnackbarView()
+ {
+ InitializeComponent();
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (e.OldValue is AutoTestProgressViewModel oldVm)
+ oldVm.PropertyChanged -= OnVmPropertyChanged;
+
+ if (e.NewValue is AutoTestProgressViewModel newVm)
+ newVm.PropertyChanged += OnVmPropertyChanged;
+ }
+
+ private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(AutoTestProgressViewModel.IsSuccess)) return;
+ if (DataContext is not AutoTestProgressViewModel vm) return;
+
+ if (vm.IsSuccess == true)
+ {
+ _autoHideTimer?.Stop();
+ _autoHideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
+ _autoHideTimer.Tick += (_, _) =>
+ {
+ _autoHideTimer!.Stop();
+ if (vm.CloseCommand.CanExecute(null))
+ vm.CloseCommand.Execute(null);
+ };
+ _autoHideTimer.Start();
+ }
+ }
+
+ private void OnDismissClick(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is AutoTestProgressViewModel vm && vm.CloseCommand.CanExecute(null))
+ vm.CloseCommand.Execute(null);
+ }
+ }
+}
diff --git a/Views/UserControls/BenchRpmCommandCard.xaml b/Views/UserControls/BenchRpmCommandCard.xaml
index 45833ae..52227f2 100644
--- a/Views/UserControls/BenchRpmCommandCard.xaml
+++ b/Views/UserControls/BenchRpmCommandCard.xaml
@@ -92,31 +92,31 @@
-
+
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
+ Appearance="Secondary" Height="40" HorizontalAlignment="Stretch" Margin="2" FontSize="12"/>
@@ -129,13 +129,13 @@
+ Appearance="Primary" Height="46" HorizontalAlignment="Stretch" FontWeight="Bold" FontSize="14">
+ Appearance="Danger" Height="46" HorizontalAlignment="Stretch" FontWeight="Bold" FontSize="14">
diff --git a/Views/UserControls/BipDisplayView.xaml b/Views/UserControls/BipDisplayView.xaml
new file mode 100644
index 0000000..b3e3e48
--- /dev/null
+++ b/Views/UserControls/BipDisplayView.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/BipDisplayView.xaml.cs b/Views/UserControls/BipDisplayView.xaml.cs
new file mode 100644
index 0000000..3836c8a
--- /dev/null
+++ b/Views/UserControls/BipDisplayView.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ public partial class BipDisplayView : UserControl
+ {
+ public BipDisplayView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/DashboardDevicesView.xaml b/Views/UserControls/DashboardDevicesView.xaml
index 69facfc..2f776d8 100644
--- a/Views/UserControls/DashboardDevicesView.xaml
+++ b/Views/UserControls/DashboardDevicesView.xaml
@@ -11,6 +11,9 @@
Devices column — three equal-height tiles (CAN / K-Line / Bench).
DataContext is DashboardPageViewModel; all commands/collections are under Devices.
-->
+
+
+
@@ -74,16 +77,29 @@
Command="{Binding DataContext.Devices.ToggleDeviceCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
- IsEnabled="{Binding IsEnabled}">
+ IsEnabled="{Binding IsEnabled}"
+ ToolTip="{Binding StateLabel}">
+ VerticalAlignment="Center" Margin="6,0,0,0">
+
+
+
+
@@ -268,5 +289,7 @@
+
+
diff --git a/Views/UserControls/DfiCalibrationCard.xaml b/Views/UserControls/DfiCalibrationCard.xaml
index d1b45ec..4b7825e 100644
--- a/Views/UserControls/DfiCalibrationCard.xaml
+++ b/Views/UserControls/DfiCalibrationCard.xaml
@@ -37,13 +37,13 @@
VerticalAlignment="Center" Margin="0,0,8,0"/>
+ Height="36">
-
-
+
-
-
+ Height="32" Margin="0,0,6,0"/>
+ Height="32"/>
diff --git a/Views/UserControls/GraphicIndicatorView.xaml b/Views/UserControls/GraphicIndicatorView.xaml
index 6dbd09e..423e222 100644
--- a/Views/UserControls/GraphicIndicatorView.xaml
+++ b/Views/UserControls/GraphicIndicatorView.xaml
@@ -3,71 +3,107 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:sys="clr-namespace:System;assembly=System.Runtime"
+ xmlns:conv="clr-namespace:HC_APTBS.Converters"
xmlns:vm="clr-namespace:HC_APTBS.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:GraphicIndicatorViewModel, IsDesignTimeCreatable=False}">
+
+
+ 92
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+ FontSize="10"
+ Foreground="{DynamicResource TextFillColorSecondaryBrush}"
+ HorizontalAlignment="Center" Margin="0,0,0,2"/>
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+ Opacity="0.55"
+ Margin="1"
+ Style="{StaticResource IndicatorFillStyle}"/>
+
+ FontSize="14" FontWeight="SemiBold"
+ HorizontalAlignment="Center" VerticalAlignment="Center">
@@ -75,16 +111,21 @@
+
+ FontSize="10"
+ Foreground="{DynamicResource TextFillColorSecondaryBrush}"
+ HorizontalAlignment="Center" Margin="0,2,0,0"/>
+
+ ToolTip="{Binding ParameterName}"
+ Margin="0,1,0,0"/>
diff --git a/Views/UserControls/InterlockBannerView.xaml b/Views/UserControls/InterlockBannerView.xaml
index fae33c8..29d2149 100644
--- a/Views/UserControls/InterlockBannerView.xaml
+++ b/Views/UserControls/InterlockBannerView.xaml
@@ -14,7 +14,8 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/PhaseTileView.xaml.cs b/Views/UserControls/PhaseTileView.xaml.cs
new file mode 100644
index 0000000..c0d8301
--- /dev/null
+++ b/Views/UserControls/PhaseTileView.xaml.cs
@@ -0,0 +1,31 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Compact Fluent phase tile. Set =true in the
+ /// Plan step so the receives render as chips instead of tall vertical
+ /// progress bars — keeps every section card under one screen.
+ /// DataContext is expected to be a .
+ ///
+ public partial class PhaseTileView : UserControl
+ {
+ /// When true, receives render as small text chips instead of vertical indicators.
+ public static readonly DependencyProperty CompactProperty =
+ DependencyProperty.Register(nameof(Compact), typeof(bool), typeof(PhaseTileView),
+ new PropertyMetadata(false));
+
+ /// Compact mode flag (see ).
+ public bool Compact
+ {
+ get => (bool)GetValue(CompactProperty);
+ set => SetValue(CompactProperty, value);
+ }
+
+ public PhaseTileView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/PumpCommandsCard.xaml b/Views/UserControls/PumpCommandsCard.xaml
index da180bc..d3f1333 100644
--- a/Views/UserControls/PumpCommandsCard.xaml
+++ b/Views/UserControls/PumpCommandsCard.xaml
@@ -5,7 +5,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
mc:Ignorable="d"
- d:DesignHeight="460" d:DesignWidth="260"
+ d:DesignHeight="490" d:DesignWidth="260"
IsEnabled="{Binding IsEnabled}">
@@ -24,6 +24,7 @@
+
@@ -63,16 +64,16 @@
Margin="0,4,0,2"/>
-
+ Loaded="Slider_Loaded"/>
-
+ Loaded="Slider_Loaded"/>
-
+ Loaded="Slider_Loaded"/>
+ /// Pump command sliders. Code-behind only carries the click-anywhere-and-drag
+ /// gesture for the custom FluentThickVerticalSlider template — clicking
+ /// outside the thumb captures the mouse on the Slider and tracks the value
+ /// against the Track's geometry until release.
+ ///
public partial class PumpCommandsCard : UserControl
{
public PumpCommandsCard() => InitializeComponent();
+
+ ///
+ /// Wires the click-anywhere-and-drag handlers using
+ /// handledEventsToo: true so they fire even if a class-level
+ /// handler (e.g. Slider.OnPreviewMouseLeftButtonDown when
+ /// IsMoveToPointEnabled is on) marks the event handled.
+ ///
+ private void Slider_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Slider slider) return;
+ slider.AddHandler(PreviewMouseLeftButtonDownEvent,
+ new MouseButtonEventHandler(Slider_PreviewMouseLeftButtonDown), true);
+ slider.AddHandler(PreviewMouseLeftButtonUpEvent,
+ new MouseButtonEventHandler(Slider_PreviewMouseLeftButtonUp), true);
+ slider.AddHandler(MouseMoveEvent,
+ new MouseEventHandler(Slider_MouseMove), true);
+ slider.AddHandler(PreviewMouseWheelEvent,
+ new MouseWheelEventHandler(Slider_PreviewMouseWheel), true);
+ }
+
+ ///
+ /// Adjusts the Slider's value by one step per wheel notch while the cursor
+ /// is over the slider. The step is taken from
+ /// when available, falling back to , then
+ /// 1% of the slider's range. The event is marked handled so the wheel doesn't
+ /// also scroll a parent ScrollViewer.
+ ///
+ private void Slider_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
+ {
+ if (sender is not Slider slider) return;
+
+ double step = slider.TickFrequency;
+ if (step <= 0) step = slider.SmallChange;
+ if (step <= 0) step = (slider.Maximum - slider.Minimum) * 0.01;
+ if (step <= 0) return;
+
+ double notches = e.Delta / 120.0;
+ double newValue = slider.Value + notches * step;
+ if (newValue < slider.Minimum) newValue = slider.Minimum;
+ else if (newValue > slider.Maximum) newValue = slider.Maximum;
+ slider.Value = newValue;
+
+ e.Handled = true;
+ }
+
+ ///
+ /// On press outside the Thumb, captures the mouse on the Slider so the
+ /// user can drag from any point on the track in one motion.
+ ///
+ private void Slider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is not Slider slider) return;
+ if (slider.Template?.FindName("PART_Track", slider) is not Track track) return;
+
+ // Direct thumb press — let the Thumb's own drag handle it.
+ if (IsClickInsideThumb(e.OriginalSource as DependencyObject, track.Thumb)) return;
+
+ UpdateValueFromPoint(slider, e.GetPosition(slider));
+ slider.CaptureMouse();
+ e.Handled = true;
+ }
+
+ ///
+ /// While the Slider has mouse capture, continuously map the cursor
+ /// position back to a Slider value.
+ ///
+ private void Slider_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (sender is not Slider slider || !slider.IsMouseCaptured) return;
+ UpdateValueFromPoint(slider, e.GetPosition(slider));
+ }
+
+ ///
+ /// Releases the Slider's mouse capture on button-up.
+ ///
+ private void Slider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is Slider slider && slider.IsMouseCaptured)
+ {
+ slider.ReleaseMouseCapture();
+ e.Handled = true;
+ }
+ }
+
+ ///
+ /// Maps a cursor position (in Slider coordinates) to a Slider value using
+ /// the Slider's own bounds. Bypasses Track.ValueFromPoint, which
+ /// can return scaled values for custom templates whose RepeatButton sizes
+ /// don't match the Track's expected layout.
+ ///
+ private static void UpdateValueFromPoint(Slider slider, Point pointInSlider)
+ {
+ bool vertical = slider.Orientation == Orientation.Vertical;
+ double length = vertical ? slider.ActualHeight : slider.ActualWidth;
+ if (length <= 0) return;
+
+ double pos = vertical ? pointInSlider.Y : pointInSlider.X;
+ double fraction = pos / length;
+ if (fraction < 0) fraction = 0;
+ else if (fraction > 1) fraction = 1;
+
+ // Vertical (default): top = Maximum. Horizontal (default): left = Minimum.
+ // IsDirectionReversed flips that on each axis.
+ bool flip = vertical ? !slider.IsDirectionReversed : slider.IsDirectionReversed;
+ if (flip) fraction = 1.0 - fraction;
+
+ double value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
+ if (value < slider.Minimum) value = slider.Minimum;
+ else if (value > slider.Maximum) value = slider.Maximum;
+ slider.Value = value;
+ }
+
+ private static bool IsClickInsideThumb(DependencyObject? src, Thumb? thumb)
+ {
+ if (thumb is null || src is null) return false;
+ for (var node = src; node is not null; node = VisualTreeHelper.GetParent(node))
+ {
+ if (node == thumb) return true;
+ }
+ return false;
+ }
}
}
diff --git a/Views/UserControls/PumpIdentificationCard.xaml b/Views/UserControls/PumpIdentificationCard.xaml
index 990c596..8127d64 100644
--- a/Views/UserControls/PumpIdentificationCard.xaml
+++ b/Views/UserControls/PumpIdentificationCard.xaml
@@ -5,7 +5,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
mc:Ignorable="d"
- d:DesignHeight="360" d:DesignWidth="320">
+ d:DesignHeight="430" d:DesignWidth="490">
@@ -13,15 +13,15 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/UserControls/TestDoneView.xaml.cs b/Views/UserControls/TestDoneView.xaml.cs
deleted file mode 100644
index 8c795f0..0000000
--- a/Views/UserControls/TestDoneView.xaml.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Windows.Controls;
-
-namespace HC_APTBS.Views.UserControls
-{
- ///
- /// Done step of the Tests wizard — PASS/FAIL banner, results table, Run Again.
- /// DataContext is expected to be a .
- ///
- public partial class TestDoneView : UserControl
- {
- public TestDoneView()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/Views/UserControls/TestPlanView.xaml b/Views/UserControls/TestPlanView.xaml
deleted file mode 100644
index 6f08481..0000000
--- a/Views/UserControls/TestPlanView.xaml
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/UserControls/TestPlanView.xaml.cs b/Views/UserControls/TestPlanView.xaml.cs
deleted file mode 100644
index 3b2d39b..0000000
--- a/Views/UserControls/TestPlanView.xaml.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Windows.Controls;
-
-namespace HC_APTBS.Views.UserControls
-{
- ///
- /// Plan step of the Tests wizard — phase enable/disable and duration preview.
- /// DataContext is expected to be a .
- ///
- public partial class TestPlanView : UserControl
- {
- public TestPlanView()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/Views/UserControls/TestPreconditionsView.xaml b/Views/UserControls/TestPreconditionsView.xaml
deleted file mode 100644
index cbef64e..0000000
--- a/Views/UserControls/TestPreconditionsView.xaml
+++ /dev/null
@@ -1,173 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/UserControls/TestPreconditionsView.xaml.cs b/Views/UserControls/TestPreconditionsView.xaml.cs
deleted file mode 100644
index bca4623..0000000
--- a/Views/UserControls/TestPreconditionsView.xaml.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Windows.Controls;
-
-namespace HC_APTBS.Views.UserControls
-{
- ///
- /// Preconditions checklist for the Tests page wizard (§4b).
- /// DataContext is expected to be a .
- ///
- public partial class TestPreconditionsView : UserControl
- {
- public TestPreconditionsView()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/Views/UserControls/TestRunningView.xaml b/Views/UserControls/TestRunningView.xaml
deleted file mode 100644
index ba14d2c..0000000
--- a/Views/UserControls/TestRunningView.xaml
+++ /dev/null
@@ -1,159 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/UserControls/TestRunningView.xaml.cs b/Views/UserControls/TestRunningView.xaml.cs
deleted file mode 100644
index 79a052e..0000000
--- a/Views/UserControls/TestRunningView.xaml.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Windows.Controls;
-
-namespace HC_APTBS.Views.UserControls
-{
- ///
- /// Running step of the Tests wizard — live phase progress, flowmeter charts, abort.
- /// DataContext is expected to be a .
- ///
- public partial class TestRunningView : UserControl
- {
- public TestRunningView()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/Views/UserControls/TestSectionCard.xaml b/Views/UserControls/TestSectionCard.xaml
new file mode 100644
index 0000000..ca3e1c7
--- /dev/null
+++ b/Views/UserControls/TestSectionCard.xaml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/TestSectionCard.xaml.cs b/Views/UserControls/TestSectionCard.xaml.cs
new file mode 100644
index 0000000..e39bf88
--- /dev/null
+++ b/Views/UserControls/TestSectionCard.xaml.cs
@@ -0,0 +1,31 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Fluent section card hosting a test type's phase tiles.
+ /// Set =true to render chips (idle state) or
+ /// false to render full vertical live-bar indicators (running state).
+ /// DataContext is expected to be a .
+ ///
+ public partial class TestSectionCard : UserControl
+ {
+ /// Forwarded to every child .
+ public static readonly DependencyProperty CompactProperty =
+ DependencyProperty.Register(nameof(Compact), typeof(bool), typeof(TestSectionCard),
+ new PropertyMetadata(false));
+
+ /// Compact mode flag (see ).
+ public bool Compact
+ {
+ get => (bool)GetValue(CompactProperty);
+ set => SetValue(CompactProperty, value);
+ }
+
+ public TestSectionCard()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/TestSectionView.xaml b/Views/UserControls/TestSectionView.xaml
deleted file mode 100644
index f439d64..0000000
--- a/Views/UserControls/TestSectionView.xaml
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/UserControls/TestSectionView.xaml.cs b/Views/UserControls/TestSectionView.xaml.cs
deleted file mode 100644
index e9dabbf..0000000
--- a/Views/UserControls/TestSectionView.xaml.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Windows.Controls;
-
-namespace HC_APTBS.Views.UserControls
-{
- ///
- /// One test section — Expander header plus the horizontal row of phase cards.
- /// DataContext is expected to be a .
- ///
- public partial class TestSectionView : UserControl
- {
- public TestSectionView()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/Views/UserControls/UnlockSnackbarView.xaml b/Views/UserControls/UnlockSnackbarView.xaml
index f2efc07..1fb0a2f 100644
--- a/Views/UserControls/UnlockSnackbarView.xaml
+++ b/Views/UserControls/UnlockSnackbarView.xaml
@@ -84,6 +84,12 @@
Visibility="{Binding IsCancellable, Converter={StaticResource BoolToVis}}"
Height="30" Padding="12,4"/>
+
+
+
+
+
+
+
+ PerMonitorV2
+ true/PM
+
+
+
diff --git a/docs/dump functions.txt b/docs/dump functions.txt
new file mode 100644
index 0000000..7a97653
--- /dev/null
+++ b/docs/dump functions.txt
@@ -0,0 +1,297 @@
+ public bool DumpRom(int firstAddress, int lastAddress)
+ {
+ const ushort BLOCK_SIZE = 0x0100; // 256 bytes per block
+ const byte MAX_CHUNK = 13; // protocol limit
+ const bool DEBUG = true;
+
+ if (firstAddress < 0 || lastAddress < 0 || lastAddress < firstAddress)
+ {
+ Logger.Write("ReadRomEepromBlock: invalid address range.");
+ return false;
+ }
+
+ // Number of 0x0100 blocks touched by the requested range
+ int numBlocks = ((lastAddress - firstAddress) / BLOCK_SIZE) + 1;
+
+ // Fresh buffer for this dump
+ var packets2 = new List(numBlocks * BLOCK_SIZE);
+
+ Logger.Write($"ROM EEPROM BIN dump start: 0x{firstAddress:X4} .. 0x{lastAddress:X4}");
+
+ try
+ {
+ int addr = firstAddress;
+
+ while (addr <= lastAddress)
+ {
+ ushort blockBase = (ushort)(addr & 0xFF00);
+
+ if (DEBUG)
+ Logger.Write($"===== ROM EEPROM block base 0x{blockBase:X4} =====");
+
+ // Last address we may read inside this current 0x0100 block
+ int blockEnd = blockBase + BLOCK_SIZE - 1;
+
+ // Do not read past:
+ // 1) requested lastAddress
+ // 2) current block end
+ int maxReadableAbs = Math.Min(lastAddress, blockEnd);
+
+ while (addr <= maxReadableAbs)
+ {
+ int remaining = maxReadableAbs - addr + 1;
+ byte len = (byte)Math.Min(MAX_CHUNK, remaining);
+
+ var chunk = ReadRomEeprom((ushort)addr, len);
+
+ // Safety: if the ECU returns fewer bytes than requested, don't infinite-loop
+ if (chunk == null || chunk.Count == 0)
+ {
+ Logger.Write($"ReadRomEeprom returned 0 bytes at 0x{addr:X4}");
+ return false;
+ }
+
+ packets2.AddRange(chunk);
+
+ if (DEBUG)
+ //Logger.Write($"{addr:X4}: " + string.Join(" ", chunk.Select(b => b.ToString("X2"))));
+
+ // Advance by what was actually received
+ addr += chunk.Count;
+ }
+
+ // Move to next block if still not finished
+ if (addr <= lastAddress)
+ {
+ addr = blockBase + BLOCK_SIZE;
+ }
+ }
+
+ KeepAlive();
+
+ string fileName = $"rom_eeprom_dump_{firstAddress:X4}-{lastAddress:X4}.bin";
+ File.WriteAllBytes(fileName, packets2.ToArray());
+
+ Logger.Write($"ROM EEPROM BIN dump done: {packets2.Count} bytes -> {fileName}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.Write($"ReadRomEepromBlock failed: {ex.Message}");
+ return false;
+ }
+ }
+ public bool DumpEeprom(int startAddress, int endAddress)
+ {
+ const ushort BLOCK_SIZE = 0x0100; // logical block stride
+ const ushort VALID_BYTES_PER_BLOCK = 0x00C0; // EEPROM valid region: 0x0000..0x00BF
+ const byte MAX_CHUNK = 13; // protocol limit / safe chunk size
+
+ if (startAddress < 0 || endAddress < 0 || endAddress < startAddress)
+ {
+ Logger.Write("ReadEepromBlock: invalid address range.");
+ return false;
+ }
+
+ // Fresh buffer for this dump
+ var packets2 = new List();
+
+ Logger.Write($"EEPROM BIN dump start: 0x{startAddress:X4} .. 0x{endAddress:X4}");
+
+ try
+ {
+ // Walk absolute address range, but only read valid EEPROM bytes inside each 0x0100 block
+ int addr = startAddress;
+
+ while (addr <= endAddress)
+ {
+ ushort blockBase = (ushort)(addr & 0xFF00); // start of current 0x0100 block
+ ushort offsetInBlock = (ushort)(addr & 0x00FF);
+
+ // If current address is outside valid EEPROM region of this block, skip to next block
+ if (offsetInBlock >= VALID_BYTES_PER_BLOCK)
+ {
+ addr = blockBase + BLOCK_SIZE;
+ continue;
+ }
+
+ // Last valid absolute address inside this block
+ int blockValidEndAbs = blockBase + VALID_BYTES_PER_BLOCK - 1;
+
+ // Do not read past:
+ // 1) requested endAddress
+ // 2) valid EEPROM end within this block
+ int maxReadableAbs = Math.Min(endAddress, blockValidEndAbs);
+
+ while (addr <= maxReadableAbs)
+ {
+ int remaining = maxReadableAbs - addr + 1;
+ byte len = (byte)Math.Min(MAX_CHUNK, remaining);
+
+ var chunk = ReadEeprom((ushort)addr, len);
+
+ if (chunk == null || chunk.Count == 0)
+ {
+ Logger.Write($"ReadEeprom returned 0 bytes at 0x{addr:X4}");
+ return false;
+ }
+
+ packets2.AddRange(chunk);
+
+ float progress = 1.0f * (addr - startAddress) / (endAddress - startAddress);
+ Logger.Write($"Progress{progress:P1}");
+ //Logger.Write(string.Join(" ", chunk.Select(b => $"0x{b:X2}")));
+
+ // Safety: advance by what was actually received
+ addr += chunk.Count;
+ }
+
+ // Move to next block if needed
+ if (addr <= endAddress)
+ {
+ addr = (blockBase + BLOCK_SIZE);
+ }
+ }
+
+ KeepAlive();
+
+ string fileName = $"eeprom_dump_{startAddress:X4}-{endAddress:X4}.bin";
+ File.WriteAllBytes(fileName, packets2.ToArray());
+
+ Logger.Write($"EEPROM BIN dump done: {packets2.Count} bytes -> {fileName}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.Write($"ReadEepromBlock failed: {ex.Message}");
+ return false;
+ }
+ }
+ public bool DumpAllEeprom()
+ {
+ int startAddress = 0x0000;
+ int endAddress = 0x0000; //0x00BF
+ const ushort VALID_BYTES_PER_BLOCK = 0x00C0; // EEPROM valid region: 0x0000..0x00BF
+ const byte MAX_CHUNK = 13; // protocol limit / safe chunk size
+
+ if (startAddress < 0 || endAddress < 0 || endAddress < startAddress)
+ {
+ Logger.Write("ReadEepromBlock: invalid address range.");
+ return false;
+ }
+
+ // Fresh buffer for this dump
+ var packets2 = new List();
+
+ Logger.Write($"EEPROM BIN dump start: 0x{startAddress:X4} .. 0x{endAddress:X4}");
+
+ try
+ {
+ // Walk absolute address range, but only read valid EEPROM bytes inside each 0x0100 block
+ int addr = startAddress;
+
+ while (addr <= endAddress)
+ {
+ ushort blockBase = (ushort)(addr & 0xFF00); // start of current 0x0100 block
+ ushort offsetInBlock = (ushort)(addr & 0x00FF);
+
+ // Last valid absolute address inside this block
+ int blockValidEndAbs = blockBase + VALID_BYTES_PER_BLOCK - 1;
+
+ // Do not read past:
+ // 1) requested endAddress
+ // 2) valid EEPROM end within this block
+ int maxReadableAbs = Math.Min(endAddress, blockValidEndAbs);
+
+ while (addr <= maxReadableAbs)
+ {
+ int remaining = maxReadableAbs - addr + 1;
+ byte len = (byte)Math.Min(MAX_CHUNK, remaining);
+
+ var chunk = ReadEeprom((ushort)addr, len);
+
+ if (chunk == null || chunk.Count == 0)
+ {
+ Logger.Write($"ReadEeprom returned 0 bytes at 0x{addr:X4}");
+ return false;
+ }
+
+ packets2.AddRange(chunk);
+
+ float progress = 1.0f * (addr - startAddress) / (endAddress - startAddress);
+ Logger.Write($"Progress{progress:P1}");
+ //Logger.Write(string.Join(" ", chunk.Select(b => $"0x{b:X2}")));
+
+ // Safety: advance by what was actually received
+ addr += chunk.Count;
+ }
+
+ }
+ packets2.AddRange(new byte[16]);
+ packets2.AddRange(Enumerable.Repeat((byte)0xFF, 7));
+
+ var oemzone = SendCustom(new List { 0x18, 0x00, 0x00, 0x82, 0x33 });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ var newbytes = oemzone[0].Body;
+ newbytes.RemoveRange(0,4);
+ packets2.AddRange(newbytes);
+
+ /*oemzone = SendCustom(new List { 0x18, 0x00, 0x00, 0x00, 0x00 });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ packets2.AddRange(newbytes);
+
+ oemzone = SendCustom(new List { 0x18, 0x00, 0x00, 0x9F, 0xFF });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ packets2.AddRange(newbytes);
+
+ oemzone = SendCustom(new List { 0x18, 0x00, 0x00, 0xD7, 0x01 });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ packets2.AddRange(newbytes);
+
+ oemzone = SendCustom(new List { 0x18, 0x00, 0x00, 0xFF, 0xFC });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ packets2.AddRange(newbytes);*/
+
+ oemzone = SendCustom(new List { 0x18, 0x00, 0x01, 0x6A, 0x89 });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ newbytes.RemoveRange(0, 4);
+ packets2.AddRange(newbytes);
+
+ oemzone = SendCustom(new List { 0x18, 0x00, 0x02, 0x2E, 0x10 });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ newbytes.RemoveRange(0, 4);
+ packets2.AddRange(newbytes);
+
+ oemzone = SendCustom(new List { 0x18, 0x00, 0x03, 0xFF, 0xFF });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ newbytes.RemoveRange(0, 4);
+ packets2.AddRange(newbytes);
+
+ oemzone = SendCustom(new List { 0x18, 0x00, 0x04, 0xC7, 0xAE });
+ oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
+ newbytes = oemzone[0].Body;
+ newbytes.RemoveRange(0, 4);
+
+ packets2.AddRange(newbytes);
+
+ KeepAlive();
+
+ string fileName = $"eeprom_dump_{startAddress:X4}-{0x00FF:X4}.bin";
+ File.WriteAllBytes(fileName, packets2.ToArray());
+
+ Logger.Write($"EEPROM BIN dump done: {packets2.Count} bytes -> {fileName}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.Write($"ReadEepromBlock failed: {ex.Message}");
+ return false;
+ }
+ }
\ No newline at end of file
diff --git a/docs/gotcha-oil-pump-dialog-race.md b/docs/gotcha-oil-pump-dialog-race.md
new file mode 100644
index 0000000..2435a7f
--- /dev/null
+++ b/docs/gotcha-oil-pump-dialog-race.md
@@ -0,0 +1,58 @@
+# Gotcha: Oil-pump confirmation dialog vs. `RefreshFromTick` race
+
+## Symptom
+On the Tests page, pressing **Start Test** shows the oil-pump leak-check dialog. After the operator clicks **Accept**, the tests **do not start** — the operator has to press **Start Test** a second time. The second press works.
+
+## Why it happens
+`BenchControlViewModel.OnIsOilPumpOnChanged` uses `dlg.ShowDialog()`, which runs a **nested dispatcher message pump** on the UI thread. While that pump is draining, the `MainViewModel` refresh timer keeps ticking and calls `BenchControlViewModel.RefreshFromTick()`, which reads the relay state from config and writes it back into the `_isOilPumpOn` backing field:
+
+```csharp
+bool relayOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
+if (_isOilPumpOn != relayOn)
+{
+ _isOilPumpOn = relayOn;
+ OnPropertyChanged(nameof(IsOilPumpOn));
+}
+```
+
+The ordering on the first press is:
+
+1. `IsOilPumpOn = true` — setter writes backing field to `true`, then calls `OnIsOilPumpOnChanged`.
+2. Inside the partial: `dlg.ShowDialog()` blocks. **`SetRelay` has not been called yet, so `relay.State` is still `false`.**
+3. A refresh tick fires during the nested pump. `RefreshFromTick` sees `_isOilPumpOn (true) != relayOn (false)` and **clobbers `_isOilPumpOn` back to `false`**.
+4. Operator clicks Accept. `ShowDialog` returns. `_bench.SetRelay(OilPump, true)` finally runs and commits `relay.State = true`.
+5. `OnIsOilPumpOnChanged` returns — but `_isOilPumpOn` is still `false` from step 3.
+6. The caller (`TestsPageViewModel.StartTestAsync`) checks `if (!Root.BenchControl.IsOilPumpOn) return;` — guard trips, **test never starts**.
+
+On the second press, `relay.State` is already `true`, so `RefreshFromTick` is a no-op during the second dialog and the flow completes.
+
+## The fix
+After `SetRelay` commits the real state at the bottom of `OnIsOilPumpOnChanged`, re-assert the backing field:
+
+```csharp
+_bench.SetRelay(RelayNames.OilPump, value);
+
+if (_isOilPumpOn != value)
+{
+ _isOilPumpOn = value;
+ OnPropertyChanged(nameof(IsOilPumpOn));
+}
+```
+
+Writing through the backing field (not the setter) avoids re-triggering the confirmation dialog.
+
+## General lesson — nested message pumps
+**Any `ShowDialog()` call is a nested dispatcher pump.** While it blocks, timers, CAN callbacks marshalled to the UI thread, and property-change handlers keep running. Mutable state that other handlers may "correct" based on transient external readings can be rewritten under you before your synchronous code resumes. When mixing a modal dialog with a periodic state-sync task:
+
+- Either **suspend the sync task** while the dialog is open, or
+- **Re-assert local state after the dialog returns** once the ground truth (relay, register, etc.) has actually been committed.
+
+Symptoms of this class of bug:
+- An operation "works the second time but not the first"
+- A property setter appears to silently revert
+- A guard on a property right after a dialog accept evaluates the opposite of what the user chose
+
+## Files involved
+- [ViewModels/BenchControlViewModel.cs](../ViewModels/BenchControlViewModel.cs) — `OnIsOilPumpOnChanged`, `RefreshFromTick`
+- [ViewModels/Pages/TestsPageViewModel.cs](../ViewModels/Pages/TestsPageViewModel.cs) — `StartTestAsync` guard that exposed the race
+- [Services/Impl/BenchService.cs](../Services/Impl/BenchService.cs) — `SetRelay` (synchronous update of `relay.State` + async CAN transmit)
diff --git a/docs/gotcha-pump-change-ui-jank.md b/docs/gotcha-pump-change-ui-jank.md
new file mode 100644
index 0000000..3f320e9
--- /dev/null
+++ b/docs/gotcha-pump-change-ui-jank.md
@@ -0,0 +1,58 @@
+# Gotcha: `OnPumpChanged` UI-thread jank — deferred fixes
+
+## Status
+**Deferred.** These two issues cause a small but measurable hitch on the UI thread during manual pump selection. For pumps without an immobilizer unlocker the jank is not perceptible enough to justify the work. Document kept so the cost is understood if we ever need to address it (e.g. if disk I/O gets slower, pump catalogs grow, or the jank stacks with a future UI change).
+
+The separate unlocker-pump delay issue was fixed independently by making the unlock state observer event-driven via `CanBusParameter.ValueChanged`.
+
+## Issue 1 — `ConfigurationService.LoadPumpStatus` does disk I/O on the UI thread
+
+### Symptom
+On pump change, up to ~20–200 ms of UI thread blocking (worst case on cold cache or spinning disk) before `OnPumpChanged` returns. Two XML parses per pump change.
+
+### Why it happens
+[`MainViewModel.OnPumpChanged`](../ViewModels/MainViewModel.cs) calls `_config.LoadPumpStatus(...)` twice (once for the `Status` word, once for `Empf3`) while building the status displays. [`LoadPumpStatus`](../Services/Impl/ConfigurationService.cs) caches by `statusId`, but on pump switch the new pump usually carries a different `Type`, so both lookups miss and trigger `XDocument.Load(StatusXml)` — a full file read plus XML parse. Runs synchronously on the UI thread.
+
+### Fix (when needed)
+Preload every `` entry into `_statusCache` during `ConfigurationService` construction (or app bootstrap). The file is small and status definitions never change at runtime. After that, `LoadPumpStatus` degenerates to a pure dictionary lookup and disk I/O is gone from the pump-change path.
+
+Alternative (smaller change, slower fix): wrap both calls in `Task.Run` and apply results via `Dispatcher.InvokeAsync`. `PumpStatusDefinition` is a plain POCO, so it's safe to construct off-thread.
+
+## Issue 2 — `TestPanelViewModel.LoadAllTests` allocation burst on the UI thread
+
+### Symptom
+~5–30 ms synchronous burst on pump change while the test panel is rebuilt. On slower machines or pumps with many tests the operator sees a visible hitch between clicking the pump and the page settling.
+
+### Why it happens
+[`LoadAllTests`](../ViewModels/TestPanelViewModel.cs) clears the `Tests` collection and synchronously creates one `TestSectionViewModel` per test definition, each spawning child `PhaseCardViewModel` and `GraphicIndicatorViewModel` instances. For a typical pump that's ~100+ view models constructed in a single tight loop, each raising `INotifyPropertyChanged` setters. The `ObservableCollection.Add` calls also dispatch `CollectionChanged` synchronously through any `ItemsControl` already bound to `Tests`.
+
+### Fix (when needed)
+In `OnPumpChanged`, yield before `LoadAllTests` so the already-queued render frame commits first:
+
+```csharp
+// ...all the lightweight synchronous bookkeeping (senders, CAN param swap,
+// slider gate, unlock startup)...
+
+// Let the frame with the slider-enable + pump-name update paint before
+// we do the heavy test-panel rebuild.
+await Dispatcher.Yield(DispatcherPriority.Background);
+TestPanel.LoadAllTests(pump);
+```
+
+This turns `OnPumpChanged` into an `async void` handler — acceptable here because the caller is the `SelectedPump` partial-method hook which does not observe the returned Task. Operator sees the slider gate open and the unlock dialog appear instantly; the test panel fills in on the next dispatcher tick.
+
+Alternative: keep `OnPumpChanged` synchronous and wrap only the rebuild in `Dispatcher.BeginInvoke(..., DispatcherPriority.Background)`. Same effect; easier to keep the void signature.
+
+## Why we're not fixing these now
+- Non-unlocker pumps: the combined ~25–230 ms worst case is absorbed by the operator's own reaction time after clicking the pump. Not flagged as a UX problem in field use.
+- Unlocker pumps: the original 1 s unlock-dialog delay was the dominant visible symptom. That was fixed by the event-driven observer — the remaining jank from issues 1 and 2 sits under the noise floor of the unlock dialog appearing.
+
+Revisit if:
+- Pump catalogs grow to the point that `LoadAllTests` crosses the ~50 ms mark
+- `status.xml` grows (new status types) or storage latency regresses
+- Any future UI change on `DashboardPage` / `PumpPage` makes the pump-change transition visually tighter and exposes the hitch
+
+## Files involved
+- [ViewModels/MainViewModel.cs](../ViewModels/MainViewModel.cs) — `OnPumpChanged` (lines 391–470)
+- [Services/Impl/ConfigurationService.cs](../Services/Impl/ConfigurationService.cs) — `LoadPumpStatus` (lines 364–435)
+- [ViewModels/TestPanelViewModel.cs](../ViewModels/TestPanelViewModel.cs) — `LoadAllTests` (lines 110–125)
diff --git a/docs/kline_eeprom_spec.md b/docs/kline_eeprom_spec.md
new file mode 100644
index 0000000..84d39d7
--- /dev/null
+++ b/docs/kline_eeprom_spec.md
@@ -0,0 +1,207 @@
+## 5. EEPROM memory map (8-bit address, 0x00–0xFF)
+
+The on-board serial EEPROM has 256 bytes addressed `0x00–0xFF`. The ECU's internal mirror after boot covers a sub-range only (`FUN_7568` loads EEPROM 0x40–0x8B into RAM 0x400–0x44B); the upper area is read on demand.
+
+Observed structure (from cross-dump comparison + disassembly):
+
+| EEPROM range | Purpose | Notes |
+|---|---|---|
+| 0x00–0x3F | Pump cal record A — operating parameters | Read at boot via `FUN_4A79` (record-based, 8-byte chunks). Content is largely zero in current dumps. Erasable via cmd 0x05. |
+| 0x40–0x8B | Pump cal record B — primary | Block-loaded into RAM 0x400–0x44B at boot by `FUN_7568`. Contains: temp offset (0x42), dphi seed (0x44) + checksum (0x45), accel-comp seed (0x48), angle-table seed (0x4C), CKP loop seeds (0x50–0x56), redundant copies (0x79–0x7A), serial number ASCII (0x80–0x88) |
+| 0x8C–0xBF | Pump cal record C — extension | Not loaded into RAM at boot. Readable only via K-line cmd 0x19 after Zone 3 unlock. **Per user observation 2026-05-07: present in physical EEPROM, never consumed by running ECU code.** Likely OEM/factory metadata. |
+| 0xC0–0xCF | Reserved / unused | Outside any zone bounds in observed configs |
+| 0xD0 | Lockout backoff counter | Written by `FUN_29D4` failure path, read at boot by `FUN_3AD1`. Persists failed-auth state across power cycles. |
+| 0xD7–0xDF | Zone 0 seed (9 bytes) | Read into RAM 0xB7+ as part of cmd 0x18 success response |
+| 0xE1–0xE9 | Zone 1 seed (9 bytes) | Same |
+| 0xEA–0xF2 | Zone 2 seed / Zone 8 magic seed (9 bytes) | Same. Shared between Zone 2 and Zone 8. |
+| 0xF3–0xFB | Zone 3 seed (9 bytes) | Same |
+| 0xFC | Zone 0 use counter | Incremented on each cmd 0x18 + RB0=0x10 success |
+| 0xFD | Zone 1 use counter | Same |
+| 0xFE | Zone 2 / Zone 8 use counter | **Magic-zone access leaves this trace** |
+| 0xFF | Zone 3 use counter | Same |
+
+The "cal record" boundaries (A/B/C) are inferred from access patterns; the EEPROM does not have explicit headers separating them.
+
+---
+
+## 6. Security architecture
+
+### 6.1 Zone descriptor table
+
+A 4-entry × 12-byte table at **ROM 0x5FA0** drives zone authentication. Each entry has:
+
+| Offset | Field | Type | Purpose |
+|---|---|---|---|
+| +0 | `alt_key` | u16-LE | Alternative key (used by cmd 0x18 with RB0 ≠ 0x10) |
+| +2 | `exp_key` | u16-LE | Primary expected key |
+| +4 | `zone_start` | u16-LE | First EEPROM address the zone covers |
+| +6 | `zone_end` | u16-LE | Last EEPROM address (inclusive) |
+| +8 | `flag_byte` | u8 | RE6/RE7 bit pattern set on success |
+| +9 | `seed_off` | u8 | EEPROM offset of the 9-byte seed read on success |
+| +10 | `cnt_off` | u8 | EEPROM offset of the per-zone use counter |
+| +11 | reserved | u8 | usually 0xFF (unused) |
+
+A **fifth implicit "magic zone 8"** is hard-coded in `FUN_29D4`:
+- Key: **0x4453** (ASCII "DS")
+- Range: 0x0000–0xFFFE (effectively the whole 64KB MCU address space)
+- On success: `RE7 = 0xFF` (every gate-bit set, including the cmd 0x1A write gate)
+- Seed/counter: shares Zone 2's offsets (0xEA / 0xFE)
+
+
+### 6.3 Authentication flow (`FUN_29D4`)
+
+Tester side:
+
+1. **Build cmd 0x18 block** with:
+ - `RB3` = 0x03 (request length / sub-count)
+ - `RB4` = zone selector (0, 1, 2, 3, or 8)
+ - `RB5` = key high byte
+ - `RB6` = key low byte
+ - `RB7` = any non-zero byte (acts as continuation flag — without it, the ECU's `RWC4 = 0` reset short-circuits the success path)
+ - `RB0` = 0x10 if you also want write access enabled (sets the upper-nibble flag bits AND increments the use counter)
+ - Otherwise `RB0` = block length (4 + data bytes)
+
+2. **TX block, observe response.** ECU returns success block (`RB2 = 0xF0`, payload = the 9-byte seed + zone bounds) or error (`RB2` = 0xE5/error pattern).
+
+3. **On success**, internal flags are set:
+ - Zone 0 → `RE6 |= 0x01` (read) or `|= 0x11` if RB0=0x10 (read+write)
+ - Zone 1 → `RE6 |= 0x02` or `|= 0x22`
+ - Zone 2 → `RE6 |= 0x04` or `|= 0x44`
+ - Zone 3 → `RE7 |= 0x08`
+ - Zone 8 → `RE7 = 0xFF` (and cmd 0x1A becomes available)
+
+4. The flags persist for the rest of the K-line session; they reset on session end / power cycle.
+
+ECU-side details an implementer should be aware of:
+
+- The 9-byte seed read at `EEPROM[seed_off..seed_off+8]` is **echoed back to the tester in the response**. It does not gate access — pure transport, presumably so the tool can fingerprint the unit.
+- `RE8` (lockout state) must be 0 to attempt auth. If non-zero (set by previous failed attempts), the ECU silently fails until the lockout timer (`FUN_38BE`) decrements RE8 to 0.
+- Wildcard match: if `exp_key == 0xFFFF` in any zone descriptor, ANY tester key is accepted. **None of the observed dumps use this**, but a future ROM variant might.
+- Sentinel: if `exp_key == 0x5555`, the zone is permanently disabled (cannot be unlocked). None observed yet either.
+
+### 6.4 Lockout mechanism
+
+Implemented in `FUN_29D4` failure path + `FUN_38BE` timer.
+
+```
+fail #1 -> RE9 = 1, RE8 = 1, stored to EEPROM 0xD0
+fail #2 -> RE9 = 2, RE8 = 2
+fail #3 -> RE9 = 4
+...
+fail #8 -> RE9 = 128
+fail #9+ -> RE9 = 240 (saturated)
+```
+
+`RE8` decrements once per ~30000 ticks (active session) or ~120000 ticks (idle). Each decrement re-writes EEPROM 0xD0 — the lockout therefore **persists across power cycles**. A wedged unit can require minutes to hours of waiting to retry.
+
+**Recommendation for the reader software:** read EEPROM 0xD0 before any auth attempt (via cmd 0x19 after Zone 3 unlock — the only safe path that doesn't risk further lockout).
+
+## 7. Read recipes
+
+### 7.1 Recipe A — public read of 0x00–0xBF (Zone 3)
+
+This is the safest operation: it requires a static key (0x00FF) that is identical across every variant we have dumps for, has no destructive side-effects, does not increment the magic-zone counter, and covers the full "general data" portion of the EEPROM.
+
+```
+# Step 1 — unlock Zone 3
+TX: [ 06 NN 18 03 03 00 FF FF 03 ]
+ len=06, seq=NN, cmd=0x18, RB3=03, RB4=03 (Zone 3),
+ RB5=00, RB6=FF (key 0x00FF), RB7=FF (continuation flag),
+ end=03
+
+ECU response (success): RB2 = 0xF0, plus 9-byte seed @ 0xF3..0xFB.
+ECU now has RE7 |= 0x08.
+
+# Step 2 — read EEPROM in 13-byte chunks
+for offset in 0x00, 0x0D, 0x1A, 0x27, 0x34, 0x41, 0x4E, 0x5B,
+ 0x68, 0x75, 0x82, 0x8F, 0x9C, 0xA9, 0xB6:
+ TX: [ 04 NN 19 0D 00 offset 03 ]
+ cmd=0x19 (read EEPROM), RB3=0x0D (13 bytes), RB5=offset
+ RX: 13 bytes from EEPROM[offset..offset+12] in the response payload
+
+# 15 chunks * 13 bytes = 195 bytes, covering 0x00..0xC2
+# Trim or adjust the last chunk's RB3 to 0x0A so it stops at 0xBF inclusive.
+```
+
+### 7.2 Recipe B — full read of 0x00–0xFF (Zone 0)
+
+Reads bytes that Zone 3 cannot see (0xC0–0xFF — counters, seeds, lockout). Requires the OEM key 0x00A6.
+
+```
+# Step 1 — unlock Zone 0
+TX: [ 06 NN 18 03 00 00 A6 01 03 ]
+ RB4=00 (Zone 0), RB5=00, RB6=A6, RB7=01
+
+# Step 2 — read EEPROM via cmd 0x03 (per-zone path)
+for offset in 0x00, 0x0A, 0x14, ..., 0xF6:
+ TX: [ 06 NN 03 0A 00 offset 03 ]
+ cmd=0x03, RB3=0x0A (10 bytes), RB4:RB5 = 0x00:offset (16-bit address)
+
+# 26 chunks * 10 bytes = 260 bytes, slightly over-reads;
+# adjust the last chunk's RB3 to stop at 0xFF.
+```
+
+### 7.3 Recipe C — magic full access (Zone 8) — read AND write
+
+```
+# Step 1 — unlock Zone 8 with the master key
+TX: [ 06 NN 18 03 08 44 53 01 03 ]
+ RB4=08 (magic zone), RB5=44, RB6=53 (key "DS" = 0x4453), RB7=01
+
+# After: RE7 = 0xFF (every gate-bit set).
+# All read commands work; cmd 0x1A is also available for writing.
+
+# WARNING: this access path increments EEPROM[0xFE] (the use counter).
+# The increment is non-destructive but forensically observable — service
+# tools at the OEM can read 0xFE to see how many times Zone 8 was accessed.
+```
+
+### 7.4 Recipe D — fast-path RAM-mirror dump (no auth)
+
+For EEPROM 0x40–0x8B specifically, you don't need to talk to the EEPROM driver at all — the data is mirrored into RAM 0x400–0x44B at boot and accessible via cmd 0x01 (read RAM, no auth required).
+
+```
+for offset in 0x400, 0x40D, 0x41A, ..., 0x444:
+ TX: [ 04 NN 01 0D high_addr low_addr 03 ]
+ cmd=0x01 (read RAM/ROM), RB3=0x0D, RB4=high, RB5=low
+
+# 6 chunks covering 0x400..0x44B = exact mirror of EEPROM 0x40..0x8B.
+```
+
+This recipe is ideal for monitoring tools that want to observe the live EEPROM cache without authentication overhead.
+
+---
+
+## 8. Write recipes — DANGER ZONE
+
+Writes require either Zone 0/1/2 unlock with `RB0 = 0x10` (cmd 0x0C path) or Zone 8 (cmd 0x1A path).
+
+### 8.1 Cmd 0x0C — write via OEM zone (with RB0 = 0x10 unlock)
+
+```
+# Step 1 — unlock Zone 0 with write enabled
+TX: [ 06 NN 18 03 00 00 A6 01 03 ] # NOTE: RB0 must be 0x10, not 0x06,
+ # to set the read+write flag pattern
+ # (RC8 << 4 | RC8) and increment 0xFC.
+```
+
+(Implementer task: verify the RB0=0x10 vs RB0=0x06 distinction by experiment — it's set in `FUN_29D4 @ 0x29D4` based on `RB0 == 0x10` test, but the call path that actually populates `RB0` differs between the dispatcher's path-A and path-B.)
+
+```
+# Step 2 — cmd 0x0C write with verify
+TX: [ 0E NN 0C NN 00 offset b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 03 ]
+ cmd=0x0C, RB3=count<11, RB4:RB5 = address, RB6+ = bytes to write
+```
+
+### 8.2 Cmd 0x1A — write via Zone 8 (after master unlock)
+
+After Zone 8 is unlocked (RE7 = 0xFF), cmd 0x1A becomes available:
+
+```
+TX: [ ... NN 1A count rb4 offset b1..bN 03 ]
+ cmd=0x1A, RB3=count<11, RB5=EEPROM offset (8-bit), data bytes from RAM 0xB6+
+```
+
+Each byte is written and immediately read-back-verified internally by `FUN_22EA`. On verify failure, the response indicates failure and the partial write is left in place.
+
+---
\ No newline at end of file
diff --git a/seed_aliases.py b/seed_aliases.py
new file mode 100644
index 0000000..54da340
--- /dev/null
+++ b/seed_aliases.py
@@ -0,0 +1,123 @@
+"""One-shot seeder: inject blocks into pumps.xml from the legacy
+KlineIDs comma-string at old_source/Herlic2.0/App.config.
+
+Reads canonical -> [aliases] map (hard-coded below from the legacy default),
+finds each in pumps.xml, and inserts an ...
+block as the first child of that element.
+
+Idempotent: if a already has an child, the script merges new
+aliases into it without creating duplicates.
+"""
+import sys
+from pathlib import Path
+import xml.etree.ElementTree as ET
+
+# Source: old_source/Herlic2.0/App.config -> KlineIDs setting (default value).
+# Format: "klineID:canonicalPumpID,..."
+LEGACY_KLINEIDS = (
+ "0470004007:0470004002,0470004008:0470004006,0470004012:0470004004,"
+ "1093412060:0470504028,1093412050:0470504012,1093424041:0470504034,"
+ "0986444003:0470504004,1093412071:0470504033,1093423001:0470504027,"
+ "0986444012:0470504011,1093412070:0470504033,0986444005:0470504009,"
+ "1093424051:0470504046,1093424025:1093424026,0986444002:0470504003,"
+ "1093424027:1093424026,1093423002:0470504027,1093424024:1093424026,"
+ "0986444006:0470506002,0986444011:0470504010,0986444044:0470506037,"
+ "1093421004:0470504026,1093412072:0470504033,1093411003:0470504030,"
+ "1093424040:0470504034,0986444019:0470504020,0986444004:0470504005,"
+ "1093411022:0470504037,0986444042:0470506017,0986444014:0470504015,"
+ "1093421006:0470504026,1093421005:0470504026,1093424080:0470504046,"
+ "0986444026:0470506030,1093411004:0470504030,0986444010:0470506009,"
+ "0986444008:0470506006,"
+)
+
+PUMPS_XML = Path.home() / ".HC_APTBS" / "config" / "pumps.xml"
+
+
+def parse_legacy(s: str) -> dict[str, list[str]]:
+ """Group legacy entries by canonical pump ID."""
+ aliases_by_canonical: dict[str, list[str]] = {}
+ for entry in s.split(","):
+ entry = entry.strip()
+ if not entry or ":" not in entry:
+ continue
+ kline_id, canonical = entry.split(":", 1)
+ kline_id = kline_id.strip()
+ canonical = canonical.strip()
+ if not kline_id or not canonical:
+ continue
+ bucket = aliases_by_canonical.setdefault(canonical, [])
+ if kline_id not in bucket:
+ bucket.append(kline_id)
+ return aliases_by_canonical
+
+
+def main() -> int:
+ aliases_by_canonical = parse_legacy(LEGACY_KLINEIDS)
+ print(f"Parsed {sum(len(v) for v in aliases_by_canonical.values())} aliases "
+ f"across {len(aliases_by_canonical)} canonical pumps.")
+
+ if not PUMPS_XML.exists():
+ print(f"ERROR: {PUMPS_XML} does not exist.", file=sys.stderr)
+ return 1
+
+ tree = ET.parse(PUMPS_XML)
+ root = tree.getroot()
+
+ pumps_by_id: dict[str, ET.Element] = {}
+ for pump in root.iter("Pump"):
+ pid = pump.attrib.get("id")
+ if pid:
+ pumps_by_id[pid] = pump
+
+ missing = sorted(set(aliases_by_canonical) - set(pumps_by_id))
+ if missing:
+ print(f"WARNING: {len(missing)} canonical pumps from legacy data not in "
+ f"pumps.xml — skipping: {missing}", file=sys.stderr)
+
+ injected_pumps = 0
+ injected_aliases = 0
+ skipped_existing = 0
+
+ for canonical, kline_ids in sorted(aliases_by_canonical.items()):
+ pump = pumps_by_id.get(canonical)
+ if pump is None:
+ continue
+
+ existing = pump.find("Aliases")
+ if existing is None:
+ existing = ET.Element("Aliases")
+ pump.insert(0, existing)
+ existing.text = "\n "
+ existing.tail = "\n "
+
+ already = {(c.tag, (c.text or "").strip())
+ for c in existing if c.tag in ("KlineId", "ModelRef")}
+
+ added_here = 0
+ for k in kline_ids:
+ key = ("KlineId", k)
+ if key in already:
+ skipped_existing += 1
+ continue
+ kid = ET.SubElement(existing, "KlineId")
+ kid.text = k
+ kid.tail = "\n "
+ already.add(key)
+ added_here += 1
+ injected_aliases += 1
+
+ if added_here:
+ injected_pumps += 1
+ # Fix the tail of the last child so the closing tag is on its own line.
+ last = list(existing)[-1]
+ last.tail = "\n "
+
+ tree.write(PUMPS_XML, encoding="utf-8", xml_declaration=True)
+ print(f"Injected {injected_aliases} aliases into {injected_pumps} pumps.")
+ if skipped_existing:
+ print(f" ({skipped_existing} aliases were already present, skipped.)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/Import-BipStatus.ps1 b/tools/Import-BipStatus.ps1
new file mode 100644
index 0000000..82676c2
--- /dev/null
+++ b/tools/Import-BipStatus.ps1
@@ -0,0 +1,230 @@
+<#
+.SYNOPSIS
+ Parses BIP-STATUS sections from PSG5-PI CFG files and injects
+ blocks into every preinjection="true" pump in pumps.xml.
+
+.DESCRIPTION
+ Applies the 7-def Config B (T15014 family) to every preinjection=true pump.
+ Reactions are forced to 0 (display-only pass per implementation plan).
+ A timestamped backup of pumps.xml is created before modification.
+
+.PARAMETER CfgDir
+ Directory containing the CFG files.
+
+.PARAMETER PumpsXml
+ Path to pumps.xml. Defaults to %USERPROFILE%\.HC_APTBS\config\pumps.xml.
+
+.PARAMETER DryRun
+ Print the generated XML only; do NOT modify pumps.xml.
+
+.EXAMPLE
+ powershell -ExecutionPolicy Bypass -File Import-BipStatus.ps1
+ powershell -ExecutionPolicy Bypass -File Import-BipStatus.ps1 -DryRun
+#>
+param(
+ [string]$CfgDir = (Join-Path $PSScriptRoot '..\old_source\HerlicScripts\CFGs'),
+ [string]$PumpsXml = (Join-Path $env:USERPROFILE '.HC_APTBS\config\pumps.xml'),
+ [switch]$DryRun
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$BipCfgFiles = @(
+ 'T15009.CFG','T15014.CFG','T15017.CFG','T15018.CFG','T15019.CFG',
+ 'T15021.CFG','T18309.CFG','T18603.CFG','T18607.CFG','T31804.CFG'
+)
+
+# ---- Parse one CFG file -------------------------------------------------------
+function Parse-BipConfig {
+ param([string]$cfgPath)
+
+ $defMap = @{}
+ $vector = $null
+
+ $lines = [System.IO.File]::ReadAllLines($cfgPath, [System.Text.Encoding]::GetEncoding('ISO-8859-1'))
+
+ foreach ($line in $lines) {
+ $raw = ($line -split '\\')[0].Trim()
+ if (-not $raw) { continue }
+
+ $cols = $raw -split '\|' | ForEach-Object { $_.Trim() }
+ $key = $cols[0]
+
+ if ($key -eq 'BIP-STATUS') {
+ if ($cols.Count -ge 5) { $vector = $cols[4].Trim() }
+ }
+ elseif ($key -match '^BIP-STATUS(\d+)-DEF$') {
+ $idx = [int]$Matches[1]
+ $hexStr = if ($cols.Count -ge 5) { $cols[4].Trim() } else { '0000' }
+ $spfnRaw = if ($cols.Count -ge 7) { $cols[6].Trim() } else { '9' }
+ $spfn = if ($spfnRaw -eq 'B') { 11 } else {
+ $n = 9; [int]::TryParse($spfnRaw, [ref]$n) | Out-Null; $n
+ }
+ $defMap[$idx] = [ordered]@{
+ Index = $idx
+ Enabled = $true
+ HexPattern = [Convert]::ToUInt16($hexStr, 16)
+ Reaction = 0
+ SpecialFunction= $spfn
+ Description = ''
+ }
+ }
+ elseif ($key -match '^BIP-STATUS(\d+)$') {
+ $idx = [int]$Matches[1]
+ $desc = if ($cols.Count -ge 5) { $cols[4].Trim() } else { '' }
+ if ($defMap.ContainsKey($idx)) { $defMap[$idx].Description = $desc }
+ }
+ }
+
+ # Apply enable vector: char at position i = '1' means bit i enabled
+ if ($vector) {
+ $vArr = $vector.ToCharArray()
+ foreach ($idx in @($defMap.Keys)) {
+ if ($idx -lt $vArr.Count) {
+ $defMap[$idx].Enabled = ($vArr[$idx] -eq '1')
+ }
+ }
+ }
+
+ return @($defMap.GetEnumerator() | Sort-Object { $_.Key } | ForEach-Object { $_.Value })
+}
+
+# ---- Parse all CFG files ------------------------------------------------------
+Write-Host ""
+Write-Host "=== Parsing CFG files from: $CfgDir ===" -ForegroundColor Cyan
+Write-Host ""
+
+$configs = @{}
+foreach ($cfgName in $BipCfgFiles) {
+ $cfgPath = Join-Path $CfgDir $cfgName
+ if (-not (Test-Path $cfgPath)) {
+ Write-Warning "CFG not found, skipping: $cfgPath"
+ continue
+ }
+ $parsed = Parse-BipConfig -cfgPath $cfgPath
+ $cnt = $parsed.Count
+ Write-Host " $cfgName -- $cnt BIP definitions"
+ $configs[$cfgName] = $parsed
+}
+
+# T15014 = 7-def Config B (most complete; safe to apply to all preinjection pumps)
+$configB = $null
+if ($configs.ContainsKey('T15014.CFG')) { $configB = $configs['T15014.CFG'] }
+if ($null -eq $configB) {
+ # Fallback: any 7-def config
+ foreach ($k in $configs.Keys) {
+ if ($configs[$k].Count -eq 7) { $configB = $configs[$k]; break }
+ }
+}
+if ($null -eq $configB -and $configs.ContainsKey('T15009.CFG')) {
+ $configB = $configs['T15009.CFG']
+}
+if ($null -eq $configB) {
+ Write-Error "No BIP config could be parsed. Aborting."
+ exit 1
+}
+
+$defCount = $configB.Count
+Write-Host ""
+Write-Host "Using $defCount-def Config B (T15014 family) for all preinjection pumps." -ForegroundColor Green
+
+# ---- Generate preview XML -----------------------------------------------------
+if ($DryRun) {
+ Write-Host ""
+ Write-Host "=== Generated BipStatus XML (DryRun -- pumps.xml NOT modified) ===" -ForegroundColor Yellow
+ Write-Host " "
+ foreach ($d in $configB) {
+ $en = if ($d.Enabled) { 'true' } else { 'false' }
+ $hex = '0x{0:X4}' -f [int]$d.HexPattern
+ $desc= $d.Description
+ Write-Host (" {5}" -f $d.Index, $en, $hex, $d.Reaction, $d.SpecialFunction, $desc)
+ }
+ Write-Host " "
+ Write-Host ""
+ exit 0
+}
+
+# ---- Load pumps.xml -----------------------------------------------------------
+if (-not (Test-Path $PumpsXml)) {
+ Write-Error "pumps.xml not found: $PumpsXml"
+ exit 1
+}
+
+$backup = $PumpsXml -replace '\.xml$', ('_bip_backup_{0}.xml' -f (Get-Date -Format 'yyyyMMdd_HHmmss'))
+Copy-Item $PumpsXml $backup -Force
+Write-Host "Backed up pumps.xml -> $backup" -ForegroundColor DarkGray
+
+[System.Reflection.Assembly]::LoadWithPartialName('System.Xml.Linq') | Out-Null
+$xdoc = [System.Xml.Linq.XDocument]::Load($PumpsXml)
+
+$pumps = @($xdoc.Descendants([System.Xml.Linq.XName]'Pump') | Where-Object {
+ $attr = $_.Attribute('preinjection')
+ ($null -ne $attr) -and ($attr.Value -eq 'true')
+})
+
+Write-Host ""
+Write-Host "Found $($pumps.Count) preinjection=true pump(s) in pumps.xml." -ForegroundColor Cyan
+
+$updated = 0
+foreach ($pump in $pumps) {
+ $idAttr = $pump.Attribute('id')
+ $pumpId = if ($null -ne $idAttr) { $idAttr.Value } else { '(unknown)' }
+
+ # Remove any existing BipStatus
+ $existing = $pump.Element([System.Xml.Linq.XName]'BipStatus')
+ if ($null -ne $existing) {
+ $existing.Remove()
+ Write-Host " Replaced BipStatus on pump $pumpId"
+ } else {
+ Write-Host " Added BipStatus on pump $pumpId"
+ }
+
+ # Build new BipStatus element
+ $bipElem = [System.Xml.Linq.XElement]::new([System.Xml.Linq.XName]'BipStatus')
+ foreach ($d in $configB) {
+ $en = if ($d.Enabled) { 'true' } else { 'false' }
+ $hex = '0x{0:X4}' -f [int]$d.HexPattern
+ $bit = [System.Xml.Linq.XElement]::new([System.Xml.Linq.XName]'Bit',
+ [System.Xml.Linq.XAttribute]::new('index', [string]$d.Index),
+ [System.Xml.Linq.XAttribute]::new('enabled', $en),
+ [System.Xml.Linq.XAttribute]::new('hex', $hex),
+ [System.Xml.Linq.XAttribute]::new('reaction', [string]$d.Reaction),
+ [System.Xml.Linq.XAttribute]::new('specialFunction', [string]$d.SpecialFunction),
+ [System.Xml.Linq.XText]::new($d.Description)
+ )
+ $bipElem.Add($bit)
+ }
+
+ # Insert after , or append if absent
+ $paramsElem = $pump.Element([System.Xml.Linq.XName]'Params')
+ if ($null -ne $paramsElem) {
+ $paramsElem.AddAfterSelf($bipElem)
+ } else {
+ $pump.Add($bipElem)
+ }
+ $updated++
+}
+
+if ($updated -eq 0) {
+ Write-Warning "No pumps updated. Restoring backup."
+ Copy-Item $backup $PumpsXml -Force
+ exit 0
+}
+
+# Save UTF-8 without BOM
+$settings = [System.Xml.XmlWriterSettings]::new()
+$settings.Encoding = [System.Text.UTF8Encoding]::new($false)
+$settings.Indent = $true
+$settings.IndentChars = ' '
+$settings.OmitXmlDeclaration = $false
+
+$writer = [System.Xml.XmlWriter]::Create($PumpsXml, $settings)
+try { $xdoc.Save($writer) } finally { $writer.Close() }
+
+Write-Host ""
+Write-Host "Done. Updated $updated pump(s) in pumps.xml." -ForegroundColor Green
+Write-Host "Backup: $backup" -ForegroundColor DarkGray
+Write-Host ""
+Write-Host "Next: run the app, select a preinjection pump, and verify BipDisplayView shows $defCount BIP rows." -ForegroundColor Cyan
+Write-Host ""