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 : 10xA8, + /// 20xE8. 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 @@ - 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/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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -