diff --git a/App.xaml b/App.xaml
index 3a364b7..cd96ff6 100644
--- a/App.xaml
+++ b/App.xaml
@@ -6,6 +6,8 @@
+
+
diff --git a/App.xaml.cs b/App.xaml.cs
index d2f826c..5501868 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -1,6 +1,7 @@
using System.Windows;
using HC_APTBS.Infrastructure.Logging;
using HC_APTBS.Infrastructure.Pcan;
+using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.Services.Impl;
using HC_APTBS.ViewModels;
@@ -30,6 +31,12 @@ public partial class App : Application
ConfigureServices(services);
_serviceProvider = services.BuildServiceProvider();
+ // 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();
+ CanBusParameter.WarningLogger = logger.Warning;
+ SensorConfiguration.WarningLogger = logger.Warning;
+
// Initialise the ViewModel (loads pump IDs, starts refresh timer, connects CAN).
var mainVm = _serviceProvider.GetRequiredService();
await mainVm.InitialiseAsync();
diff --git a/Converters/EnumToIntConverter.cs b/Converters/EnumToIntConverter.cs
new file mode 100644
index 0000000..b6e3bb6
--- /dev/null
+++ b/Converters/EnumToIntConverter.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace HC_APTBS.Converters
+{
+ ///
+ /// Two-way converter that maps an enum value to/from its underlying value.
+ ///
+ /// Used to bind TabControl.SelectedIndex (int) to a ViewModel enum property.
+ /// Convert and ConvertBack both honor the actual enum type — pass targetType as the
+ /// enum type when converting back.
+ ///
+ public sealed class EnumToIntConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is Enum ? System.Convert.ToInt32(value) : 0;
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is int i && targetType.IsEnum)
+ return Enum.ToObject(targetType, i);
+ return System.Windows.Data.Binding.DoNothing;
+ }
+ }
+}
diff --git a/Infrastructure/Pcan/PcanAdapter.cs b/Infrastructure/Pcan/PcanAdapter.cs
index 7ea18d5..582a857 100644
--- a/Infrastructure/Pcan/PcanAdapter.cs
+++ b/Infrastructure/Pcan/PcanAdapter.cs
@@ -237,9 +237,11 @@ namespace HC_APTBS.Infrastructure.Pcan
foreach (var param in parameters)
{
if (param.IsReceive) continue;
- uint raw = (uint)param.GetTransmitValue();
- msg.DATA[param.ByteH] = (byte)((raw & 0xFF00) >> 8);
- msg.DATA[param.ByteL] = (byte)(raw & 0x00FF);
+ // Cast to int first so negative values (e.g. FBKW) get proper
+ // two's complement representation in the 16-bit CAN field.
+ ushort raw = unchecked((ushort)(int)param.GetTransmitValue());
+ msg.DATA[param.ByteH] = (byte)(raw >> 8);
+ msg.DATA[param.ByteL] = (byte)(raw & 0xFF);
}
CurrentStatus = PCANBasic.Write(_channel, ref msg);
diff --git a/MainWindow.xaml b/MainWindow.xaml
index d51d82f..09eabb0 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -5,6 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:HC_APTBS.ViewModels"
xmlns:uc="clr-namespace:HC_APTBS.Views.UserControls"
+ xmlns:pages="clr-namespace:HC_APTBS.Views.Pages"
xmlns:models="clr-namespace:HC_APTBS.Models"
mc:Ignorable="d"
Title="{DynamicResource App.Title}"
@@ -15,62 +16,93 @@
Background="#FFEDEDED"
Closing="OnWindowClosing">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -83,463 +115,93 @@
-
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Models/BipStatusDefinition.cs b/Models/BipStatusDefinition.cs
new file mode 100644
index 0000000..f74f1ce
--- /dev/null
+++ b/Models/BipStatusDefinition.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using System.Linq;
+
+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.
+
+ ///
+ /// One BIP-STATUS entry: a 16-bit HEX pattern matched against the captured BIP
+ /// status word, together with the reaction to apply on a match.
+ ///
+ public class BipStatusDefinition
+ {
+ /// Whether this definition participates in pattern matching.
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// 16-bit nibble pattern compared against the captured BIP status word.
+ /// Legacy examples: 0x0000 = BIP OK, 0x000F = no BIP in window,
+ /// 0x00F0 = no BIP + MV off, 0x0F00 = no MV drive, 0xF00F = deviation too large.
+ ///
+ public ushort HexPattern { get; set; }
+
+ ///
+ /// Reaction code applied on match:
+ /// 0 = none, 1 = abort (emergency stop), 2 = warning, 3 = log-only.
+ ///
+ public int Reaction { get; set; } = 0;
+
+ ///
+ /// Special-function marker from legacy CFG:
+ /// 9 = standard handling, 0xB = Ford timing-correction (Type 3 only).
+ ///
+ public int SpecialFunction { get; set; } = 9;
+
+ /// Human-readable description shown on match.
+ public string Description { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Per-pump BIP status container. Holds up to 8
+ /// entries (max by legacy convention; not validated).
+ ///
+ public class PumpBipDefinition
+ {
+ /// Ordered BIP status definitions (max 8).
+ public List Bits { get; set; } = new();
+
+ ///
+ /// Returns the first enabled definition whose
+ /// exactly equals , or null if none match.
+ ///
+ public BipStatusDefinition? Match(ushort value) =>
+ Bits.FirstOrDefault(b => b.Enabled && b.HexPattern == value);
+ }
+}
diff --git a/Models/CanBusParameter.cs b/Models/CanBusParameter.cs
index 8034542..dae9916 100644
--- a/Models/CanBusParameter.cs
+++ b/Models/CanBusParameter.cs
@@ -105,6 +105,19 @@ namespace HC_APTBS.Models
///
public double Alpha { get; set; } = 1.0;
+ // ── Runtime-warning plumbing ──────────────────────────────────────────────
+
+ ///
+ /// Static logging hook invoked when encounters
+ /// a zero denominator. Wired at app startup to IAppLogger.Warning.
+ /// Left null keeps the Models layer DI-free and testable.
+ /// Signature: (source, message).
+ ///
+ public static Action? WarningLogger { get; set; }
+
+ /// Set once per instance after the first zero-denominator warning, to prevent hot-loop log spam.
+ private bool _warnedDenomZero;
+
// ── Convenience alias kept for cross-file compatibility ───────────────────
/// Alias for — used by legacy call sites.
@@ -144,16 +157,40 @@ namespace HC_APTBS.Models
///
/// Applies the P1–P6 rational transfer function to .
/// Used only by pump params ( = true).
+ /// Returns 0 and warns once if the denominator is zero.
///
public double GetTransformResult()
{
if (IsReceive)
{
- return (-P2 - P3 * P5 - P4 * P6 + P4 * Value)
- / (P1 + P3 * P5 + P3 * P6 - P3 * Value);
+ double denom = P1 + P3 * P5 + P3 * P6 - P3 * Value;
+ if (denom == 0.0)
+ {
+ if (!_warnedDenomZero)
+ {
+ WarningLogger?.Invoke(nameof(CanBusParameter),
+ $"Zero denominator for '{Name}' (receive). Returning 0.");
+ _warnedDenomZero = true;
+ }
+ return 0.0;
+ }
+ return (-P2 - P3 * P5 - P4 * P6 + P4 * Value) / denom;
}
- return ((P1 * Value + P2) / (P3 * Value + P4)) + P5 + P6;
+ {
+ double denom = P3 * Value + P4;
+ if (denom == 0.0)
+ {
+ if (!_warnedDenomZero)
+ {
+ WarningLogger?.Invoke(nameof(CanBusParameter),
+ $"Zero denominator for '{Name}' (transmit). Returning 0.");
+ _warnedDenomZero = true;
+ }
+ return 0.0;
+ }
+ return ((P1 * Value + P2) / denom) + P5 + P6;
+ }
}
///
@@ -185,13 +222,22 @@ namespace HC_APTBS.Models
///
public static CanBusParameter FromXml(XElement xe)
{
+ string name = xe.Name.LocalName;
+ ushort byteh = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0");
+ ushort bytel = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0");
+ if (byteh > 7 || bytel > 7)
+ {
+ throw new ArgumentOutOfRangeException(nameof(xe),
+ $"Param '{name}': byteh={byteh} bytel={bytel} out of 0-7.");
+ }
+
var p = new CanBusParameter
{
- Name = xe.Name.LocalName,
+ Name = name,
MessageId = uint.Parse(xe.Attribute("busid")?.Value ?? "0",
NumberStyles.HexNumber),
- ByteH = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0"),
- ByteL = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0"),
+ ByteH = byteh,
+ ByteL = bytel,
Type = int.Parse(xe.Attribute("type")?.Value ?? "0"),
IsReceive = !string.Equals(xe.Attribute("send")?.Value, "true",
StringComparison.OrdinalIgnoreCase),
diff --git a/Models/CompletedTestRun.cs b/Models/CompletedTestRun.cs
new file mode 100644
index 0000000..b909767
--- /dev/null
+++ b/Models/CompletedTestRun.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace HC_APTBS.Models
+{
+ ///
+ /// Immutable snapshot of a completed test session captured at the moment
+ /// fires. Lives only for the
+ /// lifetime of the current app session — there is no cross-session storage.
+ ///
+ /// The is a deep copy of the pump's metadata and
+ /// list (including per-parameter results) so that
+ /// re-running a test on the same pump does not mutate prior history entries.
+ /// It can be handed directly to
+ /// to reproduce the exact PDF for this run.
+ ///
+ public sealed class CompletedTestRun
+ {
+ /// Unique identifier for the run — used as the key for per-entry delete.
+ public Guid Id { get; init; } = Guid.NewGuid();
+
+ /// Wall-clock time the TestFinished event fired.
+ public DateTime CompletedAt { get; init; }
+
+ /// Pump model string copied from the source pump at capture time.
+ public string PumpModel { get; init; } = string.Empty;
+
+ /// Pump serial number copied from the source pump at capture time.
+ public string PumpSerial { get; init; } = string.Empty;
+
+ /// True when every evaluated parameter in every enabled phase passed.
+ public bool OverallPassed { get; init; }
+
+ /// True when the run ended via abort / stop instead of normal completion.
+ public bool Interrupted { get; init; }
+
+ ///
+ /// Deep-cloned pump containing metadata + deep-cloned
+ /// list with results. Safe to hand directly to .
+ ///
+ public PumpDefinition PumpSnapshot { get; init; } = null!;
+
+ ///
+ /// Operator observations edited on the Results page. Mutable after capture,
+ /// persisted back from the
+ /// when the operator exports a PDF.
+ ///
+ public string Observations { get; set; } = string.Empty;
+
+ ///
+ /// Space-separated list of test names in the snapshot (e.g. "WL · UP · PFP"),
+ /// shown as a secondary label in the history list.
+ ///
+ public string TestNames =>
+ PumpSnapshot?.Tests != null && PumpSnapshot.Tests.Count > 0
+ ? string.Join(" \u00B7 ", PumpSnapshot.Tests.Select(t => t.Name))
+ : string.Empty;
+ }
+}
diff --git a/Models/PumpDefinition.cs b/Models/PumpDefinition.cs
index fab71c4..0afae02 100644
--- a/Models/PumpDefinition.cs
+++ b/Models/PumpDefinition.cs
@@ -104,6 +104,15 @@ namespace HC_APTBS.Models
/// Ordered list of test procedures applicable to this pump model.
public List Tests { get; set; } = new();
+ // ── BIP (pre-injection only) ──────────────────────────────────────────────
+
+ ///
+ /// BIP (Begin of Injection Period) status definitions. Non-null only on
+ /// pre-injection PSG5-PI pumps (Type 2 T15xxx / Type 3 T18xxx Ford). Null on
+ /// standard VP44 pumps. See .
+ ///
+ public PumpBipDefinition? BipStatus { get; set; }
+
// ── Runtime live values ────────────────────────────────────────────────────
/// Current fuel quantity feedback value (me) from the pump ECU.
diff --git a/Models/PumpStatusDefinition.cs b/Models/PumpStatusDefinition.cs
index 5d943c1..3e35dca 100644
--- a/Models/PumpStatusDefinition.cs
+++ b/Models/PumpStatusDefinition.cs
@@ -49,5 +49,12 @@ namespace HC_APTBS.Models
/// Human-readable description of this state.
public string Description { get; set; } = string.Empty;
+
+ ///
+ /// Reaction code applied when the owning status bit enters this state during
+ /// a test phase:
+ /// 0 = none, 1 = abort (emergency stop), 2 = warning, 3 = log-only.
+ ///
+ public int Reaction { get; set; } = 0;
}
}
diff --git a/Models/SensorConfiguration.cs b/Models/SensorConfiguration.cs
index b9b73bb..a15c96b 100644
--- a/Models/SensorConfiguration.cs
+++ b/Models/SensorConfiguration.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Xml.Linq;
namespace HC_APTBS.Models
@@ -41,18 +42,41 @@ namespace HC_APTBS.Models
/// Additive offset correction applied after the gain.
public double Offset { get; set; } = 0;
+ ///
+ /// Static logging hook used by runtime guards and .
+ /// Wired at app startup to IAppLogger.Warning. Left null keeps the
+ /// Models layer DI-free and testable. Signature: (source, message).
+ ///
+ public static Action? WarningLogger { get; set; }
+
+ /// Set once per instance after the first zero-range warning, to prevent hot-loop log spam.
+ private bool _warnedRangeZero;
+
///
/// Converts a raw CAN bus ADC count to a calibrated engineering-unit value.
+ /// Returns 0 and warns once if MaxVolt == MinVolt.
///
/// 10-bit ADC count from the CAN frame.
/// Calibrated value in engineering units.
public double GetValueFromRaw(double rawCanBusValue)
{
+ double range = MaxVolt - MinVolt;
+ if (range == 0.0)
+ {
+ if (!_warnedRangeZero)
+ {
+ WarningLogger?.Invoke(nameof(SensorConfiguration),
+ $"MaxVolt == MinVolt for sensor '{SensorName}' (channel {Number}). Returning 0.");
+ _warnedRangeZero = true;
+ }
+ return 0.0;
+ }
+
// Convert ADC counts → volts (10-bit ADC, 5 V reference)
double volts = rawCanBusValue * 5000.0 / 1024.0 / 1000.0;
// Map voltage range → engineering-unit range
- double value = volts * ((MaxVal - MinVal) / (MaxVolt - MinVolt));
+ double value = volts * ((MaxVal - MinVal) / range);
return value * Gain + Offset;
}
@@ -73,17 +97,21 @@ namespace HC_APTBS.Models
public static SensorConfiguration FromXml(XElement element)
{
var sc = new SensorConfiguration();
- TryParse(element.Attribute("num")?.Value, v => sc.Number = short.Parse(v));
- TryParse(element.Attribute("name")?.Value, v => sc.SensorName = v);
- TryParse(element.Element("Gain")?.Value, v => sc.Gain = double.Parse(v));
- TryParse(element.Element("Offset")?.Value, v => sc.Offset = double.Parse(v));
- TryParse(element.Element("MinVolt")?.Value, v => sc.MinVolt = double.Parse(v));
- TryParse(element.Element("MaxVolt")?.Value, v => sc.MaxVolt = double.Parse(v));
- TryParse(element.Element("MinVal")?.Value, v => sc.MinVal = double.Parse(v));
- TryParse(element.Element("MaxVal")?.Value, v => sc.MaxVal = double.Parse(v));
+ TryParse(element.Attribute("num")?.Value, "num", v => sc.Number = short.Parse(v, CultureInfo.InvariantCulture));
+ TryParse(element.Attribute("name")?.Value, "name", v => sc.SensorName = v);
+ TryParse(element.Element("Gain")?.Value, "Gain", v => sc.Gain = ParseInvariant(v));
+ TryParse(element.Element("Offset")?.Value, "Offset", v => sc.Offset = ParseInvariant(v));
+ TryParse(element.Element("MinVolt")?.Value, "MinVolt", v => sc.MinVolt = ParseInvariant(v));
+ TryParse(element.Element("MaxVolt")?.Value, "MaxVolt", v => sc.MaxVolt = ParseInvariant(v));
+ TryParse(element.Element("MinVal")?.Value, "MinVal", v => sc.MinVal = ParseInvariant(v));
+ TryParse(element.Element("MaxVal")?.Value, "MaxVal", v => sc.MaxVal = ParseInvariant(v));
return sc;
}
+ /// Parses a decimal under , tolerating comma separators.
+ private static double ParseInvariant(string v)
+ => double.Parse(v.Replace(',', '.'), CultureInfo.InvariantCulture);
+
/// Creates the default pressure sensor calibration for channel 1.
public static SensorConfiguration DefaultPressureSensor()
=> new SensorConfiguration
@@ -97,10 +125,15 @@ namespace HC_APTBS.Models
MaxVal = 20
};
- private static void TryParse(string? value, Action assign)
+ private static void TryParse(string? value, string fieldName, Action assign)
{
- if (!string.IsNullOrEmpty(value))
- try { assign(value!); } catch { /* Ignore malformed XML values */ }
+ if (string.IsNullOrEmpty(value)) return;
+ try { assign(value!); }
+ catch (Exception ex)
+ {
+ WarningLogger?.Invoke(nameof(SensorConfiguration),
+ $"Skipped malformed '{fieldName}' value '{value}': {ex.Message}");
+ }
}
}
}
diff --git a/Models/TestDefinition.cs b/Models/TestDefinition.cs
index a6574ea..f49b0d9 100644
--- a/Models/TestDefinition.cs
+++ b/Models/TestDefinition.cs
@@ -57,6 +57,13 @@ namespace HC_APTBS.Models
/// Execution order index within the pump's test list.
public int Order { get; set; }
+ ///
+ /// When true, the Tests page preconditions step gates Start on operator authentication
+ /// () before this test may run.
+ /// Backwards-compatible: absent attribute in older pumps.xml files defaults to false.
+ ///
+ public bool RequiresAuth { get; set; } = false;
+
/// Phase definitions, executed sequentially.
public List Phases { get; set; } = new();
@@ -90,7 +97,8 @@ namespace HC_APTBS.Models
new XAttribute("Tacond", ConditioningTimeSec),
new XAttribute("Tmeasur", MeasurementTimeSec),
new XAttribute("MeasurePerSecond", MeasurementsPerSecond),
- new XAttribute("orden", Order));
+ new XAttribute("orden", Order),
+ new XAttribute("requiresAuth", RequiresAuth));
foreach (var phase in Phases)
node.Add(phase.ToXml());
@@ -108,7 +116,8 @@ namespace HC_APTBS.Models
MeasurementTimeSec = int.Parse(element.Attribute("Tmeasur")!.Value),
MeasurementsPerSecond = double.Parse(
element.Attribute("MeasurePerSecond")!.Value, CultureInfo.InvariantCulture),
- Order = int.Parse(element.Attribute("orden")!.Value)
+ Order = int.Parse(element.Attribute("orden")!.Value),
+ RequiresAuth = bool.TryParse(element.Attribute("requiresAuth")?.Value, out var req) && req
};
foreach (var phaseEl in element.Elements(XmlPhase))
diff --git a/Models/TestFlowState.cs b/Models/TestFlowState.cs
new file mode 100644
index 0000000..2efc8bf
--- /dev/null
+++ b/Models/TestFlowState.cs
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 0000000..2acd9c3
--- /dev/null
+++ b/Resources/NavStyles.xaml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Resources/Strings.en.xaml b/Resources/Strings.en.xaml
index 27035a7..6a64b1e 100644
--- a/Resources/Strings.en.xaml
+++ b/Resources/Strings.en.xaml
@@ -8,10 +8,39 @@
HC_APTBS — Herlic Test Bench
- ESP
-
- Settings
+
+ Dashboard
+ Bench
+ Pump
+ Tests
+ Results
+ Settings
+
+
+ Bench readings
+ Connections
+ CAN bus
+ Bench controller
+ Pump ECU
+ K-Line session
+ ONLINE
+ OFFLINE
+ OPEN
+ CLOSED
+ FAILED
+ Active alarms
+ System OK — no active alarms
+ Test summary
+ Active:
+ Phase:
+ No test is currently running.
+ Last test: PASS
+ Last test: FAIL
+ Start Test
+ Requires a selected pump and an open CAN connection.
+ Stop
+ EMERGENCY STOP
Status:
@@ -24,7 +53,6 @@
Disconnected
- Bench
Connect CAN
Disconnect CAN
rpm
@@ -64,6 +92,40 @@
Electronic
Deposit Cooler
Deposit Heater
+ T-in Cooler
+ Flasher
+ 4-Pulse Signal
+ ON
+ OFF
+ Temperature
+ Setpoint
+ Tolerance
+ Apply setpoint
+
+
+ Identification
+ Fault Codes
+ Live Data
+ Adaptation
+ Immobilizer Unlock
+ K-Line session is not open. Identify a pump to begin diagnostics.
+ Select a pump on the Identification tab to enable diagnostics.
+ No immobilizer unlock is currently in progress for this pump.
+ Engineering values (raw)
+
+
+ Read DTCs
+ Clear DTCs
+ No fault codes reported by the ECU.
+ Last read:
+ Fault codes cleared.
+
+
+ Authentication required
+ This section can modify pump parameters. Sign in to unlock.
+ Authenticate…
+ Unlocked as
+ Lock
T-hyb
@@ -135,6 +197,56 @@
Test started...
Test stopped.
+
+ 1. Plan
+ 2. Preconditions
+ 3. Running
+ 4. Done
+ Next ▶
+ ◀ Back
+
+
+ Preconditions
+ Pump selected
+ CAN bus live
+ K-Line session open
+ Bench RPM at zero
+ Oil pump running
+ No critical alarms
+ User authenticated
+ Fix
+ All preconditions met. Ready to start.
+ Resolve the items above before starting.
+ This test requires operator authentication.
+ Authenticate…
+ Go to Pump page to select a pump.
+ Open the Dashboard to check CAN connection.
+ Open a K-Line session on the Pump page.
+ Stop the bench from the Bench page.
+ Start the oil pump from the Bench page.
+ Clear critical alarms on the Dashboard.
+ Authenticate the operator above.
+
+
+ ■ Abort
+ Pause
+ Resume
+ Retry phase
+ Skip phase
+ Coming soon
+
+
+ PASSED
+ FAILED
+ View full results
+ Run again
+
+
+ Abort test?
+ Stopping the test now will end the current phase and return partial results. Continue?
+ Abort
+ Keep running
+
Warm-up
Adjustment
@@ -152,6 +264,19 @@
Result
All Tests
+
+ Results
+ Test history (session)
+ Run detail
+ Observations / Notes
+ Export PDF…
+ No completed tests yet. Finish a test on the Tests page to see it here.
+ Select a run on the left to view its details.
+ INTERRUPTED
+ Remove this entry
+ Clear session
+ Clear all history entries for this session?
+
PASS
FAIL
@@ -225,7 +350,7 @@
Safety
PID
Motor
- Company
+ Report
K-Line
Advanced
@@ -272,6 +397,29 @@
Blink interval (ms):
Flasher interval (ms):
+ Users
+ Manage Users...
+
+
+ User Management
+ Username
+ Add...
+ Remove
+ Change Password...
+ Close
+ Add User
+ Change password for '{0}'
+ Remove User
+ Remove user '{0}'?
+ Invalid Input
+ Username and password cannot be empty.
+ Invalid Input
+ Username must not contain ':' or ','.
+ Duplicate User
+ A user with that name already exists.
+ Cannot Remove
+ Cannot remove the last remaining user.
+
Failed to generate report:\n{0}
Report Error
@@ -318,5 +466,6 @@
⚠ Error bits: {0}
No sample data available for graphical display.
Samples: {0} | Target: {1} ± {2} | Average: {3} | Result: {4}
+ OBSERVATIONS
diff --git a/Resources/Strings.es.xaml b/Resources/Strings.es.xaml
index 8adf533..076297d 100644
--- a/Resources/Strings.es.xaml
+++ b/Resources/Strings.es.xaml
@@ -8,10 +8,39 @@
HC_APTBS — Herlic Banco de Pruebas
- ENG
-
- Configuración
+
+ Panel
+ Banco
+ Bomba
+ Pruebas
+ Resultados
+ Configuración
+
+
+ Lecturas del banco
+ Conexiones
+ Bus CAN
+ Controlador del banco
+ ECU de la bomba
+ Sesión K-Line
+ EN LÍNEA
+ FUERA DE LÍNEA
+ ABIERTA
+ CERRADA
+ FALLO
+ Alarmas activas
+ Sistema OK — sin alarmas activas
+ Resumen de prueba
+ Activa:
+ Fase:
+ No hay ninguna prueba en curso.
+ Última prueba: APROBADA
+ Última prueba: FALLIDA
+ Iniciar prueba
+ Requiere una bomba seleccionada y conexión CAN abierta.
+ Detener
+ PARADA DE EMERGENCIA
Estado:
@@ -24,7 +53,6 @@
Desconectado
- Banco
Conectar CAN
Desconectar CAN
rpm
@@ -64,6 +92,40 @@
Electrónico
Enfriador Depósito
Calentador Depósito
+ Enfriador T-in
+ Intermitente
+ Señal 4-Pulsos
+ ON
+ OFF
+ Temperatura
+ Consigna
+ Tolerancia
+ Aplicar consigna
+
+
+ Identificación
+ Códigos de Falla
+ Datos en Vivo
+ Adaptación
+ Desbloqueo Immo
+ La sesión K-Line no está abierta. Identifique una bomba para iniciar el diagnóstico.
+ Seleccione una bomba en la pestaña Identificación para habilitar el diagnóstico.
+ No hay ningún desbloqueo de inmovilizador en curso para esta bomba.
+ Valores de ingeniería (en bruto)
+
+
+ Leer DTC
+ Borrar DTC
+ La ECU no reportó códigos de falla.
+ Última lectura:
+ Códigos de falla borrados.
+
+
+ Se requiere autenticación
+ Esta sección puede modificar parámetros de la bomba. Inicie sesión para desbloquearla.
+ Autenticar…
+ Desbloqueado como
+ Bloquear
T-hyb
@@ -135,6 +197,56 @@
Test iniciado...
Test detenido.
+
+ 1. Planificar
+ 2. Precondiciones
+ 3. En ejecución
+ 4. Finalizado
+ Siguiente ▶
+ ◀ Atrás
+
+
+ Precondiciones
+ Bomba seleccionada
+ Bus CAN activo
+ Sesión K-Line abierta
+ RPM del banco a cero
+ Bomba de aceite en marcha
+ Sin alarmas críticas
+ Usuario autenticado
+ Corregir
+ Todas las precondiciones cumplidas. Listo para comenzar.
+ Resuelva los elementos anteriores antes de comenzar.
+ Este test requiere autenticación del operador.
+ Autenticar…
+ Vaya a la página de Bomba para seleccionar una.
+ Abra el Dashboard para verificar la conexión CAN.
+ Abra una sesión K-Line en la página Bomba.
+ Detenga el banco desde la página Banco.
+ Arranque la bomba de aceite desde la página Banco.
+ Elimine las alarmas críticas en el Dashboard.
+ Autentique al operador arriba.
+
+
+ ■ Abortar
+ Pausar
+ Reanudar
+ Reintentar fase
+ Saltar fase
+ Próximamente
+
+
+ APROBADO
+ FALLIDO
+ Ver resultados completos
+ Ejecutar de nuevo
+
+
+ ¿Abortar el test?
+ Detener el test ahora finalizará la fase actual y devolverá resultados parciales. ¿Continuar?
+ Abortar
+ Seguir ejecutando
+
Calentamiento
Ajuste
@@ -152,6 +264,19 @@
Resultado
Todos los Tests
+
+ Resultados
+ Historial de pruebas (sesión)
+ Detalle de la prueba
+ Observaciones / Notas
+ Exportar PDF…
+ Aún no hay pruebas completadas. Finalice una prueba para verla aquí.
+ Seleccione una prueba en la lista para ver sus detalles.
+ INTERRUMPIDA
+ Eliminar esta entrada
+ Limpiar sesión
+ ¿Eliminar todas las entradas del historial de esta sesión?
+
APROBADO
REPROBADO
@@ -225,7 +350,7 @@
Seguridad
PID
Motor
- Empresa
+ Reporte
K-Line
Avanzado
@@ -272,6 +397,29 @@
Intervalo parpadeo (ms):
Intervalo flasher (ms):
+ Usuarios
+ Administrar Usuarios...
+
+
+ Gestión de Usuarios
+ Usuario
+ Añadir...
+ Eliminar
+ Cambiar Contraseña...
+ Cerrar
+ Añadir Usuario
+ Cambiar contraseña de '{0}'
+ Eliminar Usuario
+ ¿Eliminar el usuario '{0}'?
+ Entrada Inválida
+ El usuario y la contraseña no pueden estar vacíos.
+ Entrada Inválida
+ El nombre de usuario no puede contener ':' o ','.
+ Usuario Duplicado
+ Ya existe un usuario con ese nombre.
+ No Se Puede Eliminar
+ No se puede eliminar el último usuario restante.
+
Error al generar informe:\n{0}
Error de Informe
@@ -318,5 +466,6 @@
⚠ Bits de error: {0}
No hay datos de muestra disponibles para visualización gráfica.
Muestras: {0} | Objetivo: {1} ± {2} | Promedio: {3} | Resultado: {4}
+ OBSERVACIONES
diff --git a/Resources/Styles.xaml b/Resources/Styles.xaml
new file mode 100644
index 0000000..ade9352
--- /dev/null
+++ b/Resources/Styles.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Services/IBenchService.cs b/Services/IBenchService.cs
index ad7981c..79b10aa 100644
--- a/Services/IBenchService.cs
+++ b/Services/IBenchService.cs
@@ -43,6 +43,14 @@ namespace HC_APTBS.Services
///
event Action? EmergencyStopTriggered; //reason
+ ///
+ /// Raised on a 0→1 transition to a pump-status state whose
+ /// is non-zero during a measurement phase.
+ /// Args: (bit, reaction, description) where reaction is 1=abort, 2=warning, 3=log.
+ /// Fires on a background thread — consumers must marshal to the UI thread.
+ ///
+ event Action? StatusReactionTriggered; //bit, reaction, description
+
// ── Active pump ───────────────────────────────────────────────────────────
///
@@ -191,6 +199,14 @@ namespace HC_APTBS.Services
/// Requests a controlled stop of the currently running test sequence.
void StopTests();
+ ///
+ /// Immediately triggers an emergency stop: zeros RPM, zeros pump parameters,
+ /// cancels any running test, and fires .
+ /// Intended for operator-initiated E-Stop from the Dashboard.
+ ///
+ /// Human-readable reason logged and propagated to the event.
+ void RequestEmergencyStop(string reason);
+
// ── Lock angle ────────────────────────────────────────────────────────────
///
@@ -226,5 +242,12 @@ namespace HC_APTBS.Services
/// Fires on a background thread — consumers must marshal to the UI thread.
///
event Action? MeasurementSampled; //parameterName, value
+
+ ///
+ /// Fires once per second during a phase's conditioning and measurement sub-sections.
+ /// Args: (section, remainingSeconds, totalSeconds) where section is "Conditioning" or "Measuring".
+ /// Fires on a background thread — consumers must marshal to the UI thread.
+ ///
+ event Action? PhaseTimerTick;
}
}
diff --git a/Services/IConfigurationService.cs b/Services/IConfigurationService.cs
index 3b61557..4553a63 100644
--- a/Services/IConfigurationService.cs
+++ b/Services/IConfigurationService.cs
@@ -79,5 +79,27 @@ namespace HC_APTBS.Services
/// Replaces all stored user credentials and persists them.
void UpdateUsers(Dictionary users);
+
+ ///
+ /// Adds a new user with the given plaintext password.
+ /// Returns false if the username already exists, if either field is empty/whitespace,
+ /// or if the username contains the reserved separator characters ':' or ','.
+ /// Hashes of existing users are preserved.
+ ///
+ bool AddUser(string username, string password);
+
+ ///
+ /// Removes the user with the given username.
+ /// Returns false if the user does not exist or if this would remove the last remaining user.
+ /// Hashes of other users are preserved.
+ ///
+ bool RemoveUser(string username);
+
+ ///
+ /// Replaces the stored password for an existing user.
+ /// Returns false if the user does not exist or the password is empty.
+ /// Hashes of other users are preserved.
+ ///
+ bool ChangeUserPassword(string username, string newPassword);
}
}
diff --git a/Services/IPdfService.cs b/Services/IPdfService.cs
index 3746e14..adc2fb8 100644
--- a/Services/IPdfService.cs
+++ b/Services/IPdfService.cs
@@ -15,11 +15,15 @@ namespace HC_APTBS.Services
/// Name of the operator to appear in the report header.
/// Client/customer name to appear in the report header.
/// Directory where the PDF file will be saved.
+ /// Optional multi-line client address/contact info shown under the client name in the header.
+ /// Optional free-text operator observations rendered in a dedicated section near the bottom of the report.
/// Full path to the generated PDF file.
string GenerateReport(
PumpDefinition pump,
string operatorName,
string clientName,
- string outputFolder);
+ string outputFolder,
+ string? clientInfo = null,
+ string? observations = null);
}
}
diff --git a/Services/Impl/BenchService.cs b/Services/Impl/BenchService.cs
index f18da23..c25cd6c 100644
--- a/Services/Impl/BenchService.cs
+++ b/Services/Impl/BenchService.cs
@@ -65,6 +65,14 @@ namespace HC_APTBS.Services.Impl
// Alarm bitmask snapshot for edge detection during test phases
private int _lastAlarmMask;
+ // Active pump's status-word definition, cached when a test starts.
+ // Used by PollPumpStatusReactions to evaluate reaction codes per (bit,state).
+ private PumpStatusDefinition? _activeStatusDef;
+
+ // Tracks which (bit, state) pairs have already fired their reaction during
+ // the current phase so we don't re-fire every tick. Entry value true = fired.
+ private readonly Dictionary<(int bit, int state), bool> _statusReactionEdge = new();
+
// QOver zero-flow safety debounce (elapsed ms from phase stopwatch)
private long _qOverZeroSinceMs;
private const int QOverDebounceSec = 3;
@@ -103,6 +111,14 @@ namespace HC_APTBS.Services.Impl
public event Action? MeasurementSampled;
///
public event Action? EmergencyStopTriggered;
+ ///
+ public event Action? PhaseTimerTick;
+ ///
+ public event Action? StatusReactionTriggered;
+
+ // Section labels for PhaseTimerTick — constants avoid per-tick string allocations.
+ private const string SectionConditioning = "Conditioning";
+ private const string SectionMeasuring = "Measuring";
// ── Constructor ───────────────────────────────────────────────────────────
@@ -195,7 +211,6 @@ namespace HC_APTBS.Services.Impl
SendRpmVoltage(voltage);
_lastTargetRpm = safeRpm;
- RpmCommandSent?.Invoke();
_log.Debug(LogId, $"SetRpm({safeRpm}) → voltage={voltage:F3}V");
}
@@ -212,7 +227,6 @@ namespace HC_APTBS.Services.Impl
if (safeRpm <= 0)
{
SendRpmVoltage(0);
- RpmCommandSent?.Invoke();
return;
}
@@ -220,7 +234,6 @@ namespace HC_APTBS.Services.Impl
double initialVoltage = RpmVoltageRelation.VoltageForRpm(
(int)safeRpm, _config.Settings.Relations);
SendRpmVoltage(initialVoltage);
- RpmCommandSent?.Invoke();
// Step 2: Calculate approach delay based on RPM distance.
double actualRpm = ReadBenchParameter(BenchParameterNames.BenchRpm);
@@ -261,7 +274,6 @@ namespace HC_APTBS.Services.Impl
_pidController = null;
_lastTargetRpm = 0;
SendRpmVoltage(0);
- RpmCommandSent?.Invoke();
_log.Info(LogId, "StopRpmPid: PID stopped, 0V sent.");
}
@@ -274,9 +286,15 @@ namespace HC_APTBS.Services.Impl
if (volts < 0) volts = 0;
_lastCommandVoltage = volts;
- SetParameter(BenchParameterNames.Rpm, volts);
+ // Scale voltage (0-10V) to 12-bit DAC value (0-4095).
+ // The embedded motor controller has a 12-bit DAC: 0 = 0V, 4095 = 10V.
+ double dacValue = (volts * 4095.0) / 10.0;
+
+ SetParameter(BenchParameterNames.Rpm, dacValue);
SendParameters(_config.Bench.ParametersByName.TryGetValue(
BenchParameterNames.Rpm, out var rpmParam) ? rpmParam.MessageId : 0x10);
+
+ RpmCommandSent?.Invoke();
}
// ── IBenchService: temperature ────────────────────────────────────────────
@@ -585,6 +603,15 @@ namespace HC_APTBS.Services.Impl
{
TestStarted?.Invoke();
+ // Cache the active pump's status-word definition so PollPumpStatusReactions
+ // can evaluate reaction codes without repeating the config lookup each tick.
+ _activeStatusDef = null;
+ _statusReactionEdge.Clear();
+ if (pump.ParametersByName.TryGetValue(PumpParameterNames.Status, out var statusParam))
+ {
+ _activeStatusDef = _config.LoadPumpStatus(statusParam.Type);
+ }
+
// Small delay to allow oil circulation to stabilise before the first test.
await Task.Delay(2000, _cts.Token);
@@ -618,6 +645,8 @@ namespace HC_APTBS.Services.Impl
finally
{
_running = false;
+ _activeStatusDef = null;
+ _statusReactionEdge.Clear();
}
}
@@ -738,13 +767,15 @@ namespace HC_APTBS.Services.Impl
long conditioningRemainMs = (long)test.ConditioningTimeSec * 1000 - sw.ElapsedMilliseconds;
_log.Debug(LogId, $"{phase.Name}: conditioning remaining={conditioningRemainMs}ms");
+ int conditioningTotalSec = (int)(conditioningRemainMs / 1000);
for (int i = 0; i * 1000 < conditioningRemainMs; i++)
{
ct.ThrowIfCancellationRequested();
CheckQOverSafety(i * 1000L);
PollAlarms(phase);
- int remaining = (int)(conditioningRemainMs / 1000) - i;
+ int remaining = conditioningTotalSec - i;
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {remaining}s");
+ PhaseTimerTick?.Invoke(SectionConditioning, remaining, conditioningTotalSec);
await Task.Delay(1000, ct);
}
@@ -812,8 +843,13 @@ namespace HC_APTBS.Services.Impl
long measureMs = (long)test.MeasurementTimeSec * 1000;
int sleepMs = (int)(1000.0 / Math.Max(test.MeasurementsPerSecond, 0.1));
+ int measureTotalSec = (int)(measureMs / 1000);
var sw = Stopwatch.StartNew();
+ // Initial tick so the UI captures total + full remaining at t=0.
+ PhaseTimerTick?.Invoke(SectionMeasuring, measureTotalSec, measureTotalSec);
+ int lastRemaining = measureTotalSec;
+
while (sw.ElapsedMilliseconds <= measureMs)
{
ct.ThrowIfCancellationRequested();
@@ -830,7 +866,16 @@ namespace HC_APTBS.Services.Impl
MeasurementSampled?.Invoke(tp.Name, sample.Value);
}
+ // Emit countdown only when the integer-second value changes.
+ int remaining = (int)Math.Max(0, (measureMs - sw.ElapsedMilliseconds + 999) / 1000);
+ if (remaining != lastRemaining)
+ {
+ PhaseTimerTick?.Invoke(SectionMeasuring, remaining, measureTotalSec);
+ lastRemaining = remaining;
+ }
+
PollAlarms(phase);
+ PollPumpStatusReactions();
await Task.Delay(sleepMs, ct);
}
@@ -982,9 +1027,17 @@ namespace HC_APTBS.Services.Impl
SetRpm(0);
ZeroPumpParameters();
_cts?.Cancel();
+ _statusReactionEdge.Clear();
EmergencyStopTriggered?.Invoke(reason);
}
+ ///
+ public void RequestEmergencyStop(string reason)
+ {
+ _log.Warning(LogId, $"Operator-initiated emergency stop: {reason}");
+ PerformEmergencyStop(reason);
+ }
+
///
/// Reads the current alarm bitmask from the Alarms CAN parameter, detects
/// bits that transitioned 0→1 since the last snapshot, and records them
@@ -1008,6 +1061,78 @@ namespace HC_APTBS.Services.Impl
_lastAlarmMask = currentMask;
}
+ ///
+ /// Reads the current pump status word, evaluates each enabled status bit
+ /// against its , and dispatches the
+ /// configured reaction (abort / warning / log) on a 0→1 transition to a
+ /// state whose is non-zero.
+ ///
+ ///
+ /// v1 treats every status bit as single-bit (state 0 or 1); multi-bit grouped
+ /// status fields are not yet supported — extend the currentState
+ /// computation when needed.
+ ///
+ ///
+ private void PollPumpStatusReactions()
+ {
+ if (_activeStatusDef == null) return;
+
+ uint raw = (uint)ReadParameter(PumpParameterNames.Status);
+
+ foreach (var bit in _activeStatusDef.Bits)
+ {
+ if (!bit.Enabled) continue;
+
+ int currentState = (int)((raw >> bit.Bit) & 1u);
+
+ // Find the StatusBitValue matching the current state.
+ StatusBitValue? match = null;
+ foreach (var v in bit.Values)
+ {
+ if (v.State == currentState) { match = v; break; }
+ }
+ if (match == null || match.Reaction == 0) continue;
+
+ var key = (bit.Bit, currentState);
+ if (_statusReactionEdge.TryGetValue(key, out var fired) && fired)
+ continue;
+
+ // Clear any sibling (same bit, different state) entries so the next
+ // transition re-arms.
+ foreach (var v in bit.Values)
+ {
+ if (v.State != currentState)
+ _statusReactionEdge[(bit.Bit, v.State)] = false;
+ }
+
+ _statusReactionEdge[key] = true;
+ HandleStatusReaction(bit.Bit, match.Reaction, match.Description);
+ }
+ }
+
+ ///
+ /// Dispatches a status-bit reaction: 1 = abort (emergency stop),
+ /// 2 = warning (log + event), 3 = log-only (log + event).
+ ///
+ private void HandleStatusReaction(int bit, int reaction, string description)
+ {
+ switch (reaction)
+ {
+ case 1:
+ StatusReactionTriggered?.Invoke(bit, reaction, description);
+ PerformEmergencyStop($"Pump status bit {bit} ({description})");
+ break;
+ case 2:
+ _log.Warning(LogId, $"Status warning bit {bit}: {description}");
+ StatusReactionTriggered?.Invoke(bit, reaction, description);
+ break;
+ case 3:
+ _log.Warning(LogId, $"Status log bit {bit}: {description}");
+ StatusReactionTriggered?.Invoke(bit, reaction, description);
+ break;
+ }
+ }
+
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task WaitForParameter(
diff --git a/Services/Impl/ConfigurationService.cs b/Services/Impl/ConfigurationService.cs
index 315ccf8..933cef9 100644
--- a/Services/Impl/ConfigurationService.cs
+++ b/Services/Impl/ConfigurationService.cs
@@ -247,6 +247,24 @@ namespace HC_APTBS.Services.Impl
xpump.Add(xtests);
}
+ // ── Serialise (pre-injection pumps only) ──
+ if (pump.BipStatus != null && pump.BipStatus.Bits.Count > 0)
+ {
+ var xbip = new XElement("BipStatus");
+ for (int i = 0; i < pump.BipStatus.Bits.Count; i++)
+ {
+ var b = pump.BipStatus.Bits[i];
+ xbip.Add(new XElement("Bit",
+ new XAttribute("index", i.ToString(CultureInfo.InvariantCulture)),
+ new XAttribute("enabled", b.Enabled.ToString().ToLowerInvariant()),
+ new XAttribute("pattern", "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));
+ }
+ xpump.Add(xbip);
+ }
+
// ── Find existing pump by ID and replace, or append ──
XElement? existing = null;
foreach (var child in pumpsNode.Elements("Pump"))
@@ -395,9 +413,10 @@ namespace HC_APTBS.Services.Impl
{
bit.Values.Add(new StatusBitValue
{
- State = int.Parse(xVal.Attribute("value")?.Value ?? "0"),
+ State = int.Parse(xVal.Attribute("value")?.Value ?? "0", CultureInfo.InvariantCulture),
Color = xVal.Attribute("color")?.Value ?? "26C200",
- Description = xVal.Value.Trim()
+ Description = xVal.Value.Trim(),
+ Reaction = int.Parse(xVal.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture)
});
}
@@ -456,6 +475,15 @@ namespace HC_APTBS.Services.Impl
TryString(r, "Language", v => _settings.Language = v);
TryString(r, "Relations", v => _settings.Relations = RpmVoltageRelation.Deserialise(v));
TryString(r, "Users", v => _settings.Users = v);
+
+ // ── Bounds enforcement (see docs/gap-config-validation.md) ───────
+ _settings.RefreshCanBusReadMs = FloorWithLog(_settings.RefreshCanBusReadMs, 1, nameof(_settings.RefreshCanBusReadMs));
+ _settings.RefreshPumpParamsMs = FloorWithLog(_settings.RefreshPumpParamsMs, 1, nameof(_settings.RefreshPumpParamsMs));
+ _settings.SecurityRpmLimit = ClampWithLog(_settings.SecurityRpmLimit, 100, 5000, nameof(_settings.SecurityRpmLimit));
+ _settings.MaxPressureBar = ClampWithLog(_settings.MaxPressureBar, 1, 100, nameof(_settings.MaxPressureBar));
+ _settings.PidP = FloorWithLog(_settings.PidP, 0.0, nameof(_settings.PidP));
+ _settings.PidI = FloorWithLog(_settings.PidI, 0.0, nameof(_settings.PidI));
+ _settings.PidD = FloorWithLog(_settings.PidD, 0.0, nameof(_settings.PidD));
}
catch (Exception ex)
{
@@ -500,7 +528,18 @@ namespace HC_APTBS.Services.Impl
continue;
}
- var param = ParseParamElement(xe);
+ CanBusParameter? param;
+ try
+ {
+ param = ParseParamElement(xe);
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"Skipped malformed param '{xe.Name.LocalName}': {ex.Message}");
+ continue;
+ }
+ if (param == null) continue;
+
_bench.ParametersByName[param.Name] = param;
if (!_bench.ParametersById.TryGetValue(param.MessageId, out var list))
@@ -581,17 +620,27 @@ namespace HC_APTBS.Services.Impl
///
/// Parses a bench CAN parameter from an XML element.
/// Uses the clean factor/offset calibration model with explicit direction flags.
+ /// Returns null (and logs a warning) if byteh/bytel are outside 0-7.
///
- private static CanBusParameter ParseParamElement(XElement xe)
+ private CanBusParameter? ParseParamElement(XElement xe)
{
+ string name = xe.Name.LocalName;
+ ushort byteh = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0");
+ ushort bytel = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0");
+ if (byteh > 7 || bytel > 7)
+ {
+ _log.Warning(LogId, $"Rejected param '{name}': byteh={byteh} bytel={bytel} out of 0-7.");
+ return null;
+ }
+
string direction = xe.Attribute("direction")?.Value ?? "rx";
return new CanBusParameter
{
- Name = xe.Name.LocalName,
+ Name = name,
MessageId = Convert.ToUInt32(xe.Attribute("id")?.Value ?? "0", 16),
- ByteH = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0"),
- ByteL = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0"),
+ ByteH = byteh,
+ ByteL = bytel,
Alpha = CanBusParameter.ParseDecimal(xe.Attribute("filter")?.Value, 1.0),
IsReceive = string.Equals(direction, "rx", StringComparison.OrdinalIgnoreCase),
Factor = CanBusParameter.ParseDecimal(xe.Attribute("factor")?.Value, 1.0),
@@ -604,14 +653,22 @@ namespace HC_APTBS.Services.Impl
private void ParseRelayElement(XElement xr)
{
+ string name = xr.Attribute("name")?.Value ?? "";
+ int bit = int.Parse(xr.Attribute("bit")?.Value ?? "0");
+ if (bit < 0 || bit > 63)
+ {
+ _log.Warning(LogId, $"Rejected relay '{name}': bit={bit} out of 0-63.");
+ return;
+ }
+
var relay = new Relay(
- xr.Attribute("name")?.Value ?? "",
+ name,
Convert.ToUInt32(xr.Attribute("id")?.Value ?? "0", 16),
- int.Parse(xr.Attribute("bit")?.Value ?? "0"));
+ bit);
_bench!.Relays[relay.Name] = relay;
}
- private static PumpDefinition? ParsePumpElement(XElement xpump)
+ private PumpDefinition? ParsePumpElement(XElement xpump)
{
var pump = new PumpDefinition
{
@@ -646,7 +703,16 @@ namespace HC_APTBS.Services.Impl
{
foreach (var xe in xparams.Elements())
{
- var param = CanBusParameter.FromXml(xe);
+ CanBusParameter param;
+ try
+ {
+ param = CanBusParameter.FromXml(xe);
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"Skipped malformed pump param '{xe.Name.LocalName}' for pump '{pump.Id}': {ex.Message}");
+ continue;
+ }
pump.ParametersByName[param.Name] = param;
@@ -677,6 +743,44 @@ namespace HC_APTBS.Services.Impl
}
}
+ // ── Parse (optional — pre-injection pumps only) ───────────
+ var xbip = xpump.Element("BipStatus");
+ if (xbip != null)
+ {
+ var bipDef = new PumpBipDefinition();
+ foreach (var xbit in xbip.Elements("Bit"))
+ {
+ try
+ {
+ var patternStr = xbit.Attribute("pattern")?.Value ?? "0";
+ if (patternStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ patternStr = patternStr.Substring(2);
+
+ var sfStr = xbit.Attribute("specialFunction")?.Value ?? "9";
+ int specialFn;
+ if (sfStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ specialFn = int.Parse(sfStr.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ else
+ specialFn = int.Parse(sfStr, CultureInfo.InvariantCulture);
+
+ bipDef.Bits.Add(new BipStatusDefinition
+ {
+ Enabled = !string.Equals(xbit.Attribute("enabled")?.Value, "false",
+ StringComparison.OrdinalIgnoreCase),
+ HexPattern = ushort.Parse(patternStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture),
+ Reaction = int.Parse(xbit.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture),
+ SpecialFunction = specialFn,
+ Description = xbit.Value.Trim()
+ });
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"Skipped malformed in BipStatus for pump '{pump.Id}': {ex.Message}");
+ }
+ }
+ pump.BipStatus = bipDef;
+ }
+
return pump;
}
@@ -729,12 +833,15 @@ namespace HC_APTBS.Services.Impl
// ── XML parse helpers ─────────────────────────────────────────────────────
- private static void TryInt(XElement root, string name, Action assign)
+ private void TryInt(XElement root, string name, Action assign)
{
try { if (int.TryParse(root.Element(name)?.Value, out int v)) assign(v); }
- catch { /* ignore malformed XML */ }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
+ }
}
- private static void TryDouble(XElement root, string name, Action assign)
+ private void TryDouble(XElement root, string name, Action assign)
{
try
{
@@ -743,17 +850,56 @@ namespace HC_APTBS.Services.Impl
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v)) assign(v);
}
- catch { }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
+ }
}
- private static void TryBool(XElement root, string name, Action assign)
+ private void TryBool(XElement root, string name, Action assign)
{
try { if (bool.TryParse(root.Element(name)?.Value, out bool v)) assign(v); }
- catch { }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
+ }
}
- private static void TryString(XElement root, string name, Action assign)
+ private void TryString(XElement root, string name, Action assign)
{
try { var v = root.Element(name)?.Value; if (v != null) assign(v); }
- catch { }
+ catch (Exception ex)
+ {
+ _log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
+ }
+ }
+
+ ///
+ /// Clamps into [, ].
+ /// Logs a warning if clamping occurred.
+ ///
+ private T ClampWithLog(T value, T min, T max, string field) where T : IComparable
+ {
+ if (value.CompareTo(min) < 0)
+ {
+ _log.Warning(LogId, $"{field}={value} below min {min}, clamped.");
+ return min;
+ }
+ if (value.CompareTo(max) > 0)
+ {
+ _log.Warning(LogId, $"{field}={value} above max {max}, clamped.");
+ return max;
+ }
+ return value;
+ }
+
+ /// Floors to , logging if floored.
+ private T FloorWithLog(T value, T min, string field) where T : IComparable
+ {
+ if (value.CompareTo(min) < 0)
+ {
+ _log.Warning(LogId, $"{field}={value} below min {min}, floored.");
+ return min;
+ }
+ return value;
}
// ── Users (PBKDF2-HMAC-SHA256 hashed credentials) ─────────────────────────
@@ -875,6 +1021,106 @@ namespace HC_APTBS.Services.Impl
Settings.Users = string.Join(",", entries);
SaveSettings();
}
+
+ ///
+ public bool AddUser(string username, string password)
+ {
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
+ {
+ _log.Warning(LogId, "AddUser rejected: empty username or password.");
+ return false;
+ }
+ if (username.IndexOfAny(new[] { ':', ',' }) >= 0)
+ {
+ _log.Warning(LogId, $"AddUser rejected: username '{username}' contains reserved separator character.");
+ return false;
+ }
+
+ var entries = ParseUserEntries();
+ if (entries.Any(e => e.user == username))
+ {
+ _log.Warning(LogId, $"AddUser rejected: user '{username}' already exists.");
+ return false;
+ }
+
+ var (salt, hash) = HashPassword(password);
+ entries.Add((username, salt, hash));
+ Settings.Users = FormatUserEntries(entries);
+ SaveSettings();
+ _log.Info(LogId, $"Added user '{username}'.");
+ return true;
+ }
+
+ ///
+ public bool RemoveUser(string username)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ return false;
+
+ var entries = ParseUserEntries();
+ if (entries.Count <= 1)
+ {
+ _log.Warning(LogId, $"RemoveUser rejected: cannot remove '{username}' — at least one user must remain.");
+ return false;
+ }
+
+ int idx = entries.FindIndex(e => e.user == username);
+ if (idx < 0)
+ {
+ _log.Warning(LogId, $"RemoveUser rejected: user '{username}' does not exist.");
+ return false;
+ }
+
+ entries.RemoveAt(idx);
+ Settings.Users = FormatUserEntries(entries);
+ SaveSettings();
+ _log.Info(LogId, $"Removed user '{username}'.");
+ return true;
+ }
+
+ ///
+ public bool ChangeUserPassword(string username, string newPassword)
+ {
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(newPassword))
+ {
+ _log.Warning(LogId, "ChangeUserPassword rejected: empty username or password.");
+ return false;
+ }
+
+ var entries = ParseUserEntries();
+ int idx = entries.FindIndex(e => e.user == username);
+ if (idx < 0)
+ {
+ _log.Warning(LogId, $"ChangeUserPassword rejected: user '{username}' does not exist.");
+ return false;
+ }
+
+ var (salt, hash) = HashPassword(newPassword);
+ entries[idx] = (username, salt, hash);
+ Settings.Users = FormatUserEntries(entries);
+ SaveSettings();
+ _log.Info(LogId, $"Changed password for user '{username}'.");
+ return true;
+ }
+
+ /// Parses into a list of (user, salt, hash) tuples, skipping malformed entries.
+ private List<(string user, string salt, string hash)> ParseUserEntries()
+ {
+ var list = new List<(string, string, string)>();
+ if (string.IsNullOrEmpty(Settings.Users)) return list;
+
+ foreach (string entry in Settings.Users.Split(','))
+ {
+ string[] parts = entry.Split(':');
+ if (parts.Length == 3 && parts[0].Length > 0)
+ list.Add((parts[0], parts[1], parts[2]));
+ }
+ return list;
+ }
+
+ /// Serialises a list of (user, salt, hash) tuples back to the comma-separated storage format.
+ private static string FormatUserEntries(List<(string user, string salt, string hash)> entries)
+ => string.Join(",", entries.Select(e => $"{e.user}:{e.salt}:{e.hash}"));
}
// ── XPath extension shim ──────────────────────────────────────────────────────
diff --git a/Services/Impl/PdfService.cs b/Services/Impl/PdfService.cs
index 7e20c69..0e6a9f2 100644
--- a/Services/Impl/PdfService.cs
+++ b/Services/Impl/PdfService.cs
@@ -59,7 +59,9 @@ namespace HC_APTBS.Services.Impl
PumpDefinition pump,
string operatorName,
string clientName,
- string outputFolder)
+ string outputFolder,
+ string? clientInfo = null,
+ string? observations = null)
{
Directory.CreateDirectory(outputFolder);
@@ -77,8 +79,8 @@ namespace HC_APTBS.Services.Impl
page.Margin(25, Unit.Millimetre);
page.DefaultTextStyle(x => x.FontSize(ReportTheme.BodySize).FontFamily(Fonts.Arial));
- page.Header().Element(c => ComposeHeader(c, operatorName, clientName, reportDate));
- page.Content().Element(c => ComposeContent(c, pump));
+ page.Header().Element(c => ComposeHeader(c, operatorName, clientName, clientInfo, reportDate));
+ page.Content().Element(c => ComposeContent(c, pump, observations));
page.Footer().Element(ComposeFooter);
});
});
@@ -91,7 +93,7 @@ namespace HC_APTBS.Services.Impl
/// Renders the page header: logo, company info, date/operator/client, title.
private void ComposeHeader(
- IContainer container, string operatorName, string clientName, DateTime reportDate)
+ IContainer container, string operatorName, string clientName, string? clientInfo, DateTime reportDate)
{
container.Column(outer =>
{
@@ -130,6 +132,12 @@ namespace HC_APTBS.Services.Impl
.FontSize(ReportTheme.CaptionSize + 1);
col.Item().Text(string.Format(_loc.GetString("Pdf.Client"), clientName))
.FontSize(ReportTheme.CaptionSize + 1).Bold();
+
+ // Optional multi-line client address/contact info.
+ if (!string.IsNullOrWhiteSpace(clientInfo))
+ col.Item().Text(clientInfo)
+ .FontSize(ReportTheme.CaptionSize)
+ .FontColor(ReportTheme.HeaderGrey);
});
});
@@ -174,8 +182,8 @@ namespace HC_APTBS.Services.Impl
// ── Content ───────────────────────────────────────────────────────────────
- /// Composes the full report body: pump info, ECU data, verdict, test sections.
- private void ComposeContent(IContainer container, PumpDefinition pump)
+ /// Composes the full report body: pump info, ECU data, verdict, test sections, observations.
+ private void ComposeContent(IContainer container, PumpDefinition pump, string? observations)
{
container.PaddingTop(6).Column(col =>
{
@@ -199,6 +207,11 @@ namespace HC_APTBS.Services.Impl
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposeTestSection(c, test));
}
+
+ // ── Operator observations (free-text) ────────────────────────────
+ if (!string.IsNullOrWhiteSpace(observations))
+ col.Item().PaddingBottom(ReportTheme.SectionGap)
+ .Element(c => ComposeObservationsSection(c, observations!));
});
}
@@ -383,6 +396,31 @@ namespace HC_APTBS.Services.Impl
});
}
+ // ── Observations section ──────────────────────────────────────────────────
+
+ /// Renders a free-text "Observations" block at the bottom of the report.
+ private void ComposeObservationsSection(IContainer container, string observations)
+ {
+ container.Column(col =>
+ {
+ // Section header bar — matches the navy style used by test sections.
+ col.Item()
+ .Background(ReportTheme.HeaderNavy)
+ .Padding(5)
+ .Text(_loc.GetString("Pdf.Observations"))
+ .FontColor(Colors.White)
+ .Bold().FontSize(ReportTheme.SectionHeaderSize);
+
+ // Bordered text block — matches the verdict block visual treatment.
+ col.Item()
+ .Border(1).BorderColor(ReportTheme.DividerLine)
+ .Background(ReportTheme.TableAltRow)
+ .Padding(8)
+ .Text(observations)
+ .FontSize(ReportTheme.BodySize);
+ });
+ }
+
// ── Test results section ──────────────────────────────────────────────────
/// Renders a single test: results table followed by measurement charts.
diff --git a/ViewModels/AuthGateViewModel.cs b/ViewModels/AuthGateViewModel.cs
new file mode 100644
index 0000000..bcbbdda
--- /dev/null
+++ b/ViewModels/AuthGateViewModel.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Services;
+using HC_APTBS.ViewModels.Dialogs;
+using HC_APTBS.Views.Dialogs;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// ViewModel for the AuthGateView wrapper control that hides write-capable
+ /// content behind an operator authentication dialog.
+ ///
+ /// Used by the Pump page on the Adaptation and Unlock sub-sections per
+ /// docs/ui-structure.md §3.d/§3.e (write actions require authentication).
+ ///
+ public sealed partial class AuthGateViewModel : ObservableObject
+ {
+ private readonly IConfigurationService _config;
+ private readonly ILocalizationService _loc;
+
+ /// Initialises the gate; starts in the locked state.
+ public AuthGateViewModel(IConfigurationService config, ILocalizationService loc)
+ {
+ _config = config;
+ _loc = loc;
+ }
+
+ // ── State ─────────────────────────────────────────────────────────────────
+
+ /// True once the operator has successfully authenticated.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AuthenticateCommand))]
+ [NotifyCanExecuteChangedFor(nameof(LockCommand))]
+ private bool _isAuthenticated;
+
+ /// The currently authenticated username (empty when locked).
+ [ObservableProperty] private string _authenticatedUser = string.Empty;
+
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Opens the UserCheck dialog; flips to authenticated on success.
+ [RelayCommand(CanExecute = nameof(CanAuthenticate))]
+ private void Authenticate()
+ {
+ var vm = new UserCheckViewModel(_config, _loc, AuthenticatedUser);
+ var dlg = new UserCheckDialog(vm) { Owner = Application.Current.MainWindow };
+ dlg.ShowDialog();
+ if (!vm.Accepted) return;
+
+ AuthenticatedUser = vm.AuthenticatedUser;
+ IsAuthenticated = true;
+ }
+
+ private bool CanAuthenticate() => !IsAuthenticated;
+
+ /// Re-locks the gate (e.g. after finishing a sensitive operation).
+ [RelayCommand(CanExecute = nameof(CanLock))]
+ private void Lock()
+ {
+ IsAuthenticated = false;
+ AuthenticatedUser = string.Empty;
+ }
+
+ private bool CanLock() => IsAuthenticated;
+ }
+}
diff --git a/ViewModels/BenchControlViewModel.cs b/ViewModels/BenchControlViewModel.cs
index bc4f405..9205aa6 100644
--- a/ViewModels/BenchControlViewModel.cs
+++ b/ViewModels/BenchControlViewModel.cs
@@ -70,10 +70,11 @@ namespace HC_APTBS.ViewModels
_config = configService;
_bench.RpmCommandSent += () =>
- {
- TargetRpm = _bench.LastTargetRpm;
- CommandVoltage = _bench.LastCommandVoltage;
- };
+ Application.Current.Dispatcher.Invoke(() =>
+ {
+ TargetRpm = _bench.LastTargetRpm;
+ CommandVoltage = _bench.LastCommandVoltage;
+ });
}
// ── Direction toggle ──────────────────────────────────────────────────────
diff --git a/ViewModels/DashboardAlarmsViewModel.cs b/ViewModels/DashboardAlarmsViewModel.cs
new file mode 100644
index 0000000..3602871
--- /dev/null
+++ b/ViewModels/DashboardAlarmsViewModel.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using HC_APTBS.Models;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// Dashboard-side alarm aggregator.
+ ///
+ /// Keeps an collection in sync with the
+ /// bench Alarms CAN bitmask, resolving each set bit against the
+ /// alarm definitions loaded from alarms.xml. The consumer (MainViewModel's
+ /// refresh tick) calls every refresh interval.
+ ///
+ public sealed partial class DashboardAlarmsViewModel : ObservableObject
+ {
+ private readonly IReadOnlyList _definitions;
+ private int _lastMask = -1;
+
+ /// List of currently asserted alarms.
+ public ObservableCollection ActiveAlarms { get; } = new();
+
+ /// True when no alarms are active — used to show the "System OK" banner.
+ [ObservableProperty] private bool _isClear = true;
+
+ /// True when at least one active alarm is flagged critical.
+ [ObservableProperty] private bool _hasCritical;
+
+ public DashboardAlarmsViewModel(IReadOnlyList definitions)
+ {
+ _definitions = definitions;
+ }
+
+ ///
+ /// Rebuilds from the current bench alarm bitmask.
+ /// Cheap no-op when the mask has not changed since the last call.
+ /// Must be invoked on the UI thread.
+ ///
+ public void Update(int mask)
+ {
+ if (mask == _lastMask) return;
+ _lastMask = mask;
+
+ ActiveAlarms.Clear();
+ bool anyCritical = false;
+
+ foreach (var def in _definitions)
+ {
+ bool bitSet = (mask & (1 << def.Bit)) != 0;
+ def.IsActive = bitSet;
+ if (!bitSet) continue;
+
+ ActiveAlarms.Add(def);
+ if (def.IsCritical) anyCritical = true;
+ }
+
+ IsClear = ActiveAlarms.Count == 0;
+ HasCritical = anyCritical;
+ }
+ }
+}
diff --git a/ViewModels/Dialogs/ConfirmDialogViewModel.cs b/ViewModels/Dialogs/ConfirmDialogViewModel.cs
new file mode 100644
index 0000000..da5250e
--- /dev/null
+++ b/ViewModels/Dialogs/ConfirmDialogViewModel.cs
@@ -0,0 +1,51 @@
+using System;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace HC_APTBS.ViewModels.Dialogs
+{
+ ///
+ /// Generic Yes/No (or Confirm/Cancel) modal dialog view-model.
+ ///
+ /// Reusable across abort-test, skip-phase, delete-pump, and any future
+ /// binary decision prompt. Caller sets , ,
+ /// , before showing the dialog,
+ /// then inspects after the dialog closes.
+ ///
+ public sealed partial class ConfirmDialogViewModel : ObservableObject
+ {
+ /// Window title.
+ [ObservableProperty] private string _title = string.Empty;
+
+ /// Body message — may be multi-line.
+ [ObservableProperty] private string _message = string.Empty;
+
+ /// Text shown on the positive-action button (defaults to a localised "OK").
+ [ObservableProperty] private string _confirmText = "OK";
+
+ /// Text shown on the cancel button (defaults to a localised "Cancel").
+ [ObservableProperty] private string _cancelText = "Cancel";
+
+ /// True when the operator clicked ; false on cancel/close.
+ public bool Accepted { get; private set; }
+
+ /// Raised when the dialog should close itself.
+ public event Action? RequestClose;
+
+ /// Accepts the prompt — sets to true and closes.
+ [RelayCommand]
+ private void Confirm()
+ {
+ Accepted = true;
+ RequestClose?.Invoke();
+ }
+
+ /// Cancels the prompt — leaves false and closes.
+ [RelayCommand]
+ private void Cancel()
+ {
+ Accepted = false;
+ RequestClose?.Invoke();
+ }
+ }
+}
diff --git a/ViewModels/Dialogs/UserManageViewModel.cs b/ViewModels/Dialogs/UserManageViewModel.cs
new file mode 100644
index 0000000..536ca57
--- /dev/null
+++ b/ViewModels/Dialogs/UserManageViewModel.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Services;
+using HC_APTBS.Views.Dialogs;
+
+namespace HC_APTBS.ViewModels.Dialogs
+{
+ ///
+ /// ViewModel for the user management dialog.
+ /// Invokes incremental methods on
+ /// (AddUser, RemoveUser, ChangeUserPassword) so that
+ /// hashes of untouched accounts are preserved. Each action persists immediately;
+ /// the dialog has no Accept/Cancel split — Close simply dismisses the window.
+ ///
+ public sealed partial class UserManageViewModel : ObservableObject
+ {
+ private readonly IConfigurationService _config;
+ private readonly ILocalizationService _loc;
+
+ /// Creates the ViewModel and populates from the service.
+ public UserManageViewModel(IConfigurationService config, ILocalizationService loc)
+ {
+ _config = config;
+ _loc = loc;
+ ReloadUsers();
+ }
+
+ // ── Bindable state ────────────────────────────────────────────────────
+
+ /// Usernames currently stored in the configuration.
+ public ObservableCollection Users { get; } = new();
+
+ /// Currently selected username in the DataGrid, or null when nothing is selected.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(RemoveCommand))]
+ [NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
+ private string? _selectedUser;
+
+ /// Raised to close the owning dialog window.
+ public event Action? RequestClose;
+
+ // ── Commands ──────────────────────────────────────────────────────────
+
+ /// Prompts for a username and password, then adds the user via the service.
+ [RelayCommand]
+ private void Add()
+ {
+ var prompt = new UserPromptDialog(
+ _loc.GetString("Dialog.UserManage.Prompt.AddTitle"),
+ usernameVisible: true)
+ {
+ Owner = Application.Current?.Windows.Count > 0
+ ? GetActiveWindow()
+ : null
+ };
+
+ if (prompt.ShowDialog() != true)
+ return;
+
+ string username = prompt.EnteredUsername?.Trim() ?? string.Empty;
+ string password = prompt.EnteredPassword ?? string.Empty;
+
+ if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
+ {
+ ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
+ return;
+ }
+ if (username.IndexOfAny(new[] { ':', ',' }) >= 0)
+ {
+ ShowError("Dialog.UserManage.Error.InvalidChars", "Dialog.UserManage.Error.InvalidCharsTitle");
+ return;
+ }
+
+ if (!_config.AddUser(username, password))
+ {
+ ShowError("Dialog.UserManage.Error.Duplicate", "Dialog.UserManage.Error.DuplicateTitle");
+ return;
+ }
+
+ ReloadUsers();
+ SelectedUser = username;
+ }
+
+ /// Removes the selected user after confirmation, refusing when only one user remains.
+ [RelayCommand(CanExecute = nameof(HasSelection))]
+ private void Remove()
+ {
+ string user = SelectedUser!;
+ var confirm = MessageBox.Show(
+ string.Format(_loc.GetString("Dialog.UserManage.Confirm.Remove"), user),
+ _loc.GetString("Dialog.UserManage.Confirm.RemoveTitle"),
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (confirm != MessageBoxResult.Yes)
+ return;
+
+ if (!_config.RemoveUser(user))
+ {
+ ShowError("Dialog.UserManage.Error.LastUser", "Dialog.UserManage.Error.LastUserTitle");
+ return;
+ }
+
+ ReloadUsers();
+ }
+
+ /// Prompts for a new password for the selected user and applies it.
+ [RelayCommand(CanExecute = nameof(HasSelection))]
+ private void ChangePassword()
+ {
+ string user = SelectedUser!;
+
+ var prompt = new UserPromptDialog(
+ string.Format(_loc.GetString("Dialog.UserManage.Prompt.ChangeTitle"), user),
+ usernameVisible: false,
+ prefillUsername: user)
+ {
+ Owner = GetActiveWindow()
+ };
+
+ if (prompt.ShowDialog() != true)
+ return;
+
+ string newPassword = prompt.EnteredPassword ?? string.Empty;
+ if (string.IsNullOrEmpty(newPassword))
+ {
+ ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
+ return;
+ }
+
+ if (!_config.ChangeUserPassword(user, newPassword))
+ {
+ ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
+ return;
+ }
+ }
+
+ /// Closes the dialog.
+ [RelayCommand]
+ private void Close() => RequestClose?.Invoke();
+
+ // ── Helpers ───────────────────────────────────────────────────────────
+
+ private bool HasSelection() => !string.IsNullOrEmpty(SelectedUser);
+
+ /// Reloads the user list from the service, preserving selection when possible.
+ private void ReloadUsers()
+ {
+ string? previous = SelectedUser;
+ Users.Clear();
+ foreach (var name in _config.GetUsers())
+ Users.Add(name);
+
+ SelectedUser = (previous != null && Users.Contains(previous)) ? previous : null;
+ }
+
+ private void ShowError(string messageKey, string titleKey)
+ {
+ MessageBox.Show(
+ _loc.GetString(messageKey),
+ _loc.GetString(titleKey),
+ MessageBoxButton.OK,
+ MessageBoxImage.Stop);
+ }
+
+ /// Returns the topmost active window for use as a dialog owner, or null if none.
+ private static Window? GetActiveWindow()
+ {
+ foreach (Window w in Application.Current.Windows)
+ {
+ if (w.IsActive) return w;
+ }
+ return Application.Current.MainWindow;
+ }
+ }
+}
diff --git a/ViewModels/DtcListViewModel.cs b/ViewModels/DtcListViewModel.cs
new file mode 100644
index 0000000..60c2318
--- /dev/null
+++ b/ViewModels/DtcListViewModel.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// ViewModel for the Pump page §3.b DTC list (Diagnostic Trouble Codes).
+ ///
+ /// Exposes a list of fault-code lines parsed from
+ /// , with read/clear commands.
+ /// Each line is surfaced as a structured so the UI
+ /// can render them as rows rather than a raw blob.
+ ///
+ public sealed partial class DtcListViewModel : ObservableObject
+ {
+ private readonly IKwpService _kwp;
+ private readonly ILocalizationService _loc;
+ private readonly IAppLogger _log;
+ private const string LogId = "DtcListVM";
+
+ /// Initialises the ViewModel with the required services.
+ public DtcListViewModel(IKwpService kwp, ILocalizationService loc, IAppLogger log)
+ {
+ _kwp = kwp;
+ _loc = loc;
+ _log = log;
+ }
+
+ // ── State ─────────────────────────────────────────────────────────────────
+
+ /// Parsed fault-code entries, one per line returned from K-Line.
+ public ObservableCollection Codes { get; } = new();
+
+ /// True while a read or clear operation is in progress.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ReadCommand))]
+ [NotifyCanExecuteChangedFor(nameof(ClearCommand))]
+ private bool _isBusy;
+
+ /// True when the last read returned no fault codes.
+ [ObservableProperty] private bool _isClear;
+
+ /// Status text shown above the list (empty, error, or "last read at …").
+ [ObservableProperty] private string _statusText = string.Empty;
+
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Reads the current DTCs from the ECU over K-Line.
+ [RelayCommand(CanExecute = nameof(CanOperate))]
+ private async Task ReadAsync()
+ {
+ var port = _kwp.DetectKLinePort();
+ if (string.IsNullOrEmpty(port))
+ {
+ StatusText = _loc.GetString("Error.KLineNotFound");
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ string raw = await _kwp.ReadFaultCodesAsync(port);
+ ApplyRawText(raw);
+ StatusText = string.Format(_loc.GetString("Dtc.LastRead"), DateTime.Now);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"ReadAsync: {ex.Message}");
+ StatusText = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ /// Clears all DTCs on the ECU and refreshes the list.
+ [RelayCommand(CanExecute = nameof(CanOperate))]
+ private async Task ClearAsync()
+ {
+ var port = _kwp.DetectKLinePort();
+ if (string.IsNullOrEmpty(port))
+ {
+ StatusText = _loc.GetString("Error.KLineNotFound");
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ string raw = await _kwp.ClearFaultCodesAsync(port);
+ ApplyRawText(raw);
+ StatusText = _loc.GetString("Dtc.Cleared");
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"ClearAsync: {ex.Message}");
+ StatusText = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private bool CanOperate() => !IsBusy;
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Populates from the raw K-Line fault-code string.
+ /// Handles the special "No fault codes" response by setting .
+ ///
+ private void ApplyRawText(string raw)
+ {
+ Application.Current.Dispatcher.Invoke(() =>
+ {
+ Codes.Clear();
+
+ if (string.IsNullOrWhiteSpace(raw)
+ || raw.Contains("No fault", StringComparison.OrdinalIgnoreCase))
+ {
+ IsClear = true;
+ return;
+ }
+
+ IsClear = false;
+ foreach (var line in raw.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ string trimmed = line.Trim();
+ if (trimmed.Length == 0) continue;
+ Codes.Add(DtcEntry.Parse(trimmed));
+ }
+ });
+ }
+
+ /// Clears any cached DTCs (used when the pump selection changes).
+ public void Reset()
+ {
+ Application.Current.Dispatcher.Invoke(() =>
+ {
+ Codes.Clear();
+ IsClear = false;
+ StatusText = string.Empty;
+ });
+ }
+ }
+
+ ///
+ /// A single diagnostic trouble code row. Split into and
+ /// when the raw line follows the usual "CODE — text"
+ /// layout, otherwise the raw text is surfaced in .
+ ///
+ public sealed class DtcEntry
+ {
+ /// DTC code identifier (e.g. "P1688").
+ public string Code { get; set; } = string.Empty;
+
+ /// Human-readable description text.
+ public string Description { get; set; } = string.Empty;
+
+ /// Parses one line of K-Line fault-code output into a structured entry.
+ public static DtcEntry Parse(string line)
+ {
+ // Common K-Line formats: "P1234 — Description", "P1234: Description",
+ // "P1234 Description", or just raw text without a leading code.
+ int split = -1;
+ foreach (char sep in new[] { '—', '-', ':', '\t' })
+ {
+ split = line.IndexOf(sep);
+ if (split > 0) break;
+ }
+
+ if (split <= 0 || split > 10)
+ return new DtcEntry { Description = line };
+
+ return new DtcEntry
+ {
+ Code = line[..split].Trim(),
+ Description = line[(split + 1)..].Trim()
+ };
+ }
+ }
+}
diff --git a/ViewModels/InterlockBannerViewModel.cs b/ViewModels/InterlockBannerViewModel.cs
new file mode 100644
index 0000000..051126e
--- /dev/null
+++ b/ViewModels/InterlockBannerViewModel.cs
@@ -0,0 +1,95 @@
+using System;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// ViewModel for the bench-page interlock banner.
+ /// Surfaces two soft safety warnings inline (dismissible), matching the
+ /// Bench page guideline in docs/ui-structure.md §2:
+ ///
+ /// - Oil pump off while RPM > threshold.
+ /// - RPM above configured AppSettings.MaxRpm safety limit.
+ ///
+ ///
+ public sealed partial class InterlockBannerViewModel : ObservableObject
+ {
+ private const double OilPumpRpmThreshold = 300.0;
+
+ private readonly IConfigurationService _config;
+
+ /// Text shown in the banner. Empty string hides the banner.
+ [ObservableProperty] private string _message = string.Empty;
+
+ /// True when the banner is currently shown.
+ [ObservableProperty] private bool _isVisible;
+
+ /// True when the banner is showing a critical warning (red background).
+ [ObservableProperty] private bool _isCritical;
+
+ private bool _dismissed;
+ private int _dismissedFor; // Bit flags: 1=oil, 2=rpm
+
+ /// Configuration service (source of MaxRpm).
+ public InterlockBannerViewModel(IConfigurationService configService)
+ {
+ _config = configService;
+ }
+
+ ///
+ /// Recomputes the banner message from live bench state.
+ /// Called from the page's refresh tick handler.
+ ///
+ /// Current bench motor RPM.
+ /// True when the oil pump relay is energised.
+ public void Update(double benchRpm, bool isOilPumpOn)
+ {
+ int maxRpm = Math.Max(1, _config.Settings.MaxRpm);
+
+ bool oilWarn = !isOilPumpOn && benchRpm > OilPumpRpmThreshold;
+ bool rpmWarn = benchRpm > maxRpm;
+
+ int activeFlags = (oilWarn ? 1 : 0) | (rpmWarn ? 2 : 0);
+
+ // Reset dismissal when the condition actually clears.
+ if (activeFlags == 0)
+ {
+ _dismissed = false;
+ _dismissedFor = 0;
+ Message = string.Empty;
+ IsCritical = false;
+ IsVisible = false;
+ return;
+ }
+
+ // If the operator already dismissed this exact condition set, stay hidden.
+ if (_dismissed && _dismissedFor == activeFlags)
+ return;
+
+ _dismissed = false;
+
+ if (rpmWarn)
+ {
+ Message = $"RPM {benchRpm:F0} exceeds safety limit ({maxRpm}).";
+ IsCritical = true;
+ }
+ else
+ {
+ Message = $"Oil pump is OFF while bench is running at {benchRpm:F0} RPM.";
+ IsCritical = false;
+ }
+ IsVisible = true;
+ _dismissedFor = activeFlags;
+ }
+
+ /// Hides the banner until the underlying condition changes.
+ [RelayCommand]
+ private void Dismiss()
+ {
+ _dismissed = true;
+ IsVisible = false;
+ }
+ }
+}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 274d317..9109b67 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -10,10 +10,28 @@ using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
+using HC_APTBS.ViewModels.Pages;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels
{
+ /// Identifies the top-level navigation page shown in the shell.
+ public enum AppPage
+ {
+ /// Bench controls, flowmeter charts, encoder angles.
+ Bench = 0,
+ /// Pump manual control, DFI, status displays.
+ Pump = 1,
+ /// Test suite, live progress, results.
+ Tests = 2,
+ /// At-a-glance operator landing page: readings, connections, alarms, quick actions.
+ Dashboard = 3,
+ /// 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
+ }
+
///
/// Root ViewModel for the application's main window.
///
@@ -55,6 +73,25 @@ namespace HC_APTBS.ViewModels
/// ViewModel for the non-modal unlock progress window.
private UnlockProgressViewModel? _unlockVm;
+ ///
+ /// Publicly observable accessor for the currently running (or last completed)
+ /// immobilizer unlock VM. Used by the Pump page's inline unlock panel to
+ /// display the same state that the floating dialog shows. Null while no
+ /// unlock has been started for the current pump.
+ ///
+ public UnlockProgressViewModel? CurrentUnlockVm
+ {
+ get => _unlockVm;
+ private set
+ {
+ if (!ReferenceEquals(_unlockVm, value))
+ {
+ _unlockVm = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
/// The non-modal unlock progress window, if open.
private UnlockProgressDialog? _unlockDlg;
@@ -96,6 +133,40 @@ namespace HC_APTBS.ViewModels
/// ViewModel for the second pump status display (Empf3 word).
public StatusDisplayViewModel StatusDisplay2 { get; } = new();
+ /// ViewModel for the Dashboard's active-alarm list.
+ public DashboardAlarmsViewModel DashboardAlarms { get; }
+
+ /// Diagnostic Trouble Code list for the Pump page §3.b sub-section.
+ public DtcListViewModel DtcList { get; }
+
+ /// Auth gate for the Pump page §3.d Adaptation sub-section.
+ public AuthGateViewModel AdaptationAuth { get; }
+
+ // ── Page ViewModels (thin façades over the child VMs above) ───────────────
+
+ /// Dashboard navigation page VM.
+ public DashboardPageViewModel DashboardPage { get; private set; } = null!;
+
+ /// Bench navigation page VM.
+ public BenchPageViewModel BenchPage { get; private set; } = null!;
+
+ /// Pump navigation page VM.
+ public PumpPageViewModel PumpPage { get; private set; } = null!;
+
+ /// Tests navigation page VM.
+ public TestsPageViewModel TestsPage { get; private set; } = null!;
+
+ /// Settings navigation page VM.
+ public SettingsPageViewModel SettingsPage { get; private set; } = null!;
+
+ /// Results navigation page VM (session-only test-run history).
+ public ResultsPageViewModel ResultsPage { get; private set; } = null!;
+
+ // ── Navigation state ──────────────────────────────────────────────────────
+
+ /// Currently selected top-level navigation page.
+ [ObservableProperty] private AppPage _selectedPage = AppPage.Dashboard;
+
// ── Constructor ───────────────────────────────────────────────────────────
///
@@ -130,6 +201,20 @@ namespace HC_APTBS.ViewModels
PumpControl = new PumpControlViewModel(benchService);
BenchControl = new BenchControlViewModel(benchService, configService);
AngleDisplay = new AngleDisplayViewModel(configService);
+ DashboardAlarms = new DashboardAlarmsViewModel(configService.Settings.Alarms);
+ DtcList = new DtcListViewModel(kwpService, localizationService, logger);
+ AdaptationAuth = new AuthGateViewModel(configService, 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}.
+ DashboardPage = new DashboardPageViewModel(this);
+ BenchPage = new BenchPageViewModel(this, benchService, configService);
+ PumpPage = new PumpPageViewModel(this, DtcList, AdaptationAuth);
+ TestsPage = new TestsPageViewModel(this, configService, localizationService);
+ SettingsPage = new SettingsPageViewModel(configService, localizationService);
+ SettingsPage.SettingsSaved += OnSettingsSaved;
+ ResultsPage = new ResultsPageViewModel(this, pdfService, configService, localizationService, logger);
// React to pump changes from the identification child VM.
PumpIdentification.PumpChanged += OnPumpChanged;
@@ -163,7 +248,12 @@ namespace HC_APTBS.ViewModels
{
CurrentPhaseName = phase;
TestPanel.SetActivePhase(phase);
+ // Clear real-time plot traces at each new phase boundary.
+ FlowmeterChart.Delivery.Clear();
+ FlowmeterChart.Over.Clear();
});
+ _bench.PhaseTimerTick += (section, remaining, total) => App.Current.Dispatcher.Invoke(
+ () => TestPanel.ApplyPhaseTimerTick(section, remaining, total));
_bench.VerboseMessage += msg => App.Current.Dispatcher.Invoke(() =>
{
VerboseStatus = msg;
@@ -191,6 +281,10 @@ namespace HC_APTBS.ViewModels
{
VerboseStatus = string.Format(_loc.GetString("Error.EmergencyStop"), reason);
});
+ _bench.StatusReactionTriggered += (bit, reaction, desc) => App.Current.Dispatcher.Invoke(() =>
+ {
+ VerboseStatus = $"[STATUS] bit {bit} reaction={reaction}: {desc}";
+ });
// Angle display: lock angle and PSG zero from test phases
_bench.LockAngleFaseReady += () => App.Current.Dispatcher.Invoke(() =>
@@ -281,8 +375,8 @@ namespace HC_APTBS.ViewModels
if (pump.UnlockType == 0) return;
_unlockCts = new CancellationTokenSource();
- _unlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
- _unlockDlg = new UnlockProgressDialog(_unlockVm)
+ CurrentUnlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
+ _unlockDlg = new UnlockProgressDialog(_unlockVm!)
{ Owner = Application.Current.MainWindow };
// Start unlock in background — ViewModel tracks via event subscriptions.
@@ -312,7 +406,7 @@ namespace HC_APTBS.ViewModels
if (_unlockVm != null)
{
_unlockVm.Dispose();
- _unlockVm = null;
+ CurrentUnlockVm = null;
}
if (_unlockDlg != null)
@@ -394,6 +488,13 @@ namespace HC_APTBS.ViewModels
/// PSG encoder position value.
[ObservableProperty] private double _psgEncoderValue;
+ ///
+ /// True when the Oil Pump relay is currently energised. Mirrored on each refresh
+ /// tick from _config.Bench.Relays[RelayNames.OilPump] so the Tests page
+ /// preconditions checklist can bind to it without walking the relay dictionary.
+ ///
+ [ObservableProperty] private bool _isOilPumpOn;
+
// ── Pump live readings (from pump CAN parameters) ──────────────────────────
/// Pump RPM reported by the ECU over CAN.
@@ -489,6 +590,17 @@ namespace HC_APTBS.ViewModels
private bool CanStopTest() => IsTestRunning;
+ ///
+ /// Operator-initiated emergency stop from the Dashboard.
+ /// Zeros the motor, zeros pump parameters, and cancels any running test.
+ ///
+ [RelayCommand]
+ private void EmergencyStop()
+ {
+ _bench.RequestEmergencyStop("Operator pressed E-Stop on Dashboard");
+ _testCts?.Cancel();
+ }
+
// ── Commands: relay toggles ───────────────────────────────────────────────
/// Toggles the electronic relay (pump solenoid power).
@@ -534,7 +646,12 @@ namespace HC_APTBS.ViewModels
{
string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
string path = _pdf.GenerateReport(
- CurrentPump, reportVm.OperatorName, reportVm.SelectedClientName, desktop);
+ CurrentPump,
+ reportVm.OperatorName,
+ reportVm.SelectedClientName,
+ desktop,
+ clientInfo: reportVm.ClientInfo,
+ observations: reportVm.Observations);
_log.Info(LogId, $"Report saved: {path}");
IsTestSaved = true;
@@ -552,15 +669,6 @@ namespace HC_APTBS.ViewModels
private bool CanGenerateReport()
=> CurrentPump != null && !IsTestRunning && CurrentPump.Tests.Count > 0;
- // ── Commands: language toggle ──────────────────────────────────────────────
-
- /// Toggles the UI language between Spanish and English.
- [RelayCommand]
- private void ToggleLanguage()
- {
- _loc.SetLanguage(_loc.CurrentLanguage == "ESP" ? "ENG" : "ESP");
- }
-
/// Refreshes all ViewModel-cached localised strings after a language change.
private void RefreshLocalisedStrings()
{
@@ -569,17 +677,13 @@ namespace HC_APTBS.ViewModels
: _loc.GetString("Status.Disconnected");
}
- // ── Commands: settings ────────────────────────────────────────────────────
-
- /// Opens the settings dialog for editing application configuration.
- [RelayCommand]
- private void OpenSettings()
+ ///
+ /// Reseeds settings-dependent runtime state after the operator saves on the Settings page.
+ /// Currently only the bench refresh-timer interval needs re-application.
+ ///
+ private void OnSettingsSaved()
{
- var vm = new SettingsViewModel(_config, _loc);
- var dlg = new SettingsDialog(vm) { Owner = Application.Current.MainWindow };
- dlg.ShowDialog();
-
- if (vm.Accepted && _refreshTimer != null)
+ if (_refreshTimer != null)
_refreshTimer.Interval = TimeSpan.FromMilliseconds(_config.Settings.RefreshBenchInterfaceMs);
}
@@ -658,6 +762,15 @@ namespace HC_APTBS.ViewModels
FlowmeterChart.AddSamples(QDelivery, QOver);
BenchControl.RefreshFromTick();
+ // Mirror the oil pump relay state for the Tests page preconditions checklist.
+ IsOilPumpOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
+
+ // Feed page-scoped Bench VMs (pressure trace + interlock banner).
+ BenchPage.RefreshFromTick();
+
+ // Refresh Dashboard's active-alarm list from the bench alarm bitmask.
+ DashboardAlarms.Update((int)_bench.ReadBenchParameter(BenchParameterNames.Alarms));
+
if (CurrentPump != null)
{
PumpRpm = _bench.ReadPumpParameter(PumpParameterNames.Rpm);
@@ -708,6 +821,7 @@ namespace HC_APTBS.ViewModels
LastTestSuccess = !interrupted && success;
VerboseStatus = interrupted ? _loc.GetString("Test.Stopped") : (success ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail"));
TestPanel.IsRunning = false;
+ TestPanel.ClearPhaseTimer();
_bench.StopPumpSender();
StartTestCommand.NotifyCanExecuteChanged();
StopTestCommand.NotifyCanExecuteChanged();
@@ -716,6 +830,13 @@ namespace HC_APTBS.ViewModels
// Populate results table from all completed tests.
if (!interrupted && CurrentPump != null)
ResultDisplay.LoadAllResults(CurrentPump.Tests);
+
+ // Capture a session-only history entry (Results page §5) — covers normal
+ // and interrupted completions. Snapshot is deep-cloned so later runs
+ // cannot mutate this entry's data.
+ if (CurrentPump != null)
+ ResultsPage.CaptureRun(CurrentPump, interrupted, success);
+
_log.Info(LogId,
$"Test finished — interrupted={interrupted}, success={success}");
});
diff --git a/ViewModels/Pages/BenchPageViewModel.cs b/ViewModels/Pages/BenchPageViewModel.cs
new file mode 100644
index 0000000..7c8b526
--- /dev/null
+++ b/ViewModels/Pages/BenchPageViewModel.cs
@@ -0,0 +1,66 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels.Pages
+{
+ ///
+ /// ViewModel for the Bench navigation page.
+ ///
+ /// Groups the bench-related child ViewModels owned by
+ /// together with page-specific façades
+ /// (temperature control, relay bank, pressure trace, interlock banner)
+ /// that only the Bench page consumes.
+ ///
+ public sealed class BenchPageViewModel : ObservableObject
+ {
+ /// Root ViewModel — owns services, live readings, and global commands.
+ public MainViewModel Root { get; }
+
+ /// Manual bench controls (direction, RPM start/stop, oil pump, counter).
+ public BenchControlViewModel BenchControl => Root.BenchControl;
+
+ /// Real-time flowmeter charts (Q-Delivery, Q-Over).
+ public FlowmeterChartViewModel FlowmeterChart => Root.FlowmeterChart;
+
+ /// Encoder angle monitoring (PSG, INJ, Manual, Lock Angle).
+ public AngleDisplayViewModel AngleDisplay => Root.AngleDisplay;
+
+ /// Temperature PID setpoint + heater / cooler relay toggles.
+ public TemperatureControlViewModel TempControl { get; }
+
+ /// Auxiliary relay toggle bank (Electronic, Flasher, Pulse4Signal).
+ public RelayBankViewModel RelayBank { get; }
+
+ /// Real-time pressure traces (P1, P2).
+ public PressureTraceChartViewModel PressureTrace { get; } = new();
+
+ /// Soft safety interlock banner state (oil pump / RPM limit).
+ public InterlockBannerViewModel Interlock { get; }
+
+ /// Root view-model providing live bench readings and child VMs.
+ /// Bench service for setpoint and relay control.
+ /// Configuration service for safety limits and defaults.
+ public BenchPageViewModel(
+ MainViewModel root,
+ IBenchService benchService,
+ IConfigurationService configService)
+ {
+ Root = root;
+ TempControl = new TemperatureControlViewModel(benchService, configService);
+ RelayBank = new RelayBankViewModel(benchService);
+ Interlock = new InterlockBannerViewModel(configService);
+ }
+
+ ///
+ /// Called from to feed the
+ /// page-scoped VMs (pressure trace, interlock banner) from the latest
+ /// bench readings. Keeping this here avoids adding page-specific logic
+ /// to the root ViewModel.
+ ///
+ public void RefreshFromTick()
+ {
+ PressureTrace.AddSamples(Root.Pressure, Root.Pressure2);
+ Interlock.Update(Root.BenchRpm, BenchControl.IsOilPumpOn);
+ }
+ }
+}
diff --git a/ViewModels/Pages/DashboardPageViewModel.cs b/ViewModels/Pages/DashboardPageViewModel.cs
new file mode 100644
index 0000000..bd2b5da
--- /dev/null
+++ b/ViewModels/Pages/DashboardPageViewModel.cs
@@ -0,0 +1,25 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace HC_APTBS.ViewModels.Pages
+{
+ ///
+ /// ViewModel for the Dashboard navigation page.
+ ///
+ /// Thin façade — holds a reference so the Dashboard XAML
+ /// can bind to MainViewModel-owned live readings, connection state, test summary,
+ /// alarms, and commands via {Binding Root.X}.
+ ///
+ public sealed class DashboardPageViewModel : ObservableObject
+ {
+ /// Root ViewModel — owns services, live readings, and global commands.
+ public MainViewModel Root { get; }
+
+ /// Active alarm aggregator bound to the Dashboard alarm list.
+ public DashboardAlarmsViewModel Alarms => Root.DashboardAlarms;
+
+ public DashboardPageViewModel(MainViewModel root)
+ {
+ Root = root;
+ }
+ }
+}
diff --git a/ViewModels/Pages/PumpPageViewModel.cs b/ViewModels/Pages/PumpPageViewModel.cs
new file mode 100644
index 0000000..e42b40b
--- /dev/null
+++ b/ViewModels/Pages/PumpPageViewModel.cs
@@ -0,0 +1,132 @@
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using HC_APTBS.Models;
+using HC_APTBS.ViewModels.Dialogs;
+
+namespace HC_APTBS.ViewModels.Pages
+{
+ /// Identifies the sub-section shown inside the Pump navigation page.
+ public enum PumpSubPage
+ {
+ /// §3.a — Pump selection and K-Line ECU read.
+ Identification = 0,
+ /// §3.b — Diagnostic Trouble Codes.
+ Dtcs = 1,
+ /// §3.c — Live pump CAN readings and status words.
+ LiveData = 2,
+ /// §3.d — DFI calibration and ME/FBKW/PreIn manual control (auth-gated).
+ Adaptation = 3,
+ /// §3.e — Ford VP44 immobilizer unlock (visible only when required).
+ Unlock = 4
+ }
+
+ ///
+ /// ViewModel for the Pump navigation page.
+ ///
+ /// Thin façade that groups the pump-related child ViewModels owned by
+ /// and adds sub-page navigation, banner flags,
+ /// and the Adaptation auth gate. Holds a reference so
+ /// page XAML can bind to MainViewModel-owned properties (PumpRpm, PumpTemp,
+ /// KLineState, …) via {Binding Root.X}.
+ ///
+ public sealed partial class PumpPageViewModel : ObservableObject
+ {
+ /// Root ViewModel — owns services, live readings, and global commands.
+ public MainViewModel Root { get; }
+
+ // ── Child VM façades ──────────────────────────────────────────────────────
+
+ /// Pump selector and K-Line read (§3.a).
+ public PumpIdentificationViewModel Identification => Root.PumpIdentification;
+
+ /// Diagnostic Trouble Code list (§3.b).
+ public DtcListViewModel DtcList { get; }
+
+ /// Adaptation sub-section auth gate (§3.d).
+ public AuthGateViewModel AdaptationAuth { get; }
+
+ /// DFI management (§3.d).
+ public DfiManageViewModel DfiViewModel => Root.DfiViewModel;
+
+ /// Manual pump control sliders (§3.d).
+ public PumpControlViewModel PumpControl => Root.PumpControl;
+
+ /// First pump status display — Status word (§3.c).
+ public StatusDisplayViewModel StatusDisplay1 => Root.StatusDisplay1;
+
+ /// Second pump status display — Empf3 word (§3.c).
+ public StatusDisplayViewModel StatusDisplay2 => Root.StatusDisplay2;
+
+ /// Current immobilizer unlock VM (§3.e). Null when no unlock is in progress for this pump.
+ public UnlockProgressViewModel? UnlockVm => Root.CurrentUnlockVm;
+
+ // ── Navigation state ──────────────────────────────────────────────────────
+
+ /// Currently selected Pump sub-section.
+ [ObservableProperty] private PumpSubPage _selectedSubPage = PumpSubPage.Identification;
+
+ // ── Banner flags (derived from Root state) ────────────────────────────────
+
+ /// True when a pump has been loaded from the database.
+ [ObservableProperty] private bool _isPumpSelected;
+
+ /// True when the K-Line session is currently open.
+ [ObservableProperty] private bool _isKLineSessionOpen;
+
+ /// True when the K-Line session is in the failed state.
+ [ObservableProperty] private bool _isKLineSessionFailed;
+
+ /// True for pumps that require a Ford immobilizer unlock (Type 1 or 2).
+ [ObservableProperty] private bool _isUnlockApplicable;
+
+ /// Constructs the page VM and subscribes to relevant Root state changes.
+ public PumpPageViewModel(
+ MainViewModel root,
+ DtcListViewModel dtcList,
+ AuthGateViewModel adaptationAuth)
+ {
+ Root = root;
+ DtcList = dtcList;
+ AdaptationAuth = adaptationAuth;
+
+ // Initialise derived flags from the current Root state.
+ RefreshDerivedFlags();
+
+ // Keep the derived flags in sync with Root changes.
+ Root.PropertyChanged += OnRootPropertyChanged;
+ Root.PumpIdentification.PumpChanged += _ => RefreshDerivedFlags();
+ }
+
+ private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(MainViewModel.KLineState):
+ IsKLineSessionOpen = Root.KLineState == KLineConnectionState.Connected;
+ IsKLineSessionFailed = Root.KLineState == KLineConnectionState.Failed;
+ break;
+
+ case nameof(MainViewModel.CurrentUnlockVm):
+ OnPropertyChanged(nameof(UnlockVm));
+ break;
+ }
+ }
+
+ private void RefreshDerivedFlags()
+ {
+ IsPumpSelected = Root.CurrentPump != null;
+ IsKLineSessionOpen = Root.KLineState == KLineConnectionState.Connected;
+ IsKLineSessionFailed = Root.KLineState == KLineConnectionState.Failed;
+ IsUnlockApplicable = Root.CurrentPump != null && Root.CurrentPump.UnlockType != 0;
+ OnPropertyChanged(nameof(UnlockVm));
+
+ // When the pump changes, re-lock the adaptation gate — a new operator
+ // may be handling a different pump.
+ if (AdaptationAuth.IsAuthenticated)
+ AdaptationAuth.LockCommand.Execute(null);
+
+ // Drop any stale DTCs from the previous pump.
+ DtcList.Reset();
+ }
+ }
+}
diff --git a/ViewModels/Pages/ResultsPageViewModel.cs b/ViewModels/Pages/ResultsPageViewModel.cs
new file mode 100644
index 0000000..5a75f38
--- /dev/null
+++ b/ViewModels/Pages/ResultsPageViewModel.cs
@@ -0,0 +1,273 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Windows;
+using System.Xml.Linq;
+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
+{
+ ///
+ /// ViewModel for the Results navigation page (§5 in docs/ui-structure.md).
+ ///
+ /// Owns a session-only history of completed test runs, drives the embedded
+ /// detail pane, hosts the operator
+ /// observations/notes field, and runs the PDF export flow against the selected
+ /// snapshot.
+ ///
+ public sealed partial class ResultsPageViewModel : ObservableObject
+ {
+ // ── Services ──────────────────────────────────────────────────────────────
+
+ private readonly IPdfService _pdf;
+ private readonly IConfigurationService _config;
+ private readonly ILocalizationService _loc;
+ private readonly IAppLogger _log;
+ private const string LogId = "ResultsPage";
+
+ /// Remembers the last authenticated user to pre-fill the next auth dialog.
+ private string _lastAuthenticatedUser = string.Empty;
+
+ // ── Root reference ────────────────────────────────────────────────────────
+
+ /// Root ViewModel — lets page XAML bind app-global state when needed.
+ public MainViewModel Root { get; }
+
+ // ── Child VMs ─────────────────────────────────────────────────────────────
+
+ /// Detail-pane ViewModel driving the embedded ResultDisplayView.
+ public ResultDisplayViewModel Detail { get; }
+
+ // ── Observable state ──────────────────────────────────────────────────────
+
+ /// Newest-first list of captured completions for this session.
+ public ObservableCollection History { get; } = new();
+
+ /// Currently selected history entry, or null when the list is empty.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ExportPdfCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RemoveEntryCommand))]
+ private CompletedTestRun? _selectedRun;
+
+ /// True while the history list has no entries — drives the empty-state UI.
+ public bool IsHistoryEmpty => History.Count == 0;
+
+ /// True when at least one entry exists — drives the Clear Session button's IsEnabled.
+ public bool HasAnyEntries => History.Count > 0;
+
+ // ── Constructor ───────────────────────────────────────────────────────────
+
+ /// Initialises the Results page VM with the services it needs.
+ public ResultsPageViewModel(
+ MainViewModel root,
+ IPdfService pdfService,
+ IConfigurationService configService,
+ ILocalizationService localizationService,
+ IAppLogger logger)
+ {
+ Root = root;
+ _pdf = pdfService;
+ _config = configService;
+ _loc = localizationService;
+ _log = logger;
+
+ Detail = new ResultDisplayViewModel(localizationService);
+ History.CollectionChanged += (_, _) =>
+ {
+ OnPropertyChanged(nameof(IsHistoryEmpty));
+ OnPropertyChanged(nameof(HasAnyEntries));
+ ClearHistoryCommand.NotifyCanExecuteChanged();
+ };
+ }
+
+ // ── Capture ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Records a completed test session. Called from
+ /// 's OnTestFinished hook on the UI thread.
+ /// Captures a deep-cloned snapshot so that subsequent runs do not mutate
+ /// prior entries in place.
+ ///
+ public void CaptureRun(PumpDefinition pump, bool interrupted, bool success)
+ {
+ try
+ {
+ var snapshot = CloneForHistory(pump);
+ var entry = new CompletedTestRun
+ {
+ CompletedAt = DateTime.Now,
+ PumpModel = pump.Model,
+ PumpSerial = pump.SerialNumber,
+ Interrupted = interrupted,
+ OverallPassed = !interrupted && success && EvaluatePassed(snapshot.Tests),
+ PumpSnapshot = snapshot,
+ };
+ History.Insert(0, entry);
+ SelectedRun = entry;
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"CaptureRun: {ex.Message}");
+ }
+ }
+
+ partial void OnSelectedRunChanged(CompletedTestRun? value)
+ {
+ if (value == null)
+ {
+ Detail.Clear();
+ return;
+ }
+ Detail.LoadAllResults(value.PumpSnapshot.Tests);
+ }
+
+ // ── Commands ──────────────────────────────────────────────────────────────
+
+ /// Authenticates the operator, collects report details, then writes the PDF.
+ [RelayCommand(CanExecute = nameof(CanExport))]
+ private void ExportPdf()
+ {
+ if (SelectedRun == null) return;
+
+ // Step 1: Authenticate operator (same flow as Test page report export).
+ var authVm = new UserCheckViewModel(_config, _loc, _lastAuthenticatedUser);
+ var authDlg = new UserCheckDialog(authVm) { Owner = Application.Current.MainWindow };
+ authDlg.ShowDialog();
+ if (!authVm.Accepted) return;
+ _lastAuthenticatedUser = authVm.AuthenticatedUser;
+
+ // Step 2: Collect report details, pre-filling observations from the captured run.
+ var reportVm = new ReportViewModel(_config)
+ {
+ OperatorName = authVm.AuthenticatedUser,
+ Observations = SelectedRun.Observations,
+ };
+ var reportDlg = new ReportDialog(reportVm) { Owner = Application.Current.MainWindow };
+ reportDlg.ShowDialog();
+ if (!reportVm.Accepted) return;
+
+ SelectedRun.Observations = reportVm.Observations;
+
+ try
+ {
+ string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
+ string path = _pdf.GenerateReport(
+ SelectedRun.PumpSnapshot,
+ reportVm.OperatorName,
+ reportVm.SelectedClientName,
+ desktop,
+ clientInfo: reportVm.ClientInfo,
+ observations: reportVm.Observations);
+ _log.Info(LogId, $"Report saved: {path}");
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
+ { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"ExportPdf: {ex.Message}");
+ MessageBox.Show(
+ string.Format(_loc.GetString("Error.ReportGeneration"), ex.Message),
+ _loc.GetString("Error.ReportTitle"),
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private bool CanExport() => SelectedRun != null;
+
+ /// Removes a single history entry by .
+ [RelayCommand(CanExecute = nameof(CanRemoveEntry))]
+ private void RemoveEntry(Guid id)
+ {
+ var target = History.FirstOrDefault(e => e.Id == id);
+ if (target == null) return;
+
+ bool wasSelected = ReferenceEquals(target, SelectedRun);
+ History.Remove(target);
+ if (wasSelected) SelectedRun = History.FirstOrDefault();
+ }
+
+ private bool CanRemoveEntry(Guid id) => History.Any(e => e.Id == id);
+
+ /// Clears the whole session history after operator confirmation.
+ [RelayCommand(CanExecute = nameof(HasAnyEntries))]
+ private void ClearHistory()
+ {
+ var result = MessageBox.Show(
+ _loc.GetString("Results.ClearSessionConfirm"),
+ _loc.GetString("Results.ClearSessionButton"),
+ MessageBoxButton.YesNo, MessageBoxImage.Question);
+ if (result != MessageBoxResult.Yes) return;
+
+ History.Clear();
+ SelectedRun = null;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Builds a minimal deep copy of that carries the
+ /// fields consumed by and the detail view, with
+ /// entries deep-cloned via XML round-trip so
+ /// results cannot be mutated by subsequent runs.
+ ///
+ private static PumpDefinition CloneForHistory(PumpDefinition pump)
+ {
+ var clone = new PumpDefinition
+ {
+ Id = pump.Id,
+ Model = pump.Model,
+ SerialNumber = pump.SerialNumber,
+ Injector = pump.Injector,
+ Tube = pump.Tube,
+ Valve = pump.Valve,
+ Tension = pump.Tension,
+ Info = pump.Info,
+ EcuText = pump.EcuText,
+ Chaveta = pump.Chaveta,
+ LockAngle = pump.LockAngle,
+ LockAngleResult = pump.LockAngleResult,
+ HasPreInjection = pump.HasPreInjection,
+ Is4Cylinder = pump.Is4Cylinder,
+ UnlockType = pump.UnlockType,
+ Rotation = pump.Rotation,
+ KwpVersion = pump.KwpVersion,
+ };
+
+ foreach (var kv in pump.KlineInfo)
+ clone.KlineInfo[kv.Key] = kv.Value;
+
+ foreach (var test in pump.Tests)
+ {
+ XElement xml = test.ToXml();
+ clone.Tests.Add(TestDefinition.FromXml(xml));
+ }
+
+ return clone;
+ }
+
+ private static bool EvaluatePassed(IReadOnlyList tests)
+ {
+ bool anyEvaluated = false;
+ foreach (var t in tests)
+ {
+ foreach (var p in t.Phases)
+ {
+ if (!p.Enabled || p.Receives == null) continue;
+ foreach (var tp in p.Receives)
+ {
+ if (tp.Result == null) continue;
+ anyEvaluated = true;
+ if (!tp.Result.Passed) return false;
+ }
+ }
+ }
+ return anyEvaluated;
+ }
+ }
+}
diff --git a/ViewModels/Dialogs/SettingsViewModel.cs b/ViewModels/Pages/SettingsPageViewModel.cs
similarity index 72%
rename from ViewModels/Dialogs/SettingsViewModel.cs
rename to ViewModels/Pages/SettingsPageViewModel.cs
index eacd6ca..120fa8e 100644
--- a/ViewModels/Dialogs/SettingsViewModel.cs
+++ b/ViewModels/Pages/SettingsPageViewModel.cs
@@ -3,29 +3,30 @@ using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using System.Windows;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Models;
using HC_APTBS.Services;
+using HC_APTBS.ViewModels.Dialogs;
+using HC_APTBS.Views.Dialogs;
-namespace HC_APTBS.ViewModels.Dialogs
+namespace HC_APTBS.ViewModels.Pages
{
///
- /// ViewModel for the application settings dialog.
+ /// ViewModel for the Settings navigation page.
/// Loads a local copy of every property so that
- /// Cancel discards all changes.
+ /// Discard reverts all pending changes without touching persisted state.
///
- public sealed partial class SettingsViewModel : ObservableObject
+ public sealed partial class SettingsPageViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
- // ── Dialog result ─────────────────────────────────────────────────────
-
- /// True when the user clicked Accept.
- public bool Accepted { get; private set; }
-
- /// Raised to close the owning dialog window.
- public event Action? RequestClose;
+ ///
+ /// Raised after successfully persists settings.
+ /// The shell subscribes to reseed timers or other settings-dependent state.
+ ///
+ public event Action? SettingsSaved;
// ── Collections ───────────────────────────────────────────────────────
@@ -91,68 +92,20 @@ namespace HC_APTBS.ViewModels.Dialogs
/// Configuration service for loading/saving settings.
/// Localization service for language switching.
- public SettingsViewModel(IConfigurationService configService, ILocalizationService localizationService)
+ public SettingsPageViewModel(IConfigurationService configService, ILocalizationService localizationService)
{
_config = configService;
_loc = localizationService;
- var s = configService.Settings;
-
- // General
- _selectedLanguage = s.Language;
- _daysKeepLogs = s.DaysKeepLogs;
-
- // Safety
- _tempMax = s.TempMax;
- _tempMin = s.TempMin;
- _securityRpmLimit = s.SecurityRpmLimit;
- _maxPressureBar = s.MaxPressureBar;
- _toleranceUpExtension = s.ToleranceUpExtension;
- _tolerancePfpExtension = s.TolerancePfpExtension;
- _defaultIgnoreTin = s.DefaultIgnoreTin;
-
- // PID
- _pidP = s.PidP;
- _pidI = s.PidI;
- _pidD = s.PidD;
- _pidLoopMs = s.PidLoopMs;
-
- // Motor
- _encoderResolution = s.EncoderResolution;
- _voltageForMaxRpm = s.VoltageForMaxRpm;
- _maxRpm = s.MaxRpm;
- _rightRelayValue = s.RightRelayValue;
-
- // Company
- _companyName = s.CompanyName;
- _companyInfo = s.CompanyInfo;
- _reportLogoPath = s.ReportLogoPath;
-
- // K-Line
- _selectedKLinePort = s.KLinePort;
-
- // Advanced
- _refreshBenchInterfaceMs = s.RefreshBenchInterfaceMs;
- _refreshWhileReadingMs = s.RefreshWhileReadingMs;
- _refreshCanBusReadMs = s.RefreshCanBusReadMs;
- _refreshPumpRequestMs = s.RefreshPumpRequestMs;
- _refreshPumpParamsMs = s.RefreshPumpParamsMs;
- _blinkIntervalMs = s.BlinkIntervalMs;
- _flasherIntervalMs = s.FlasherIntervalMs;
-
- // Deep-copy the RPM-voltage relation table
- foreach (var r in s.Relations)
- Relations.Add(new RpmVoltageRelation(r.Voltage, r.Rpm));
-
- // Enumerate connected FTDI devices
+ LoadFromConfig();
EnumerateFtdiDevices();
}
// ── Commands ──────────────────────────────────────────────────────────
- /// Copies all local values back to AppSettings, saves, and closes.
+ /// Copies all local values back to AppSettings, saves to disk, applies language if changed.
[RelayCommand]
- private void Accept()
+ private void Save()
{
var s = _config.Settings;
@@ -204,16 +157,14 @@ namespace HC_APTBS.ViewModels.Dialogs
_config.SaveSettings();
- Accepted = true;
- RequestClose?.Invoke();
+ SettingsSaved?.Invoke();
}
- /// Discards changes and closes.
+ /// Reverts all local fields to the currently persisted values.
[RelayCommand]
- private void Cancel()
+ private void Discard()
{
- Accepted = false;
- RequestClose?.Invoke();
+ LoadFromConfig();
}
/// Opens a file dialog to select a company logo image.
@@ -258,8 +209,92 @@ namespace HC_APTBS.ViewModels.Dialogs
Relations.Remove(relation);
}
+ ///
+ /// Opens the user management dialog after a successful admin authentication.
+ /// The auth dialog is re-prompted on every invocation — there is no session cache.
+ ///
+ [RelayCommand]
+ private void ManageUsers()
+ {
+ var owner = GetOwnerWindow();
+
+ // Step 1: admin authentication.
+ var authVm = new UserCheckViewModel(_config, _loc);
+ var authDlg = new UserCheckDialog(authVm) { Owner = owner };
+ authDlg.ShowDialog();
+ if (!authVm.Accepted) return;
+
+ // Step 2: management dialog.
+ var manageVm = new UserManageViewModel(_config, _loc);
+ var manageDlg = new UserManageDialog(manageVm) { Owner = owner };
+ manageDlg.ShowDialog();
+ }
+
+ /// Finds a plausible dialog owner (active window, else main window).
+ private static Window? GetOwnerWindow()
+ {
+ foreach (Window w in Application.Current.Windows)
+ {
+ if (w.IsActive) return w;
+ }
+ return Application.Current.MainWindow;
+ }
+
// ── Helpers ───────────────────────────────────────────────────────────
+ /// Copies every persisted setting into the local mirror fields.
+ private void LoadFromConfig()
+ {
+ var s = _config.Settings;
+
+ // General
+ SelectedLanguage = s.Language;
+ DaysKeepLogs = s.DaysKeepLogs;
+
+ // Safety
+ TempMax = s.TempMax;
+ TempMin = s.TempMin;
+ SecurityRpmLimit = s.SecurityRpmLimit;
+ MaxPressureBar = s.MaxPressureBar;
+ ToleranceUpExtension = s.ToleranceUpExtension;
+ TolerancePfpExtension = s.TolerancePfpExtension;
+ DefaultIgnoreTin = s.DefaultIgnoreTin;
+
+ // PID
+ PidP = s.PidP;
+ PidI = s.PidI;
+ PidD = s.PidD;
+ PidLoopMs = s.PidLoopMs;
+
+ // Motor
+ EncoderResolution = s.EncoderResolution;
+ VoltageForMaxRpm = s.VoltageForMaxRpm;
+ MaxRpm = s.MaxRpm;
+ RightRelayValue = s.RightRelayValue;
+
+ // Company
+ CompanyName = s.CompanyName;
+ CompanyInfo = s.CompanyInfo;
+ ReportLogoPath = s.ReportLogoPath;
+
+ // K-Line
+ SelectedKLinePort = s.KLinePort;
+
+ // Advanced
+ RefreshBenchInterfaceMs = s.RefreshBenchInterfaceMs;
+ RefreshWhileReadingMs = s.RefreshWhileReadingMs;
+ RefreshCanBusReadMs = s.RefreshCanBusReadMs;
+ RefreshPumpRequestMs = s.RefreshPumpRequestMs;
+ RefreshPumpParamsMs = s.RefreshPumpParamsMs;
+ BlinkIntervalMs = s.BlinkIntervalMs;
+ FlasherIntervalMs = s.FlasherIntervalMs;
+
+ // Deep-copy the RPM-voltage relation table
+ Relations.Clear();
+ foreach (var r in s.Relations)
+ Relations.Add(new RpmVoltageRelation(r.Voltage, r.Rpm));
+ }
+
///
/// Populates with serial numbers of connected
/// FTDI devices. Fails silently if the FTDI driver DLL is not present.
diff --git a/ViewModels/Pages/TestsPageViewModel.cs b/ViewModels/Pages/TestsPageViewModel.cs
new file mode 100644
index 0000000..ff53861
--- /dev/null
+++ b/ViewModels/Pages/TestsPageViewModel.cs
@@ -0,0 +1,233 @@
+using System.ComponentModel;
+using System.Linq;
+using System.Windows;
+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.
+ ///
+ /// 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.
+ ///
+ public sealed partial class TestsPageViewModel : ObservableObject
+ {
+ private readonly IConfigurationService _config;
+ private readonly ILocalizationService _loc;
+
+ /// Root ViewModel — owns services, live readings, and global commands.
+ public MainViewModel Root { get; }
+
+ /// Test panel: sections, phase cards, live indicators.
+ public TestPanelViewModel TestPanel => Root.TestPanel;
+
+ /// 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).
+ 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.
+ 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);
+
+ CurrentStateVm = _planVm;
+
+ Root.PropertyChanged += OnRootPropertyChanged;
+ }
+
+ // ── State ─────────────────────────────────────────────────────────────────
+
+ /// Current wizard step.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(NextCommand))]
+ [NotifyCanExecuteChangedFor(nameof(BackCommand))]
+ [NotifyCanExecuteChangedFor(nameof(AbortCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RunAgainCommand))]
+ [NotifyCanExecuteChangedFor(nameof(ViewFullResultsCommand))]
+ private TestFlowState _currentState = TestFlowState.Plan;
+
+ ///
+ /// View-model currently rendered by the step ContentControl. Swaps to
+ /// , ,
+ /// , or this (for the Done step).
+ ///
+ [ObservableProperty] private object _currentStateVm;
+
+ /// Convenience flag for view styling — true while a test is actively running.
+ public bool IsRunningStep => CurrentState == TestFlowState.Running;
+
+ /// Convenience flag for view styling — true when the page is on the Done step.
+ public bool IsDoneStep => CurrentState == TestFlowState.Done;
+
+ partial void OnCurrentStateChanged(TestFlowState oldValue, TestFlowState newValue)
+ {
+ if (oldValue == TestFlowState.Preconditions && newValue != TestFlowState.Preconditions)
+ Preconditions.Deactivate();
+
+ 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;
+ }
+
+ OnPropertyChanged(nameof(IsRunningStep));
+ OnPropertyChanged(nameof(IsDoneStep));
+ }
+
+ // ── 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 .
+ ///
+ [RelayCommand(CanExecute = nameof(CanNext))]
+ private void Next()
+ {
+ if (CurrentState != TestFlowState.Plan) return;
+ if (!Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled))) return;
+ CurrentState = TestFlowState.Preconditions;
+ }
+
+ private bool CanNext() => CurrentState == TestFlowState.Plan;
+
+ /// 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 .
+ ///
+ [RelayCommand(CanExecute = nameof(CanAbort))]
+ private void Abort()
+ {
+ var vm = new ConfirmDialogViewModel
+ {
+ Title = _loc.GetString("Test.Abort.Title"),
+ Message = _loc.GetString("Test.Abort.Message"),
+ ConfirmText = _loc.GetString("Test.Abort.Confirm"),
+ CancelText = _loc.GetString("Test.Abort.Cancel"),
+ };
+ var dlg = new ConfirmDialog(vm) { Owner = Application.Current.MainWindow };
+ dlg.ShowDialog();
+ if (!vm.Accepted) return;
+
+ if (Root.StopTestCommand.CanExecute(null))
+ Root.StopTestCommand.Execute(null);
+ }
+
+ private bool CanAbort() => CurrentState == TestFlowState.Running;
+
+ /// Resets the page for a fresh run without reloading the pump.
+ [RelayCommand(CanExecute = nameof(CanRunAgain))]
+ private void RunAgain()
+ {
+ Root.TestPanel.ResetResults();
+ Root.ResultDisplay.Clear();
+ CurrentState = TestFlowState.Plan;
+ }
+
+ private bool CanRunAgain() => CurrentState == TestFlowState.Done;
+
+ /// Jumps to the Results navigation page.
+ [RelayCommand(CanExecute = nameof(CanViewFullResults))]
+ private void ViewFullResults()
+ {
+ Root.SelectedPage = AppPage.Results;
+ }
+
+ private bool CanViewFullResults() => CurrentState == TestFlowState.Done;
+
+ // ── IsTestRunning → wizard state sync ─────────────────────────────────────
+
+ private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(MainViewModel.IsTestRunning)) return;
+
+ if (Root.IsTestRunning)
+ {
+ if (CurrentState == TestFlowState.Preconditions)
+ CurrentState = TestFlowState.Running;
+ }
+ else
+ {
+ if (CurrentState == TestFlowState.Running)
+ CurrentState = TestFlowState.Done;
+ }
+ }
+ }
+}
diff --git a/ViewModels/PreconditionItemViewModel.cs b/ViewModels/PreconditionItemViewModel.cs
new file mode 100644
index 0000000..1834c70
--- /dev/null
+++ b/ViewModels/PreconditionItemViewModel.cs
@@ -0,0 +1,66 @@
+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/PressureTraceChartViewModel.cs b/ViewModels/PressureTraceChartViewModel.cs
new file mode 100644
index 0000000..ad92caf
--- /dev/null
+++ b/ViewModels/PressureTraceChartViewModel.cs
@@ -0,0 +1,38 @@
+using SkiaSharp;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// Container ViewModel holding two real-time pressure traces:
+ /// one for P1 (bench oil pressure) and one for P2 (analogue sensor 2).
+ /// Reuses — the chart primitive is
+ /// identical, only the series colours and titles differ.
+ ///
+ public sealed class PressureTraceChartViewModel
+ {
+ /// Chart for P1 (red line).
+ public SingleFlowChartViewModel P1 { get; }
+ = new("P1 (bar)", new SKColor(0xD6, 0x28, 0x28));
+
+ /// Chart for P2 (cyan line).
+ public SingleFlowChartViewModel P2 { get; }
+ = new("P2 (bar)", new SKColor(0x00, 0xB4, 0xD8));
+
+ ///
+ /// Appends a sample pair to both traces.
+ /// Must be called on the UI thread.
+ ///
+ public void AddSamples(double p1, double p2)
+ {
+ P1.AddValue(p1);
+ P2.AddValue(p2);
+ }
+
+ /// Clears both traces.
+ public void Clear()
+ {
+ P1.Clear();
+ P2.Clear();
+ }
+ }
+}
diff --git a/ViewModels/RelayBankViewModel.cs b/ViewModels/RelayBankViewModel.cs
new file mode 100644
index 0000000..baab13c
--- /dev/null
+++ b/ViewModels/RelayBankViewModel.cs
@@ -0,0 +1,40 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using HC_APTBS.Models;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// ViewModel for a curated bank of auxiliary relay toggles on the Bench page.
+ /// Excludes relays driven by dedicated controls
+ /// (oil pump, direction, heater / cooler, counter).
+ ///
+ public sealed partial class RelayBankViewModel : ObservableObject
+ {
+ private readonly IBenchService _bench;
+
+ /// Solenoid power / pump keep-alive relay.
+ [ObservableProperty] private bool _isElectronicOn;
+
+ /// Panel flasher relay (status lamp blink).
+ [ObservableProperty] private bool _isFlasherOn;
+
+ /// 4-pulse-per-revolution output signal relay.
+ [ObservableProperty] private bool _isPulse4SignalOn;
+
+ /// Bench service used to drive the relays.
+ public RelayBankViewModel(IBenchService benchService)
+ {
+ _bench = benchService;
+ }
+
+ partial void OnIsElectronicOnChanged(bool value)
+ => _bench.SetRelay(RelayNames.Electronic, value);
+
+ partial void OnIsFlasherOnChanged(bool value)
+ => _bench.SetRelay(RelayNames.Flasher, value);
+
+ partial void OnIsPulse4SignalOnChanged(bool value)
+ => _bench.SetRelay(RelayNames.Pulse4Signal, value);
+ }
+}
diff --git a/ViewModels/TemperatureControlViewModel.cs b/ViewModels/TemperatureControlViewModel.cs
new file mode 100644
index 0000000..5c389a7
--- /dev/null
+++ b/ViewModels/TemperatureControlViewModel.cs
@@ -0,0 +1,69 @@
+using System.Globalization;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using HC_APTBS.Models;
+using HC_APTBS.Services;
+
+namespace HC_APTBS.ViewModels
+{
+ ///
+ /// ViewModel for the bench Temperature Control panel:
+ /// PID setpoint input and heater / deposit cooler / inlet cooler relay toggles.
+ ///
+ public sealed partial class TemperatureControlViewModel : ObservableObject
+ {
+ private readonly IBenchService _bench;
+ private readonly IConfigurationService _config;
+
+ /// Operator-entered PID setpoint in °C (text to allow invariant parsing).
+ [ObservableProperty] private string _setpointText = "40";
+
+ /// Tolerance ±°C applied when the setpoint is committed.
+ [ObservableProperty] private string _toleranceText = "2";
+
+ /// True when the deposit heater relay is energised.
+ [ObservableProperty] private bool _isHeaterOn;
+
+ /// True when the deposit cooler relay is energised.
+ [ObservableProperty] private bool _isDepositCoolerOn;
+
+ /// True when the T-in (inlet) cooler relay is energised.
+ [ObservableProperty] private bool _isTinCoolerOn;
+
+ /// Bench service for PID setpoint and relay control.
+ /// Configuration service — seeds setpoint from .
+ public TemperatureControlViewModel(IBenchService benchService, IConfigurationService configService)
+ {
+ _bench = benchService;
+ _config = configService;
+
+ // Seed setpoint from global temperature band midpoint, tolerance from its half-width.
+ int min = _config.Settings.TempMin;
+ int max = _config.Settings.TempMax;
+ int mid = (min + max) / 2;
+ int tol = (max - min) / 2;
+ SetpointText = mid.ToString(CultureInfo.InvariantCulture);
+ ToleranceText = tol.ToString(CultureInfo.InvariantCulture);
+ }
+
+ partial void OnIsHeaterOnChanged(bool value)
+ => _bench.SetRelay(RelayNames.DepositHeater, value);
+
+ partial void OnIsDepositCoolerOnChanged(bool value)
+ => _bench.SetRelay(RelayNames.DepositCooler, value);
+
+ partial void OnIsTinCoolerOnChanged(bool value)
+ => _bench.SetRelay(RelayNames.TinCooler, value);
+
+ /// Applies the PID setpoint entered by the operator.
+ [RelayCommand]
+ private void ApplySetpoint()
+ {
+ if (!double.TryParse(SetpointText, NumberStyles.Float, CultureInfo.InvariantCulture, out double sp))
+ return;
+ if (!double.TryParse(ToleranceText, NumberStyles.Float, CultureInfo.InvariantCulture, out double tol))
+ tol = 2;
+ _bench.SetTemperatureSetpoint(sp, tol);
+ }
+ }
+}
diff --git a/ViewModels/TestPanelViewModel.cs b/ViewModels/TestPanelViewModel.cs
index 27b7b81..506099c 100644
--- a/ViewModels/TestPanelViewModel.cs
+++ b/ViewModels/TestPanelViewModel.cs
@@ -47,6 +47,23 @@ namespace HC_APTBS.ViewModels
/// Estimated remaining time for the entire test sequence (seconds).
[ObservableProperty] private int _remainingSeconds;
+ // ── Active phase countdown (driven by IBenchService.PhaseTimerTick) ───────
+
+ /// Name of the currently running phase (empty when idle).
+ [ObservableProperty] private string _currentPhaseName = string.Empty;
+
+ /// Sub-section of the current phase: "Conditioning", "Measuring", or empty.
+ [ObservableProperty] private string _sectionLabel = string.Empty;
+
+ /// Seconds remaining in the current sub-section countdown.
+ [ObservableProperty] private int _phaseRemainingSeconds;
+
+ /// Total seconds for the current sub-section (denominator for progress).
+ [ObservableProperty] private int _phaseTotalSeconds;
+
+ /// Progress through the current sub-section (0.0 → 1.0).
+ [ObservableProperty] private double _phaseProgress;
+
// ── Test sections ─────────────────────────────────────────────────────────
/// All test sections for the currently loaded pump.
@@ -114,7 +131,9 @@ namespace HC_APTBS.ViewModels
/// Name of the phase that is now running.
public void SetActivePhase(string phaseName)
{
- StatusText = phaseName;
+ StatusText = phaseName;
+ CurrentPhaseName = phaseName;
+ ClearPhaseTimer();
_activePhaseCard = null;
foreach (var section in Tests)
@@ -190,6 +209,28 @@ namespace HC_APTBS.ViewModels
}
}
+ ///
+ /// Applies a phase-timer tick from .
+ /// Updates the sub-section label, remaining/total seconds and computed progress.
+ /// Must be called on the UI thread.
+ ///
+ public void ApplyPhaseTimerTick(string section, int remaining, int total)
+ {
+ SectionLabel = section;
+ PhaseRemainingSeconds = remaining;
+ PhaseTotalSeconds = total;
+ PhaseProgress = total > 0 ? 1.0 - (double)remaining / total : 0.0;
+ }
+
+ /// Clears the active-phase countdown (call on phase change and test end).
+ public void ClearPhaseTimer()
+ {
+ SectionLabel = string.Empty;
+ PhaseRemainingSeconds = 0;
+ PhaseTotalSeconds = 0;
+ PhaseProgress = 0;
+ }
+
///
/// Resets all phase execution states and graphic indicators for a fresh test run.
///
@@ -197,6 +238,8 @@ namespace HC_APTBS.ViewModels
{
_activePhaseCard = null;
StatusText = string.Empty;
+ CurrentPhaseName = string.Empty;
+ ClearPhaseTimer();
foreach (var section in Tests)
{
diff --git a/ViewModels/TestPreconditionsViewModel.cs b/ViewModels/TestPreconditionsViewModel.cs
new file mode 100644
index 0000000..ebb57b7
--- /dev/null
+++ b/ViewModels/TestPreconditionsViewModel.cs
@@ -0,0 +1,280 @@
+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/Views/Dialogs/ConfirmDialog.xaml b/Views/Dialogs/ConfirmDialog.xaml
new file mode 100644
index 0000000..b995a00
--- /dev/null
+++ b/Views/Dialogs/ConfirmDialog.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Dialogs/ConfirmDialog.xaml.cs b/Views/Dialogs/ConfirmDialog.xaml.cs
new file mode 100644
index 0000000..f8f0735
--- /dev/null
+++ b/Views/Dialogs/ConfirmDialog.xaml.cs
@@ -0,0 +1,21 @@
+using System.Windows;
+using HC_APTBS.ViewModels.Dialogs;
+
+namespace HC_APTBS.Views.Dialogs
+{
+ ///
+ /// Generic Yes/No (or Confirm/Cancel) modal dialog. See
+ /// for call-site usage — caller configures Title/Message/button text and inspects
+ /// after closing.
+ ///
+ public partial class ConfirmDialog : Window
+ {
+ /// Creates the dialog and wires the ViewModel.
+ public ConfirmDialog(ConfirmDialogViewModel vm)
+ {
+ InitializeComponent();
+ DataContext = vm;
+ vm.RequestClose += Close;
+ }
+ }
+}
diff --git a/Views/Dialogs/ReportDialog.xaml b/Views/Dialogs/ReportDialog.xaml
index 9a82ecb..7116c8a 100644
--- a/Views/Dialogs/ReportDialog.xaml
+++ b/Views/Dialogs/ReportDialog.xaml
@@ -68,7 +68,7 @@
diff --git a/Views/Dialogs/SettingsDialog.xaml.cs b/Views/Dialogs/SettingsDialog.xaml.cs
deleted file mode 100644
index a4d5557..0000000
--- a/Views/Dialogs/SettingsDialog.xaml.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Windows;
-using HC_APTBS.ViewModels.Dialogs;
-
-namespace HC_APTBS.Views.Dialogs
-{
- ///
- /// Dialog for editing all application settings.
- ///
- public partial class SettingsDialog : Window
- {
- public SettingsDialog(SettingsViewModel vm)
- {
- InitializeComponent();
- DataContext = vm;
- vm.RequestClose += Close;
- }
- }
-}
diff --git a/Views/Dialogs/UserManageDialog.xaml b/Views/Dialogs/UserManageDialog.xaml
new file mode 100644
index 0000000..f75a420
--- /dev/null
+++ b/Views/Dialogs/UserManageDialog.xaml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Dialogs/UserManageDialog.xaml.cs b/Views/Dialogs/UserManageDialog.xaml.cs
new file mode 100644
index 0000000..a4dbc0c
--- /dev/null
+++ b/Views/Dialogs/UserManageDialog.xaml.cs
@@ -0,0 +1,21 @@
+using System.Windows;
+using HC_APTBS.ViewModels.Dialogs;
+
+namespace HC_APTBS.Views.Dialogs
+{
+ ///
+ /// Admin dialog for managing the stored user list: add, remove, and change password.
+ /// Each action persists immediately via ;
+ /// the Close button simply dismisses the window.
+ ///
+ public partial class UserManageDialog : Window
+ {
+ /// Creates the dialog and wires the ViewModel.
+ public UserManageDialog(UserManageViewModel vm)
+ {
+ InitializeComponent();
+ DataContext = vm;
+ vm.RequestClose += Close;
+ }
+ }
+}
diff --git a/Views/Dialogs/UserPromptDialog.xaml b/Views/Dialogs/UserPromptDialog.xaml
new file mode 100644
index 0000000..babd7b6
--- /dev/null
+++ b/Views/Dialogs/UserPromptDialog.xaml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Dialogs/UserPromptDialog.xaml.cs b/Views/Dialogs/UserPromptDialog.xaml.cs
new file mode 100644
index 0000000..0192451
--- /dev/null
+++ b/Views/Dialogs/UserPromptDialog.xaml.cs
@@ -0,0 +1,59 @@
+using System.Windows;
+
+namespace HC_APTBS.Views.Dialogs
+{
+ ///
+ /// Small input dialog that prompts for a username and password, or a password only.
+ /// Used by when adding a new user or
+ /// changing an existing user's password. Kept as a code-behind dialog (not MVVM)
+ /// because it is a transient prompt with no shared state.
+ ///
+ public partial class UserPromptDialog : Window
+ {
+ /// Username entered by the operator. Empty when is false.
+ public string EnteredUsername { get; private set; } = string.Empty;
+
+ /// Password entered by the operator.
+ public string EnteredPassword { get; private set; } = string.Empty;
+
+ ///
+ /// Creates the dialog.
+ ///
+ /// Window title (already-localised string).
+ ///
+ /// True to show the username field (Add user flow); false to hide it (Change password flow).
+ ///
+ ///
+ /// Pre-filled, read-only username shown as a label when is false.
+ /// Ignored otherwise.
+ ///
+ public UserPromptDialog(string title, bool usernameVisible, string prefillUsername = "")
+ {
+ InitializeComponent();
+ Title = title;
+
+ if (usernameVisible)
+ {
+ EnteredUsername = string.Empty;
+ TbUsername.Focus();
+ }
+ else
+ {
+ // Hide username row; reserve width so layout doesn't shift.
+ LblUsername.Visibility = Visibility.Collapsed;
+ TbUsername.Visibility = Visibility.Collapsed;
+ EnteredUsername = prefillUsername;
+ PbPassword.Focus();
+ }
+ }
+
+ private void OnAccept(object sender, RoutedEventArgs e)
+ {
+ if (TbUsername.Visibility == Visibility.Visible)
+ EnteredUsername = TbUsername.Text;
+ EnteredPassword = PbPassword.Password;
+ DialogResult = true;
+ Close();
+ }
+ }
+}
diff --git a/Views/Pages/BenchPage.xaml b/Views/Pages/BenchPage.xaml
new file mode 100644
index 0000000..46a19f8
--- /dev/null
+++ b/Views/Pages/BenchPage.xaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Pages/BenchPage.xaml.cs b/Views/Pages/BenchPage.xaml.cs
new file mode 100644
index 0000000..63e0a36
--- /dev/null
+++ b/Views/Pages/BenchPage.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.Pages
+{
+ ///
+ /// Bench navigation page. DataContext is expected to be a
+ /// .
+ ///
+ public partial class BenchPage : UserControl
+ {
+ public BenchPage()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/Pages/DashboardPage.xaml b/Views/Pages/DashboardPage.xaml
new file mode 100644
index 0000000..c3d64e1
--- /dev/null
+++ b/Views/Pages/DashboardPage.xaml
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Pages/DashboardPage.xaml.cs b/Views/Pages/DashboardPage.xaml.cs
new file mode 100644
index 0000000..2e411e8
--- /dev/null
+++ b/Views/Pages/DashboardPage.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.Pages
+{
+ ///
+ /// Dashboard navigation page. DataContext is expected to be a
+ /// .
+ ///
+ public partial class DashboardPage : UserControl
+ {
+ public DashboardPage()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/Pages/PumpPage.xaml b/Views/Pages/PumpPage.xaml
new file mode 100644
index 0000000..e797448
--- /dev/null
+++ b/Views/Pages/PumpPage.xaml
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Pages/PumpPage.xaml.cs b/Views/Pages/PumpPage.xaml.cs
new file mode 100644
index 0000000..5584f11
--- /dev/null
+++ b/Views/Pages/PumpPage.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.Pages
+{
+ ///
+ /// Pump navigation page. DataContext is expected to be a
+ /// .
+ ///
+ public partial class PumpPage : UserControl
+ {
+ public PumpPage()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/Pages/ResultsPage.xaml b/Views/Pages/ResultsPage.xaml
new file mode 100644
index 0000000..930a0f9
--- /dev/null
+++ b/Views/Pages/ResultsPage.xaml
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Pages/ResultsPage.xaml.cs b/Views/Pages/ResultsPage.xaml.cs
new file mode 100644
index 0000000..aba38ed
--- /dev/null
+++ b/Views/Pages/ResultsPage.xaml.cs
@@ -0,0 +1,14 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.Pages
+{
+ ///
+ /// Results navigation page (§5 in docs/ui-structure.md).
+ /// Review, compare, and export completed test runs. DataContext must be a
+ /// .
+ ///
+ public partial class ResultsPage : UserControl
+ {
+ public ResultsPage() => InitializeComponent();
+ }
+}
diff --git a/Views/Dialogs/SettingsDialog.xaml b/Views/Pages/SettingsPage.xaml
similarity index 92%
rename from Views/Dialogs/SettingsDialog.xaml
rename to Views/Pages/SettingsPage.xaml
index 178bd00..e96c794 100644
--- a/Views/Dialogs/SettingsDialog.xaml
+++ b/Views/Pages/SettingsPage.xaml
@@ -1,22 +1,25 @@
-
+
+
+
-
-
-
+
-
-
+ HorizontalAlignment="Right" Margin="0,12,0,0">
+
+
@@ -35,6 +38,12 @@
FontWeight="SemiBold" Margin="0,16,0,4"/>
+
+
+
@@ -229,8 +238,8 @@
-
-
+
+
@@ -330,4 +339,4 @@
-
+
diff --git a/Views/Pages/SettingsPage.xaml.cs b/Views/Pages/SettingsPage.xaml.cs
new file mode 100644
index 0000000..86866ab
--- /dev/null
+++ b/Views/Pages/SettingsPage.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.Pages
+{
+ ///
+ /// Settings navigation page. Hosts grouped configuration forms (General,
+ /// Safety, PID, Motor, Report, K-Line, Advanced). DataContext is
+ /// .
+ ///
+ public partial class SettingsPage : UserControl
+ {
+ public SettingsPage()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/Pages/TestsPage.xaml b/Views/Pages/TestsPage.xaml
new file mode 100644
index 0000000..30b63f3
--- /dev/null
+++ b/Views/Pages/TestsPage.xaml
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Pages/TestsPage.xaml.cs b/Views/Pages/TestsPage.xaml.cs
new file mode 100644
index 0000000..b97427f
--- /dev/null
+++ b/Views/Pages/TestsPage.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.Pages
+{
+ ///
+ /// Tests navigation page. DataContext is expected to be a
+ /// .
+ ///
+ public partial class TestsPage : UserControl
+ {
+ public TestsPage()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/AuthGateView.xaml b/Views/UserControls/AuthGateView.xaml
new file mode 100644
index 0000000..41d87ec
--- /dev/null
+++ b/Views/UserControls/AuthGateView.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/AuthGateView.xaml.cs b/Views/UserControls/AuthGateView.xaml.cs
new file mode 100644
index 0000000..394995d
--- /dev/null
+++ b/Views/UserControls/AuthGateView.xaml.cs
@@ -0,0 +1,31 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// A gated container that shows only after the operator
+ /// authenticates via AuthGateViewModel. DataContext is
+ /// AuthGateViewModel.
+ ///
+ public partial class AuthGateView : UserControl
+ {
+ /// The content to show once the gate unlocks.
+ public static readonly DependencyProperty GatedContentProperty =
+ DependencyProperty.Register(
+ nameof(GatedContent), typeof(object), typeof(AuthGateView),
+ new PropertyMetadata(null));
+
+ /// Gets or sets the content displayed when the gate is unlocked.
+ public object? GatedContent
+ {
+ get => GetValue(GatedContentProperty);
+ set => SetValue(GatedContentProperty, value);
+ }
+
+ public AuthGateView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/BenchDriveControlView.xaml b/Views/UserControls/BenchDriveControlView.xaml
new file mode 100644
index 0000000..e821b39
--- /dev/null
+++ b/Views/UserControls/BenchDriveControlView.xaml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/BenchDriveControlView.xaml.cs b/Views/UserControls/BenchDriveControlView.xaml.cs
new file mode 100644
index 0000000..fa6557f
--- /dev/null
+++ b/Views/UserControls/BenchDriveControlView.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Manual drive control panel for the Bench page: direction toggle,
+ /// RPM start/stop with quick-select popup, oil pump, and turn counter.
+ ///
+ public partial class BenchDriveControlView : UserControl
+ {
+ /// Initializes a new instance of the control.
+ public BenchDriveControlView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/BenchReadingsView.xaml b/Views/UserControls/BenchReadingsView.xaml
new file mode 100644
index 0000000..0ec8781
--- /dev/null
+++ b/Views/UserControls/BenchReadingsView.xaml
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/BenchReadingsView.xaml.cs b/Views/UserControls/BenchReadingsView.xaml.cs
new file mode 100644
index 0000000..5479215
--- /dev/null
+++ b/Views/UserControls/BenchReadingsView.xaml.cs
@@ -0,0 +1,18 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// HMI-style bench readings panel (RPM, pressures, temperatures, Q flows)
+ /// rendered on the Bench page. Shares the LcdBlue style with the
+ /// dashboard equivalent but is wider and includes QDelivery + QOver.
+ ///
+ public partial class BenchReadingsView : UserControl
+ {
+ /// Initializes a new instance of the control.
+ public BenchReadingsView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/DashboardConnectionView.xaml b/Views/UserControls/DashboardConnectionView.xaml
new file mode 100644
index 0000000..ea0f75f
--- /dev/null
+++ b/Views/UserControls/DashboardConnectionView.xaml
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/DashboardConnectionView.xaml.cs b/Views/UserControls/DashboardConnectionView.xaml.cs
new file mode 100644
index 0000000..1827349
--- /dev/null
+++ b/Views/UserControls/DashboardConnectionView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Connection/liveness status pills for the Dashboard page.
+ /// DataContext is expected to be a .
+ ///
+ public partial class DashboardConnectionView : UserControl
+ {
+ public DashboardConnectionView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/DashboardReadingsView.xaml b/Views/UserControls/DashboardReadingsView.xaml
new file mode 100644
index 0000000..d03fb28
--- /dev/null
+++ b/Views/UserControls/DashboardReadingsView.xaml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/DashboardReadingsView.xaml.cs b/Views/UserControls/DashboardReadingsView.xaml.cs
new file mode 100644
index 0000000..1b9427b
--- /dev/null
+++ b/Views/UserControls/DashboardReadingsView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Compact read-only LCD panel for the Dashboard page.
+ /// DataContext is expected to be a .
+ ///
+ public partial class DashboardReadingsView : UserControl
+ {
+ public DashboardReadingsView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/DtcListView.xaml b/Views/UserControls/DtcListView.xaml
new file mode 100644
index 0000000..1051f78
--- /dev/null
+++ b/Views/UserControls/DtcListView.xaml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/DtcListView.xaml.cs b/Views/UserControls/DtcListView.xaml.cs
new file mode 100644
index 0000000..91fefc8
--- /dev/null
+++ b/Views/UserControls/DtcListView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Pump page §3.b DTC list view. DataContext is
+ /// DtcListViewModel.
+ ///
+ public partial class DtcListView : UserControl
+ {
+ public DtcListView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/FlowmeterChartView.xaml b/Views/UserControls/FlowmeterChartView.xaml
index 1d96331..db41982 100644
--- a/Views/UserControls/FlowmeterChartView.xaml
+++ b/Views/UserControls/FlowmeterChartView.xaml
@@ -1,7 +1,8 @@
+ xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"
+ >
@@ -23,6 +24,7 @@
YAxes="{Binding YAxes}"
Sections="{Binding Sections}"
TooltipPosition="Hidden"
- AnimationsSpeed="00:00:00"/>
+ AnimationsSpeed="00:00:00"
+ />
diff --git a/Views/UserControls/GraphicIndicatorView.xaml b/Views/UserControls/GraphicIndicatorView.xaml
new file mode 100644
index 0000000..6dbd09e
--- /dev/null
+++ b/Views/UserControls/GraphicIndicatorView.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/GraphicIndicatorView.xaml.cs b/Views/UserControls/GraphicIndicatorView.xaml.cs
new file mode 100644
index 0000000..cac582e
--- /dev/null
+++ b/Views/UserControls/GraphicIndicatorView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Vertical progress bar indicator for a single measurement value with tolerance band.
+ /// DataContext is expected to be a .
+ ///
+ public partial class GraphicIndicatorView : UserControl
+ {
+ public GraphicIndicatorView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/InterlockBannerView.xaml b/Views/UserControls/InterlockBannerView.xaml
new file mode 100644
index 0000000..fae33c8
--- /dev/null
+++ b/Views/UserControls/InterlockBannerView.xaml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/InterlockBannerView.xaml.cs b/Views/UserControls/InterlockBannerView.xaml.cs
new file mode 100644
index 0000000..66a516f
--- /dev/null
+++ b/Views/UserControls/InterlockBannerView.xaml.cs
@@ -0,0 +1,18 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Soft safety interlock banner rendered on the Bench page.
+ /// Shows when oil-pump interlock or RPM-over-limit conditions are active;
+ /// the operator can dismiss it until the condition changes.
+ ///
+ public partial class InterlockBannerView : UserControl
+ {
+ /// Initializes a new instance of the control.
+ public InterlockBannerView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/PhaseCardView.xaml b/Views/UserControls/PhaseCardView.xaml
new file mode 100644
index 0000000..953fe3f
--- /dev/null
+++ b/Views/UserControls/PhaseCardView.xaml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/PhaseCardView.xaml.cs b/Views/UserControls/PhaseCardView.xaml.cs
new file mode 100644
index 0000000..437fff3
--- /dev/null
+++ b/Views/UserControls/PhaseCardView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Single phase card (critical / operation values / graphic indicators / result).
+ /// DataContext is expected to be a .
+ ///
+ public partial class PhaseCardView : UserControl
+ {
+ public PhaseCardView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/PumpIdentificationPanelView.xaml b/Views/UserControls/PumpIdentificationPanelView.xaml
new file mode 100644
index 0000000..8074b68
--- /dev/null
+++ b/Views/UserControls/PumpIdentificationPanelView.xaml
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/PumpIdentificationPanelView.xaml.cs b/Views/UserControls/PumpIdentificationPanelView.xaml.cs
new file mode 100644
index 0000000..d769b6a
--- /dev/null
+++ b/Views/UserControls/PumpIdentificationPanelView.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Larger page-scoped variant of used by the
+ /// Pump page §3.a Identification sub-section. DataContext is
+ /// PumpIdentificationViewModel.
+ ///
+ public partial class PumpIdentificationPanelView : UserControl
+ {
+ public PumpIdentificationPanelView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/PumpIdentificationView.xaml b/Views/UserControls/PumpIdentificationView.xaml
index f4eee64..b9af735 100644
--- a/Views/UserControls/PumpIdentificationView.xaml
+++ b/Views/UserControls/PumpIdentificationView.xaml
@@ -12,14 +12,14 @@
-
-
+
+
+ FontSize="36" VerticalAlignment="Center"/>
diff --git a/Views/UserControls/PumpLiveDataView.xaml b/Views/UserControls/PumpLiveDataView.xaml
new file mode 100644
index 0000000..993576c
--- /dev/null
+++ b/Views/UserControls/PumpLiveDataView.xaml
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/PumpLiveDataView.xaml.cs b/Views/UserControls/PumpLiveDataView.xaml.cs
new file mode 100644
index 0000000..bb40a5d
--- /dev/null
+++ b/Views/UserControls/PumpLiveDataView.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Pump page §3.c Live Data view: pump CAN readings, status-word displays,
+ /// and a collapsible engineering panel. DataContext is
+ /// PumpPageViewModel (reaches MainViewModel via Root).
+ ///
+ public partial class PumpLiveDataView : UserControl
+ {
+ public PumpLiveDataView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/RelayBankView.xaml b/Views/UserControls/RelayBankView.xaml
new file mode 100644
index 0000000..20c989f
--- /dev/null
+++ b/Views/UserControls/RelayBankView.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/RelayBankView.xaml.cs b/Views/UserControls/RelayBankView.xaml.cs
new file mode 100644
index 0000000..7dd3502
--- /dev/null
+++ b/Views/UserControls/RelayBankView.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Curated bank of auxiliary relay toggles rendered on the Bench page.
+ /// Hosts Electronic, Flasher, and Pulse4Signal toggles with state indication.
+ ///
+ public partial class RelayBankView : UserControl
+ {
+ /// Initializes a new instance of the control.
+ public RelayBankView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/ResultHistoryView.xaml b/Views/UserControls/ResultHistoryView.xaml
new file mode 100644
index 0000000..e213126
--- /dev/null
+++ b/Views/UserControls/ResultHistoryView.xaml
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/ResultHistoryView.xaml.cs b/Views/UserControls/ResultHistoryView.xaml.cs
new file mode 100644
index 0000000..68ccd98
--- /dev/null
+++ b/Views/UserControls/ResultHistoryView.xaml.cs
@@ -0,0 +1,13 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Session-only test-run history list for the Results page.
+ /// DataContext must be a .
+ ///
+ public partial class ResultHistoryView : UserControl
+ {
+ public ResultHistoryView() => InitializeComponent();
+ }
+}
diff --git a/Views/UserControls/StatusDisplayView.xaml b/Views/UserControls/StatusDisplayView.xaml
index 8351f41..3691c2d 100644
--- a/Views/UserControls/StatusDisplayView.xaml
+++ b/Views/UserControls/StatusDisplayView.xaml
@@ -41,13 +41,13 @@
-
diff --git a/Views/UserControls/TemperatureControlView.xaml b/Views/UserControls/TemperatureControlView.xaml
new file mode 100644
index 0000000..f51eaae
--- /dev/null
+++ b/Views/UserControls/TemperatureControlView.xaml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/TemperatureControlView.xaml.cs b/Views/UserControls/TemperatureControlView.xaml.cs
new file mode 100644
index 0000000..0adbd40
--- /dev/null
+++ b/Views/UserControls/TemperatureControlView.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Bench temperature control panel: PID setpoint input and
+ /// heater / deposit cooler / T-in cooler relay toggles.
+ ///
+ public partial class TemperatureControlView : UserControl
+ {
+ /// Initializes a new instance of the control.
+ public TemperatureControlView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/TestDoneView.xaml b/Views/UserControls/TestDoneView.xaml
new file mode 100644
index 0000000..ff67f86
--- /dev/null
+++ b/Views/UserControls/TestDoneView.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/TestDoneView.xaml.cs b/Views/UserControls/TestDoneView.xaml.cs
new file mode 100644
index 0000000..8c795f0
--- /dev/null
+++ b/Views/UserControls/TestDoneView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Done step of the Tests wizard — PASS/FAIL banner, results table, Run Again.
+ /// DataContext is expected to be a .
+ ///
+ public partial class TestDoneView : UserControl
+ {
+ public TestDoneView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/TestPanelView.xaml b/Views/UserControls/TestPanelView.xaml
deleted file mode 100644
index 59f0d0d..0000000
--- a/Views/UserControls/TestPanelView.xaml
+++ /dev/null
@@ -1,390 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Views/UserControls/TestPanelView.xaml.cs b/Views/UserControls/TestPanelView.xaml.cs
deleted file mode 100644
index 3a056e1..0000000
--- a/Views/UserControls/TestPanelView.xaml.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using System.Windows.Controls;
-
-namespace HC_APTBS.Views.UserControls
-{
- ///
- /// Interaction logic for TestPanelView.xaml.
- /// All logic resides in .
- ///
- public partial class TestPanelView : UserControl
- {
- /// Initialises a new instance of the class.
- public TestPanelView()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/Views/UserControls/TestPlanView.xaml b/Views/UserControls/TestPlanView.xaml
new file mode 100644
index 0000000..6f08481
--- /dev/null
+++ b/Views/UserControls/TestPlanView.xaml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/TestPlanView.xaml.cs b/Views/UserControls/TestPlanView.xaml.cs
new file mode 100644
index 0000000..3b2d39b
--- /dev/null
+++ b/Views/UserControls/TestPlanView.xaml.cs
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..cbef64e
--- /dev/null
+++ b/Views/UserControls/TestPreconditionsView.xaml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/TestPreconditionsView.xaml.cs b/Views/UserControls/TestPreconditionsView.xaml.cs
new file mode 100644
index 0000000..bca4623
--- /dev/null
+++ b/Views/UserControls/TestPreconditionsView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Preconditions checklist for the Tests page wizard (§4b).
+ /// DataContext is expected to be a .
+ ///
+ public partial class TestPreconditionsView : UserControl
+ {
+ public TestPreconditionsView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/TestRunningView.xaml b/Views/UserControls/TestRunningView.xaml
new file mode 100644
index 0000000..ba14d2c
--- /dev/null
+++ b/Views/UserControls/TestRunningView.xaml
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/TestRunningView.xaml.cs b/Views/UserControls/TestRunningView.xaml.cs
new file mode 100644
index 0000000..79a052e
--- /dev/null
+++ b/Views/UserControls/TestRunningView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Running step of the Tests wizard — live phase progress, flowmeter charts, abort.
+ /// DataContext is expected to be a .
+ ///
+ public partial class TestRunningView : UserControl
+ {
+ public TestRunningView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/TestSectionView.xaml b/Views/UserControls/TestSectionView.xaml
new file mode 100644
index 0000000..f439d64
--- /dev/null
+++ b/Views/UserControls/TestSectionView.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/TestSectionView.xaml.cs b/Views/UserControls/TestSectionView.xaml.cs
new file mode 100644
index 0000000..e9dabbf
--- /dev/null
+++ b/Views/UserControls/TestSectionView.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// One test section — Expander header plus the horizontal row of phase cards.
+ /// DataContext is expected to be a .
+ ///
+ public partial class TestSectionView : UserControl
+ {
+ public TestSectionView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Views/UserControls/UnlockPanelView.xaml b/Views/UserControls/UnlockPanelView.xaml
new file mode 100644
index 0000000..8afe2f5
--- /dev/null
+++ b/Views/UserControls/UnlockPanelView.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/UserControls/UnlockPanelView.xaml.cs b/Views/UserControls/UnlockPanelView.xaml.cs
new file mode 100644
index 0000000..d5391a0
--- /dev/null
+++ b/Views/UserControls/UnlockPanelView.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace HC_APTBS.Views.UserControls
+{
+ ///
+ /// Inline unlock panel (Pump page §3.e). DataContext is the
+ /// shared UnlockProgressViewModel also driving the floating
+ /// progress dialog.
+ ///
+ public partial class UnlockPanelView : UserControl
+ {
+ public UnlockPanelView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/docs/gap-test-running-controls.md b/docs/gap-test-running-controls.md
new file mode 100644
index 0000000..65a6c3a
--- /dev/null
+++ b/docs/gap-test-running-controls.md
@@ -0,0 +1,167 @@
+# Gap: Tests page — Pause / Retry-phase / Skip-phase controls
+
+## Context
+
+The Tests wizard (`Plan → Preconditions → Running → Done`) introduces three controls
+in the Running step's bottom bar:
+
+| Control | Purpose |
+|---|---|
+| **Pause** | Temporarily halts phase progression — bench coasts to a safe-RPM hold, keep-alive and CAN traffic continue, timers freeze. |
+| **Retry current phase** | Aborts the current phase (without failing it), zeroes pump outputs, restarts the same phase from conditioning. |
+| **Skip current phase** | Marks the current phase as "skipped" (neither passed nor failed) and advances to the next enabled phase. |
+
+At the time of writing these three buttons exist in `Views/UserControls/TestRunningView.xaml`
+rendered `IsEnabled="False"` with a `Test.Running.ComingSoon` tooltip. This document
+captures the full requirements for a follow-up task that wires them to `IBenchService`.
+
+## Required `IBenchService` API additions
+
+```csharp
+///
+/// Pauses the currently running test. The bench holds at idle RPM, oil pump stays on,
+/// keep-alive continues. Phase timers are frozen. No-op if not running.
+///
+Task PauseAsync(CancellationToken ct = default);
+
+///
+/// Resumes a paused test. Re-arms phase timers from where they were frozen.
+/// Throws if not paused.
+///
+Task ResumeAsync(CancellationToken ct = default);
+
+///
+/// Aborts the current phase (without marking it failed), zeros pump outputs
+/// (ME/FBKW/PreIn → 0), and restarts the same phase from the beginning of
+/// its conditioning sub-section. No-op if not running.
+///
+Task RetryCurrentPhaseAsync(CancellationToken ct = default);
+
+///
+/// Marks the current phase as "skipped" and advances to the next enabled phase.
+/// Pump outputs are zeroed between phases. No-op if at the last enabled phase
+/// (in that case the test completes normally).
+///
+Task SkipPhaseAsync(CancellationToken ct = default);
+```
+
+All four methods must be callable from a UI thread and must **not** block — they
+queue a transition request onto the existing `BenchService` worker loop and await
+completion via a `TaskCompletionSource`.
+
+## Safe-state expectations per operation
+
+### Pause
+- RPM: ramp to **idle hold** (configurable, default 600 rpm) using the existing ramp logic.
+- Oil pump relay: stays on.
+- Pump outputs: unchanged (paused at current values).
+- Counter / encoder relays: unchanged.
+- Keep-alive loop: continues.
+- Phase countdown: frozen in place. Timer resumes on `ResumeAsync`.
+- Measurement buffer: continues accumulating samples while paused? **Decision needed** —
+ current recommendation is to discard samples received while paused, so tolerance
+ evaluation uses only "active" measurement time.
+
+### Resume
+- RPM: ramp from idle-hold back to the current phase's target RPM before un-freezing timers.
+- Un-freeze phase timers only **after** RPM is within ±5% of target for ≥500 ms (same
+ condition the initial conditioning ramp uses).
+
+### Retry
+- Pump outputs: **zeroed immediately** (ME=0, FBKW=0, PreIn=0). This addresses the
+ HIGH-priority gap in CLAUDE.md ("Pump parameters (ME/FBKW/PreIn) not zeroed
+ between test phases") for the retry path specifically.
+- Phase: same phase re-entered at start of conditioning.
+- Results: any partial samples for the retried phase are discarded. `TestResult.Samples`
+ for that phase is cleared.
+- `PhaseChanged` event fires with the same phase name again so the UI re-highlights the card.
+
+### Skip
+- Pump outputs: zeroed.
+- Phase disposition: `TestResult` row is inserted with `Status = Skipped` (new enum value
+ on `TestResult.Status` or existing `Pass/Fail` with a separate `IsSkipped` flag).
+- Progress: next enabled phase in the same test, or next enabled test if this was the last
+ phase. If no enabled phase follows, the test completes as normal (`IsTestRunning = false`).
+
+## UI state transitions
+
+| Button | Enabled when | Label change |
+|---|---|---|
+| Pause | `IsTestRunning && !IsPaused` | becomes "Resume" (`Test.Running.Resume`) while paused |
+| Retry | `IsTestRunning && !IsPaused` | static — no relabel |
+| Skip | `IsTestRunning && !IsPaused` | static |
+| Abort | always while running (paused or not) | static |
+
+When paused, the Retry and Skip buttons are **disabled** to simplify the state machine
+(operator must Resume first, then Retry/Skip). This keeps the BenchService state space
+small (Running ↔ Paused; Retry/Skip only transition inside Running).
+
+The **Abort confirm dialog** already used for Abort should be reused for **Skip**
+but with different title/message (`Test.Skip.ConfirmTitle`, `Test.Skip.ConfirmMessage`).
+Retry does **not** require confirmation — it's a non-destructive restart of the current
+phase only.
+
+## Interaction with PID temperature control
+
+PID loop (`BenchService.TemperatureLoopAsync`) should **continue** during all four
+operations — temperature is an environmental condition, not a test output. The oil-pump
+interlock (`IsOilPumpOn`) must stay satisfied throughout; a Pause that loses the oil
+pump (e.g. operator toggles the relay via the Bench page) must **abort** the test
+through the existing safety path, not silently resume with oil off.
+
+## Interaction with the oil-pump safety interlock
+
+The "no critical alarms" precondition is evaluated at Preconditions-step entry only.
+Once Running, the existing `DashboardAlarmsViewModel.HasCritical` observer is the
+authoritative safety check. Pause/Retry/Skip do **not** bypass it — any of the four
+operations should fail (throw) if a critical alarm is latched when invoked, surfacing
+the same warning banner the existing Running state already shows.
+
+## Localization keys
+
+Already added to `Resources/Strings.en.xaml` and `Resources/Strings.es.xaml`:
+
+- `Test.Running.Pause`
+- `Test.Running.Resume`
+- `Test.Running.Retry`
+- `Test.Running.Skip`
+- `Test.Running.ComingSoon` (tooltip while buttons remain disabled)
+- `Test.Running.Abort`
+
+To add when Skip confirmation lands:
+- `Test.Skip.ConfirmTitle`
+- `Test.Skip.ConfirmMessage`
+- `Test.Skip.Confirm`
+- `Test.Skip.Cancel`
+
+## Relation to existing HIGH-priority gap
+
+CLAUDE.md ("Pump parameters (ME/FBKW/PreIn) not zeroed between test phases") is the
+broader gap. This document's Retry path implements the zero-between-phases behaviour
+for the retry transition specifically. The general phase-to-phase zeroing in
+`BenchService.RunPhaseAsync` must also be fixed — tracked separately.
+
+## Manual verification checklist
+
+1. Start a multi-phase test. During a measurement phase click **Pause** — RPM drops
+ to idle, countdown freezes, button relabels to "Resume". Flow charts stop
+ advancing, oil pump stays on, no critical alarm.
+2. Click **Resume** — RPM ramps back to the phase target, countdown unfreezes from
+ the exact value, label returns to "Pause".
+3. During a measurement phase with partial samples, click **Retry** — pump outputs
+ zero, phase restarts at conditioning, card highlight refreshes, previous samples
+ cleared from `TestResult`.
+4. Click **Skip** on the second of three enabled phases — confirm dialog opens
+ (`Test.Skip.ConfirmMessage`). Cancel: test continues. Confirm: phase disposition
+ set to Skipped, pump outputs zeroed, next enabled phase starts conditioning.
+5. Skip the final enabled phase — test completes, transitions to Done step.
+6. Trigger a critical alarm mid-Pause — test aborts via the existing critical-alarm
+ path, Done step shows "Failed — safety stop".
+7. Toggle language mid-Pause — button text updates to localized "Resume" / "Pause".
+8. Unplug CAN during Pause — CAN-liveness watchdog aborts the test.
+
+## Out of scope (acknowledged)
+
+- Skipping / retrying a phase while paused (requires Resume first).
+- A Skip-all-remaining-phases shortcut.
+- A "Retry this test" that restarts from the first phase of the current test.
diff --git a/docs/ui-structure.md b/docs/ui-structure.md
new file mode 100644
index 0000000..6df79f7
--- /dev/null
+++ b/docs/ui-structure.md
@@ -0,0 +1,347 @@
+# HC_APTBS — UI Structure Reference
+
+Defines the top-level page structure, operator intent, content scope, and design
+guidelines for each page. Use this as the authoritative reference when designing
+or implementing any page or UserControl.
+
+---
+
+## Guiding principles
+
+- **Operator intent first.** Navigation reflects what the operator is trying to
+ accomplish, not which components exist or which service owns a feature.
+- **Separation of concerns.** Bench hardware and ECU diagnostics are logically
+ different domains and live on different pages — never mix them.
+- **Progressive disclosure.** Dashboard is read-first. Controls deepen as the
+ operator navigates inward. Settings are last and guarded.
+- **State-driven flow.** The Test page is not a free-form panel — it guides the
+ operator through a linear sequence: Plan → Preconditions → Running → Done.
+- **Safety is visible.** Alarms, interlocks, and precondition failures are
+ surfaced prominently, never hidden in status bars or tooltips.
+
+---
+
+## Navigation layout
+
+```
+┌──────┬─────────────────────────────────────────┐
+│ │ │
+│ ⌂ │ PAGE CONTENT │
+│ ⚙B │ │
+│ ⚡E │ │
+│ ▶T │ │
+│ ■R │ │
+│ ── │ │
+│ ⚙S │ │
+└──────┴─────────────────────────────────────────┘
+```
+
+Left sidebar, icon + label, collapsed to icon-only below a width threshold.
+Settings pinned to the bottom, separated by a divider.
+
+Global status bar at the top of the content area (not the sidebar):
+CAN heartbeat indicator · K-Line session indicator · alarm count badge ·
+logged-in user · app version.
+
+---
+
+## Pages
+
+### 1. Dashboard
+
+**Intent:** Confirm the bench is ready before doing anything.
+
+**Design tone:** Read-first. Dense information, minimal interaction. Should be
+readable at a glance from across the workbench.
+
+**Content:**
+
+| Section | Detail |
+|---------|--------|
+| Bench state | RPM (live), T-in, T-out, T-tank, P1, P2, QDelivery |
+| Connection status | CAN: last frame timestamp / "offline"; K-Line: session open / closed |
+| Active alarms | List with criticality badge (critical / warning / info). Empty state = green banner. |
+| Test summary | Active test name + current phase, or last completed test name + overall pass/fail |
+| Quick actions | **Start Test** (→ Test page), **Stop**, **Emergency Stop** |
+
+**Guidelines:**
+- E-Stop is the only destructive control allowed here. Style it as red, always
+ visible, never hidden by scroll.
+- No configuration inputs on this page.
+- Alarm list shows the full description text, not just a bit number.
+- Offline indicators use a distinct color (e.g. amber) separate from alarm red.
+- Quick-action buttons are disabled with a tooltip when prerequisites are unmet
+ (e.g. Start Test disabled if no pump selected or CAN offline).
+
+**Existing components to embed:** none — this page is new.
+
+---
+
+### 2. Bench
+
+**Intent:** Manually drive the hardware. Feels like an HMI panel.
+
+**Design tone:** Control-heavy, tactile. Large inputs, clear feedback, live
+values always visible alongside their controls.
+
+**Content:**
+
+| Group | Controls / Displays |
+|-------|---------------------|
+| RPM / Drive | Target RPM input, ramp rate, Start/Stop drive, direction toggle |
+| Temperature | T-in / T-out / T-tank live readings, cooler relay, heater relay, PID setpoint |
+| Pressure / Flow | P1, P2 live gauges, QDelivery, QOver, oil pump relay |
+| Encoder / Angle | PSG, INJ, Manual angle live displays, Lock Angle input |
+| Relay outputs | DepositCooler, DepositHeater, Counter, Pulse4Signal, Flasher — toggle buttons with state indicator |
+| Live plots | Flowmeter chart (QDelivery, QOver vs time), pressure trace — always rendering |
+
+**Guidelines:**
+- No ECU concepts on this page (no ME, FBKW, DFI, K-Line).
+- Relay buttons must show current state (on/off) as well as the toggle action.
+- Live plots persist while the operator adjusts controls — they are not
+ collapsible or tab-hidden.
+- RPM input should warn (not block) if above configured safety limit
+ (`AppSettings.MaxRpm`).
+- Oil pump interlock: if oil pump relay is off and RPM > threshold, show
+ a dismissible inline warning (not a blocking dialog).
+
+**Existing components to embed:**
+- `BenchControlView` (RPM, direction, oil pump)
+- `BenchParamConfigView` (bench parameter live readings)
+- `FlowmeterChartView` (always-on in manual context)
+- `AngleDisplayView` (encoder monitoring)
+
+---
+
+### 3. ECU
+
+**Intent:** Diagnose and interact with the pump's electronic control unit via
+K-Line. Logically separate from the mechanical bench.
+
+**Design tone:** Diagnostic tool. Tabular data, structured forms, raw values
+accessible but not leading.
+
+**Sub-sections (tabs or left sub-nav within the page):**
+
+#### 3a. Identification
+- Pump model search/select from database
+- K-Line read: serial number, model reference, SW version
+- BIP status
+- "Read ECU" button — requires K-Line session open
+
+#### 3b. DTCs
+- Fault code list (code, description, freeze-frame if available)
+- Read DTCs / Clear DTCs actions
+- Error history log with timestamps
+
+#### 3c. Live Data
+- ECU variable table: ME, FBKW, Temp, Tein, Status word
+- Status word bit grid (bit index, label, state, color-coded per
+ `PumpStatusDefinition`)
+- Refresh rate selector
+
+#### 3d. Adaptation
+- ME / FBKW / PreIn sliders (manual ECU output control)
+- DFI read / write / auto-adjust
+- Requires user authentication before write actions
+
+#### 3e. Unlock *(Ford VP44 only)*
+- Unlock type selector (Type 1 / Type 2)
+- Phase 1 countdown (600.5 s) with progress bar
+- Phase 2 TestUnlock with live status
+- Verification result display
+- Full UI spec in `docs/gap-ford-unlock-ui.md`
+
+**Guidelines:**
+- K-Line session state (open / closed) is always shown at the top of this page.
+ If closed, write actions are disabled with a "Session not open" tooltip.
+- Adaptation and Unlock sub-sections require authenticated user
+ (`UserCheckDialog`).
+- Raw K-Line bytes are shown only in a collapsible "Engineering" panel — not
+ in the primary view.
+- Pump selection (3a) is a prerequisite for everything else. If no pump is
+ selected, show a banner on all other sub-sections.
+
+**Existing components to embed:**
+- `PumpIdentificationView` → 3a
+- `StatusDisplayView` → 3c
+- `DfiManageView` → 3d
+- `PumpControlView` (ME/FBKW/PreIn) → 3d
+
+---
+
+### 4. Test
+
+**Intent:** Run the automated test procedure. Procedural and task-oriented.
+
+**Design tone:** Wizard-like linear flow. The operator should always know where
+they are in the sequence and what comes next.
+
+**State machine — sub-sections are sequential, not freely navigable:**
+
+```
+[Plan] ──► [Preconditions] ──► [Running] ──► [Done]
+ (blocked if (blocked (summary,
+ checklist until all → Results)
+ fails) checks pass)
+```
+
+#### 4a. Plan
+- Test suite dropdown (selects from pump's `TestDefinition` list)
+- Phase list: enable/disable individual phases, view tolerance bands
+- Estimated duration
+- "Next: Check Preconditions" button
+
+#### 4b. Preconditions
+Enforced checklist before the sequence starts. Each item is checked
+automatically where possible, or confirmed manually:
+
+| Check | Source |
+|-------|--------|
+| Pump selected | ECU page — pump DB record loaded |
+| CAN bus live | `PcanAdapter` heartbeat |
+| K-Line session open | `KwpService` state |
+| RPM = 0 | `BenchParameterNames.RPM` |
+| Oil pump on | Relay state |
+| No critical alarms | Alarm list |
+| User authenticated | If test requires auth |
+
+Start button is disabled until all required checks pass. Failed checks
+show inline fix links (e.g. "Go to Bench → start oil pump").
+
+#### 4c. Running
+- Phase timeline: vertical step list, current phase highlighted
+- Live measurement table: parameter, current value, tolerance band, pass/fail
+- Embedded: `FlowmeterChartView` + `AngleDisplayView` (visible during run)
+- Controls: **Pause**, **Abort**, **Retry current phase**, **Skip phase**
+- Running timer (elapsed + estimated remaining)
+
+#### 4d. Done
+- Inline summary: overall pass/fail, phase-by-phase verdict
+- "View Full Results" → navigates to Results page
+- "Run Again" → back to 4a (Plan) with same configuration
+
+**Guidelines:**
+- Back-navigation within the state machine is allowed (Plan ↔ Preconditions)
+ but not once Running has started unless the test is aborted.
+- Abort requires confirmation dialog.
+- QOver zero-flow safety: if `QOver == 0` while RPM > 300 and oil pump is on,
+ trigger emergency stop and display a blocking alert (see Known gaps in
+ CLAUDE.md — HIGH priority).
+- Pump parameters (ME/FBKW/PreIn) must be zeroed between phases
+ (see Known gaps — HIGH priority).
+- Per-sample real-time UI callback must update the live measurement table
+ during Running (see Known gaps — HIGH priority).
+
+**Existing components to embed:**
+- `TestPanelView` → 4a (Plan)
+- `TestDisplayView` → 4c (Running)
+- `FlowmeterChartView` → 4c (Running, inline)
+- `AngleDisplayView` → 4c (Running, inline)
+- `ResultDisplayView` → 4d (Done, summary) + Results page (full)
+
+---
+
+### 5. Results
+
+**Intent:** Review, compare, and document completed tests.
+
+**Design tone:** Data-focused. Clean tables, exportable. No live hardware data.
+
+**Content:**
+
+| Section | Detail |
+|---------|--------|
+| History list | Completed tests: date, pump model, serial, overall pass/fail |
+| Result detail | Selected test: per-phase table, per-parameter pass/fail, measured vs tolerance |
+| Trend / comparison | Same pump over multiple runs — delta on key parameters (future) |
+| Report preview | PDF rendered inline or in a preview pane |
+| Export | PDF export, notes/observations field before export |
+
+**Guidelines:**
+- History is read-only. No controls that affect the bench or ECU.
+- PDF export requires a report dialog (operator name, client, notes) before
+ generating. Reuse `ReportDialog` / `ReportViewModel`.
+- Observations/notes field must be present before PDF generation
+ (see Known gaps in CLAUDE.md — MEDIUM priority).
+- Empty state (no completed tests this session): show a prompt to run a test.
+- Test history persistence across sessions requires a future storage layer
+ (not yet implemented — scope to session-only for now).
+
+**Existing components to embed:**
+- `ResultDisplayView` (full detail view)
+- `ReportDialog` (triggered by Export action)
+
+---
+
+### 6. Settings
+
+**Intent:** Infrequent configuration. Not for daily operators unless necessary.
+
+**Design tone:** Form-based. Grouped, labeled, validated. No live hardware
+data visible here.
+
+**Sub-sections:**
+
+| Section | Content |
+|---------|---------|
+| Machine | CAN bitrate, K-Line COM port, encoder mode, motor direction |
+| Sensors | ADC calibration per channel (min/max voltage → engineering units) |
+| Limits & Protection | Safety RPM max, alarm criticality reactions, PID tuning constants |
+| Alarms | Alarm bit definitions, descriptions, criticality levels |
+| Users | User list, roles, password management (future: hashed) |
+| Report | Company name, logo, branding colors, default template |
+| Storage | Config file paths, backup/restore |
+
+**Guidelines:**
+- Changes are not applied until "Save" is clicked — no live-apply side effects.
+- Sensor calibration changes require confirmation (affects all future readings).
+- User management (`UserManageDialog`) is accessible from here.
+- Settings are not accessible while a test is Running (nav item disabled or
+ shows a "test in progress" tooltip).
+- All inputs use `CultureInfo.InvariantCulture` for double parsing (see Rules
+ in CLAUDE.md).
+
+**Existing components to embed:**
+- `SettingsDialog` content (migrate from dialog to full page)
+- `DfiManageView` is in ECU > Adaptation, not here
+
+---
+
+## Component placement summary
+
+| UserControl / Dialog | Page | Sub-section |
+|----------------------|------|-------------|
+| `BenchControlView` | Bench | RPM / Drive + Relays |
+| `BenchParamConfigView` | Bench | Pressure / Flow readings |
+| `FlowmeterChartView` | Bench (manual) · Test (running) | Live plots · 4c Running |
+| `AngleDisplayView` | Bench · Test (running) | Encoder group · 4c Running |
+| `PumpIdentificationView` | ECU | 3a Identification |
+| `StatusDisplayView` | ECU | 3c Live Data |
+| `DfiManageView` | ECU | 3d Adaptation |
+| `PumpControlView` | ECU | 3d Adaptation |
+| `TestPanelView` | Test | 4a Plan |
+| `TestDisplayView` | Test | 4c Running |
+| `ResultDisplayView` | Test (summary) · Results (full) | 4d Done · detail view |
+| `ReportDialog` | Results | Export action |
+| `UserCheckDialog` | ECU (auth gate) · Test (auth gate) | on write actions |
+| `UserManageDialog` | Settings | Users sub-section |
+| `KlineErrorsDialog` | ECU | triggered on DTC read errors |
+| `ProgressDialog` | ECU · Test | long K-Line operations, unlock phase |
+| `UnlockProgressDialog` | ECU | 3e Unlock |
+| `OilPumpConfirmDialog` | Bench · Test (preconditions) | oil pump interlock |
+| `RpmSafetyWarningDialog` | Bench · Test | RPM limit breach |
+| `VoltageWarningDialog` | Bench | analog sensor out of range |
+
+---
+
+## Pages to build (status)
+
+| Page | Status | Notes |
+|------|--------|-------|
+| Dashboard | **Not started** | New page, new ViewModel |
+| Bench | `BenchPage.xaml` exists | Expand with live plots, angle view |
+| ECU | `PumpPage.xaml` exists | Rename, add sub-sections 3b–3e |
+| Test | `TestsPage.xaml` exists | Add preconditions state machine |
+| Results | **Not started** | New page, new ViewModel |
+| Settings | Partial (`SettingsDialog`) | Migrate from dialog to full page |