feat: add Ford VP44 unlock progress dialog, K-Line fast unlock, localization, safety dialogs, and settings

Unlock progress UI:
- UnlockProgressDialog with dark-themed progress ring, phase indicator, elapsed
  time, and cancel/close buttons (non-modal, draggable borderless window)
- UnlockProgressViewModel with event-driven progress tracking via IUnlockService
- Triggers on pump selection (manual or K-Line auto-detect), not test start

UnlockService rewrite:
- Persistent CAN senders that outlive the unlock sequence (StopSenders on pump change)
- Concurrent K-Line fast unlock: awaits session Connected, sends RAM timer shortcut
  ({02 88 02 03 A8 01 00}), verifies via CAN TestUnlock before skipping wait
- Fix Type 1 verification (Value == 0 means unlocked, was inverted)

K-Line fast unlock support:
- IKwpService.TryFastUnlockAsync / KwpService implementation

Additional features:
- ILocalizationService with ES/EN resource dictionaries and runtime switching
- Safety dialogs: VoltageWarning, OilPumpConfirm, RpmSafetyWarning
- SettingsDialog for app configuration
- BenchService enhancements, ConfigurationService improvements, PDF report updates
- All UI strings localized via DynamicResource

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:22:48 +02:00
parent c617854c09
commit 37d099cdbd
55 changed files with 3207 additions and 379 deletions

View File

@@ -3,5 +3,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:HC_APTBS"> xmlns:local="clr-namespace:HC_APTBS">
<Application.Resources> <Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Strings.es.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources> </Application.Resources>
</Application> </Application>

View File

@@ -69,6 +69,7 @@ public partial class App : Application
services.AddSingleton<IUnlockService, UnlockService>(); services.AddSingleton<IUnlockService, UnlockService>();
services.AddSingleton<ICalibrationService, CalibrationService>(); services.AddSingleton<ICalibrationService, CalibrationService>();
services.AddSingleton<IPdfService, PdfService>(); services.AddSingleton<IPdfService, PdfService>();
services.AddSingleton<ILocalizationService, LocalizationService>();
// ── ViewModels ──────────────────────────────────────────────────────── // ── ViewModels ────────────────────────────────────────────────────────
services.AddSingleton<MainViewModel>(); services.AddSingleton<MainViewModel>();

View File

@@ -83,11 +83,14 @@ ViewModels/ — [ObservableProperty] / [RelayCommand], no UI lo
FlowmeterChartViewModel.cs — Real-time flowmeter charts FlowmeterChartViewModel.cs — Real-time flowmeter charts
AngleDisplayViewModel.cs — Encoder angle monitoring (PSG, INJ, Manual, Lock Angle) AngleDisplayViewModel.cs — Encoder angle monitoring (PSG, INJ, Manual, Lock Angle)
StatusDisplayViewModel.cs — Pump status word display StatusDisplayViewModel.cs — Pump status word display
Dialogs/ — KlineErrors, Progress, Report, UserCheck ViewModels Dialogs/ — KlineErrors, OilPumpConfirm, Progress, Report, RpmSafetyWarning,
Settings, UnlockProgress, UserCheck, VoltageWarning ViewModels
Views/ Views/
MainWindow.xaml — Root UI (multi-panel layout) MainWindow.xaml — Root UI (multi-panel layout)
Dialogs/ — KlineErrorsDialog, ProgressDialog, ReportDialog, UserCheckDialog Dialogs/ — KlineErrorsDialog, OilPumpConfirmDialog, ProgressDialog,
ReportDialog, RpmSafetyWarningDialog, SettingsDialog,
UnlockProgressDialog, UserCheckDialog, VoltageWarningDialog
UserControls/ — AngleDisplay, BenchParamConfig, DfiManage, FlowmeterChart, UserControls/ — AngleDisplay, BenchParamConfig, DfiManage, FlowmeterChart,
PumpControl, PumpIdentification, ResultDisplay, StatusDisplay, PumpControl, PumpIdentification, ResultDisplay, StatusDisplay,
TestDisplay, TestPanel TestDisplay, TestPanel
@@ -147,14 +150,11 @@ See `docs/` guidelines for full specs. Priority: CRITICAL > HIGH > MEDIUM > LOW.
**HIGH — Missing safety features:** **HIGH — Missing safety features:**
- No QOver zero-flow safety check (old: emergency stop if QOver==0 while RPM>300 + oil pump on) - No QOver zero-flow safety check (old: emergency stop if QOver==0 while RPM>300 + oil pump on)
- No safety dialogs: 27V warning, oil pump confirmation, RPM warning
- Alarm bit collection during tests not wired up (`PhaseDefinition.RecordErrorBit` never called) - Alarm bit collection during tests not wired up (`PhaseDefinition.RecordErrorBit` never called)
- No per-sample real-time UI callback during measurement (old fired per-sample events for live charts) - No per-sample real-time UI callback during measurement (old fired per-sample events for live charts)
- Pump parameters (ME/FBKW/PreIn) not zeroed between test phases - Pump parameters (ME/FBKW/PreIn) not zeroed between test phases
**HIGH — Missing features:** **HIGH — Missing features:**
- Ford unlock progress UI (service exists, no View — old had WUnlocker.xaml with visual ring + progress bar)
- No localization system (old had Spanish/English resource dictionaries with runtime switching)
- No encryption (old encrypted user passwords with AES-256 + pump data with Rijndael; new stores plaintext) - No encryption (old encrypted user passwords with AES-256 + pump data with Rijndael; new stores plaintext)
- No KlineIDs auto-mapping (old remembered K-Line ID → pump ID associations) - No KlineIDs auto-mapping (old remembered K-Line ID → pump ID associations)

View File

@@ -7,7 +7,7 @@
xmlns:uc="clr-namespace:HC_APTBS.Views.UserControls" xmlns:uc="clr-namespace:HC_APTBS.Views.UserControls"
xmlns:models="clr-namespace:HC_APTBS.Models" xmlns:models="clr-namespace:HC_APTBS.Models"
mc:Ignorable="d" mc:Ignorable="d"
Title="HC_APTBS — Herlic Test Bench" Title="{DynamicResource App.Title}"
Height="1080" Width="1920" Height="1080" Width="1920"
WindowState="Maximized" WindowState="Maximized"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
@@ -68,7 +68,8 @@
<!-- ── Menu bar ──────────────────────────────────────────────────── --> <!-- ── Menu bar ──────────────────────────────────────────────────── -->
<Menu DockPanel.Dock="Top" Background="#FFEDEDED"> <Menu DockPanel.Dock="Top" Background="#FFEDEDED">
<MenuItem Header="Settings" Command="{Binding SaveSettingsCommand}"/> <MenuItem Header="{DynamicResource Menu.Settings}" Command="{Binding OpenSettingsCommand}"/>
<MenuItem Header="{DynamicResource App.LanguageLabel}" Command="{Binding ToggleLanguageCommand}"/>
</Menu> </Menu>
<!-- ── Status bar ─────────────────────────────────────────────────── --> <!-- ── Status bar ─────────────────────────────────────────────────── -->
@@ -105,7 +106,7 @@
<!-- ══════════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════════
LEFT PANEL — bench status and controls LEFT PANEL — bench status and controls
══════════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════════ -->
<Expander Header="Bench" IsExpanded="True" Margin="0,2,0,0"> <Expander Header="{DynamicResource Bench.Header}" IsExpanded="True" Margin="0,2,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="5"> <Grid Margin="5">
<Grid.RowDefinitions> <Grid.RowDefinitions>
@@ -126,7 +127,7 @@
<ColumnDefinition/> <ColumnDefinition/>
<ColumnDefinition/> <ColumnDefinition/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Status:" VerticalAlignment="Center" <TextBlock Text="{DynamicResource Status.Label}" VerticalAlignment="Center"
FontSize="10" Margin="0,0,6,0"/> FontSize="10" Margin="0,0,6,0"/>
<Border Grid.Column="1"> <Border Grid.Column="1">
@@ -139,7 +140,7 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Border.Style> </Border.Style>
<TextBlock Text="CAN" HorizontalAlignment="Center" <TextBlock Text="{DynamicResource Status.Can}" HorizontalAlignment="Center"
FontSize="10" Padding="2"/> FontSize="10" Padding="2"/>
</Border> </Border>
@@ -153,7 +154,7 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Border.Style> </Border.Style>
<TextBlock Text="Bench" HorizontalAlignment="Center" <TextBlock Text="{DynamicResource Status.Bench}" HorizontalAlignment="Center"
FontSize="10" Padding="2"/> FontSize="10" Padding="2"/>
</Border> </Border>
@@ -167,7 +168,7 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Border.Style> </Border.Style>
<TextBlock Text="Pump" HorizontalAlignment="Center" <TextBlock Text="{DynamicResource Status.Pump}" HorizontalAlignment="Center"
FontSize="10" Padding="2"/> FontSize="10" Padding="2"/>
</Border> </Border>
@@ -184,7 +185,7 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Border.Style> </Border.Style>
<TextBlock Text="K-Line" HorizontalAlignment="Center" <TextBlock Text="{DynamicResource Status.KLine}" HorizontalAlignment="Center"
FontSize="10" Padding="2"/> FontSize="10" Padding="2"/>
</Border> </Border>
</Grid> </Grid>
@@ -192,9 +193,9 @@
<!-- ── Row 1: CAN connect / disconnect ─────────────────── --> <!-- ── Row 1: CAN connect / disconnect ─────────────────── -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4"> <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4">
<Button Content="Connect CAN" Width="110" Margin="0,0,6,0" <Button Content="{DynamicResource Bench.ConnectCan}" Width="110" Margin="0,0,6,0"
Command="{Binding ConnectCanCommand}"/> Command="{Binding ConnectCanCommand}"/>
<Button Content="Disconnect CAN" Width="120" <Button Content="{DynamicResource Bench.DisconnectCan}" Width="120"
Command="{Binding DisconnectCanCommand}" Command="{Binding DisconnectCanCommand}"
IsEnabled="{Binding IsCanConnected}"/> IsEnabled="{Binding IsCanConnected}"/>
</StackPanel> </StackPanel>
@@ -222,14 +223,14 @@
HorizontalAlignment="Right" VerticalAlignment="Center" HorizontalAlignment="Right" VerticalAlignment="Center"
FontFamily="Consolas"/> FontFamily="Consolas"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0"> <StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0">
<TextBlock Text="rpm" FontSize="18" Foreground="#FFFFEB6E"/> <TextBlock Text="{DynamicResource Bench.Rpm}" FontSize="18" Foreground="#FFFFEB6E"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<TextBlock Text="Target:" Foreground="#EBEBFF" FontSize="11" Margin="0,0,4,0"/> <TextBlock Text="{DynamicResource Bench.Target}" Foreground="#EBEBFF" FontSize="11" Margin="0,0,4,0"/>
<TextBlock Text="{Binding BenchControl.TargetRpm, StringFormat=F0}" <TextBlock Text="{Binding BenchControl.TargetRpm, StringFormat=F0}"
Foreground="#FFFFEB6E" FontSize="11" FontFamily="Consolas" Margin="0,0,8,0"/> Foreground="#FFFFEB6E" FontSize="11" FontFamily="Consolas" Margin="0,0,8,0"/>
<TextBlock Text="V:" Foreground="#EBEBFF" FontSize="11" Margin="0,0,4,0"/> <TextBlock Text="{DynamicResource Bench.Voltage}" Foreground="#EBEBFF" FontSize="11" Margin="0,0,4,0"/>
<TextBlock Text="{Binding BenchControl.CommandVoltage, StringFormat=F3}" <TextBlock Text="{Binding BenchControl.CommandVoltage, StringFormat=F3}"
Foreground="#FFFFEB6E" FontSize="11" FontFamily="Consolas"/> Foreground="#FFFFEB6E" FontSize="11" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
@@ -253,12 +254,12 @@
<RowDefinition/> <RowDefinition/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="T. In:" Grid.Row="0" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.TempIn}" Grid.Row="0" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="T. Out:" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.TempOut}" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="T. 4:" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.Temp4}" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="T. Tank:" Grid.Row="3" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.TempTank}" Grid.Row="3" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="P1:" Grid.Row="4" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.P1}" Grid.Row="4" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="P2:" Grid.Row="5" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.P2}" Grid.Row="5" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="{Binding TempIn, StringFormat=F1}" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/> <TextBlock Text="{Binding TempIn, StringFormat=F1}" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
<TextBlock Text="{Binding TempOut, StringFormat=F1}" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/> <TextBlock Text="{Binding TempOut, StringFormat=F1}" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
@@ -288,12 +289,12 @@
<RowDefinition/> <RowDefinition/>
<RowDefinition/> <RowDefinition/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="Q-Del.:" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.QDelivery}" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="13"/>
<TextBlock Text="Q-Over:" Grid.Row="1" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="13"/> <TextBlock Text="{DynamicResource Bench.QOver}" Grid.Row="1" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="13"/>
<TextBlock Text="{Binding QDelivery, StringFormat=F3}" Grid.Column="1" HorizontalAlignment="Right" Foreground="#FFFFEB6E" FontSize="22" FontFamily="Consolas"/> <TextBlock Text="{Binding QDelivery, StringFormat=F3}" Grid.Column="1" HorizontalAlignment="Right" Foreground="#FFFFEB6E" FontSize="22" FontFamily="Consolas"/>
<TextBlock Text="{Binding QOver, StringFormat=F3}" Grid.Column="1" Grid.Row="1" HorizontalAlignment="Right" Foreground="#FFFFEB6E" FontSize="22" FontFamily="Consolas"/> <TextBlock Text="{Binding QOver, StringFormat=F3}" Grid.Column="1" Grid.Row="1" HorizontalAlignment="Right" Foreground="#FFFFEB6E" FontSize="22" FontFamily="Consolas"/>
<TextBlock Text="cc/stroke" Grid.Column="2" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="11" Margin="4,0"/> <TextBlock Text="{DynamicResource Bench.CcStroke}" Grid.Column="2" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="11" Margin="4,0"/>
<TextBlock Text="cc/stroke" Grid.Column="2" Grid.Row="1" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="11" Margin="4,0"/> <TextBlock Text="{DynamicResource Bench.CcStroke}" Grid.Column="2" Grid.Row="1" VerticalAlignment="Center" Foreground="#FFFFEB6E" FontSize="11" Margin="4,0"/>
</Grid> </Grid>
</Border> </Border>
@@ -310,10 +311,10 @@
<RowDefinition/> <RowDefinition/>
<RowDefinition/> <RowDefinition/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="P-RPM:" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/> <TextBlock Text="{DynamicResource Bench.PumpRpm}" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/>
<TextBlock Text="P-Temp:" Grid.Row="1" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/> <TextBlock Text="{DynamicResource Bench.PumpTemp}" Grid.Row="1" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/>
<TextBlock Text="P-ME:" Grid.Row="2" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/> <TextBlock Text="{DynamicResource Bench.PumpMe}" Grid.Row="2" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/>
<TextBlock Text="P-FBkW:" Grid.Row="3" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/> <TextBlock Text="{DynamicResource Bench.PumpFbkw}" Grid.Row="3" VerticalAlignment="Center" FontSize="12" Foreground="DimGray"/>
<TextBlock Text="{Binding PumpRpm, StringFormat=F0}" Grid.Column="1" HorizontalAlignment="Right" FontSize="16" FontFamily="Consolas"/> <TextBlock Text="{Binding PumpRpm, StringFormat=F0}" Grid.Column="1" HorizontalAlignment="Right" FontSize="16" FontFamily="Consolas"/>
<TextBlock Text="{Binding PumpTemp, StringFormat=F1}" Grid.Column="1" Grid.Row="1" HorizontalAlignment="Right" FontSize="16" FontFamily="Consolas"/> <TextBlock Text="{Binding PumpTemp, StringFormat=F1}" Grid.Column="1" Grid.Row="1" HorizontalAlignment="Right" FontSize="16" FontFamily="Consolas"/>
<TextBlock Text="{Binding PumpMe, StringFormat=F2}" Grid.Column="1" Grid.Row="2" HorizontalAlignment="Right" FontSize="16" FontFamily="Consolas"/> <TextBlock Text="{Binding PumpMe, StringFormat=F2}" Grid.Column="1" Grid.Row="2" HorizontalAlignment="Right" FontSize="16" FontFamily="Consolas"/>
@@ -323,7 +324,7 @@
<!-- PSG encoder value --> <!-- PSG encoder value -->
<StackPanel Orientation="Horizontal" Margin="0,4" Visibility="Collapsed"> <StackPanel Orientation="Horizontal" Margin="0,4" Visibility="Collapsed">
<TextBlock Text="PSG Encoder:" VerticalAlignment="Center" FontSize="12" Margin="0,0,8,0"/> <TextBlock Text="{DynamicResource Bench.PsgEncoder}" VerticalAlignment="Center" FontSize="12" Margin="0,0,8,0"/>
<TextBlock Text="{Binding PsgEncoderValue, StringFormat=F2}" <TextBlock Text="{Binding PsgEncoderValue, StringFormat=F2}"
FontSize="16" FontFamily="Consolas" VerticalAlignment="Center"/> FontSize="16" FontFamily="Consolas" VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
@@ -334,15 +335,15 @@
<StackPanel Grid.Column="1" Width="160" Margin="6,0,0,0"> <StackPanel Grid.Column="1" Width="160" Margin="6,0,0,0">
<!-- Direction toggle --> <!-- Direction toggle -->
<TextBlock Text="Direction" FontSize="10" Foreground="DimGray" Margin="0,4,0,2"/> <TextBlock Text="{DynamicResource Bench.Direction}" FontSize="10" Foreground="DimGray" Margin="0,4,0,2"/>
<ToggleButton IsChecked="{Binding BenchControl.IsDirectionRight}" <ToggleButton IsChecked="{Binding BenchControl.IsDirectionRight}"
Height="32" FontSize="12" FontWeight="SemiBold"> Height="32" FontSize="12" FontWeight="SemiBold">
<ToggleButton.Style> <ToggleButton.Style>
<Style TargetType="ToggleButton"> <Style TargetType="ToggleButton">
<Setter Property="Content" Value="LEFT"/> <Setter Property="Content" Value="{DynamicResource Bench.Left}"/>
<Style.Triggers> <Style.Triggers>
<Trigger Property="IsChecked" Value="True"> <Trigger Property="IsChecked" Value="True">
<Setter Property="Content" Value="RIGHT"/> <Setter Property="Content" Value="{DynamicResource Bench.Right}"/>
</Trigger> </Trigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
@@ -350,18 +351,18 @@
</ToggleButton> </ToggleButton>
<!-- Start / Stop bench --> <!-- Start / Stop bench -->
<TextBlock Text="Bench Motor" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/> <TextBlock Text="{DynamicResource Bench.Motor}" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
<Button Content="START" FontSize="13" FontWeight="Bold" Height="36" <Button Content="{DynamicResource Bench.Start}" FontSize="13" FontWeight="Bold" Height="36"
Foreground="DarkGreen" Margin="0,0,0,4" Foreground="DarkGreen" Margin="0,0,0,4"
Command="{Binding BenchControl.OpenRpmPopupCommand}"/> Command="{Binding BenchControl.OpenRpmPopupCommand}"/>
<Popup StaysOpen="False" Placement="Left" <Popup StaysOpen="False" Placement="Left"
IsOpen="{Binding BenchControl.IsRpmPopupOpen, Mode=TwoWay}"> IsOpen="{Binding BenchControl.IsRpmPopupOpen, Mode=TwoWay}">
<Border Background="White" BorderBrush="Black" BorderThickness="1" Padding="8"> <Border Background="White" BorderBrush="Black" BorderThickness="1" Padding="8">
<StackPanel Width="200"> <StackPanel Width="200">
<TextBlock Text="Set RPM:" FontSize="12" Margin="0,0,0,4"/> <TextBlock Text="{DynamicResource Bench.SetRpm}" FontSize="12" Margin="0,0,0,4"/>
<TextBox Text="{Binding BenchControl.RpmInputText, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding BenchControl.RpmInputText, UpdateSourceTrigger=PropertyChanged}"
FontSize="16" FontFamily="Consolas" Height="28" Margin="0,0,0,6"/> FontSize="16" FontFamily="Consolas" Height="28" Margin="0,0,0,6"/>
<Button Content="GO" FontSize="13" FontWeight="Bold" Height="30" <Button Content="{DynamicResource Bench.Go}" FontSize="13" FontWeight="Bold" Height="30"
Foreground="DarkGreen" Margin="0,0,0,6" Foreground="DarkGreen" Margin="0,0,0,6"
Command="{Binding BenchControl.StartBenchCommand}"/> Command="{Binding BenchControl.StartBenchCommand}"/>
<UniformGrid Columns="5"> <UniformGrid Columns="5">
@@ -384,21 +385,21 @@
</StackPanel> </StackPanel>
</Border> </Border>
</Popup> </Popup>
<Button Content="STOP" FontSize="13" FontWeight="Bold" Height="36" <Button Content="{DynamicResource Bench.Stop}" FontSize="13" FontWeight="Bold" Height="36"
Foreground="DarkRed" Foreground="DarkRed"
Command="{Binding BenchControl.StopBenchCommand}"/> Command="{Binding BenchControl.StopBenchCommand}"/>
<!-- Oil pump toggle --> <!-- Oil pump toggle -->
<TextBlock Text="Oil Pump" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/> <TextBlock Text="{DynamicResource Bench.OilPump}" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
<ToggleButton IsChecked="{Binding BenchControl.IsOilPumpOn}" <ToggleButton IsChecked="{Binding BenchControl.IsOilPumpOn}"
Height="32" FontSize="12" FontWeight="SemiBold"> Height="32" FontSize="12" FontWeight="SemiBold">
<ToggleButton.Style> <ToggleButton.Style>
<Style TargetType="ToggleButton"> <Style TargetType="ToggleButton">
<Setter Property="Content" Value="OIL OFF"/> <Setter Property="Content" Value="{DynamicResource Bench.OilOff}"/>
<Setter Property="Background" Value="LightGray"/> <Setter Property="Background" Value="LightGray"/>
<Style.Triggers> <Style.Triggers>
<Trigger Property="IsChecked" Value="True"> <Trigger Property="IsChecked" Value="True">
<Setter Property="Content" Value="OIL ON"/> <Setter Property="Content" Value="{DynamicResource Bench.OilOn}"/>
<Setter Property="Background" Value="#80FF80"/> <Setter Property="Background" Value="#80FF80"/>
</Trigger> </Trigger>
</Style.Triggers> </Style.Triggers>
@@ -407,18 +408,18 @@
</ToggleButton> </ToggleButton>
<!-- Turn counter --> <!-- Turn counter -->
<TextBlock Text="Counter" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/> <TextBlock Text="{DynamicResource Bench.Counter}" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
<ToggleButton Content="Counter" <ToggleButton Content="{DynamicResource Bench.Counter}"
IsChecked="{Binding BenchControl.IsCounterPopupOpen}" IsChecked="{Binding BenchControl.IsCounterPopupOpen}"
Height="28" FontSize="11"/> Height="28" FontSize="11"/>
<Popup StaysOpen="False" Placement="Left" <Popup StaysOpen="False" Placement="Left"
IsOpen="{Binding BenchControl.IsCounterPopupOpen, Mode=TwoWay}"> IsOpen="{Binding BenchControl.IsCounterPopupOpen, Mode=TwoWay}">
<Border Background="White" BorderBrush="Black" BorderThickness="1" Padding="8"> <Border Background="White" BorderBrush="Black" BorderThickness="1" Padding="8">
<StackPanel Width="160"> <StackPanel Width="160">
<TextBlock Text="Turns:" FontSize="12" Margin="0,0,0,4"/> <TextBlock Text="{DynamicResource Bench.Turns}" FontSize="12" Margin="0,0,0,4"/>
<TextBox Text="{Binding BenchControl.CounterInputText, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding BenchControl.CounterInputText, UpdateSourceTrigger=PropertyChanged}"
FontSize="16" FontFamily="Consolas" Height="28" Margin="0,0,0,4"/> FontSize="16" FontFamily="Consolas" Height="28" Margin="0,0,0,4"/>
<Button Content="Send" FontSize="12" Height="28" <Button Content="{DynamicResource Bench.Send}" FontSize="12" Height="28"
Command="{Binding BenchControl.SendCounterCommand}"/> Command="{Binding BenchControl.SendCounterCommand}"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -427,11 +428,11 @@
Text="{Binding BenchControl.BenchCounterValue, StringFormat=00000000}"/> Text="{Binding BenchControl.BenchCounterValue, StringFormat=00000000}"/>
<!-- Relay toggles --> <!-- Relay toggles -->
<TextBlock Text="Relays" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/> <TextBlock Text="{DynamicResource Bench.Relays}" FontSize="10" Foreground="DimGray" Margin="0,8,0,2"/>
<StackPanel> <StackPanel>
<Button Content="Electronic" Style="{StaticResource RelayButton}" Command="{Binding ToggleElectronicCommand}"/> <Button Content="{DynamicResource Bench.Electronic}" Style="{StaticResource RelayButton}" Command="{Binding ToggleElectronicCommand}"/>
<Button Content="Deposit Cooler" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositCoolerCommand}"/> <Button Content="{DynamicResource Bench.DepositCooler}" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositCoolerCommand}"/>
<Button Content="Deposit Heater" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositHeaterCommand}"/> <Button Content="{DynamicResource Bench.DepositHeater}" Style="{StaticResource RelayButton}" Command="{Binding ToggleDepositHeaterCommand}"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -493,9 +494,9 @@
<RowDefinition Height="1.2*"/> <RowDefinition Height="1.2*"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="T-hyb" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Pump.THyb}" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="RPM" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Pump.Rpm}" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="T-ein" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/> <TextBlock Text="{DynamicResource Pump.TEin}" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
<TextBlock Text="{Binding PumpTemp, StringFormat=F2}" Grid.Column="1" <TextBlock Text="{Binding PumpTemp, StringFormat=F2}" Grid.Column="1"
HorizontalAlignment="Right" VerticalAlignment="Center" HorizontalAlignment="Right" VerticalAlignment="Center"
@@ -508,7 +509,7 @@
Foreground="#EBEBFF" FontSize="20" FontWeight="Bold" FontFamily="Consolas"/> Foreground="#EBEBFF" FontSize="20" FontWeight="Bold" FontFamily="Consolas"/>
<TextBlock Text="°C" Grid.Column="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="12" Margin="4,0"/> <TextBlock Text="°C" Grid.Column="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="12" Margin="4,0"/>
<TextBlock Text="1/min" Grid.Column="2" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="12" Margin="4,0"/> <TextBlock Text="{DynamicResource Pump.UnitRpm}" Grid.Column="2" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="12" Margin="4,0"/>
<TextBlock Text="µs" Grid.Column="2" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="12" Margin="4,0"/> <TextBlock Text="µs" Grid.Column="2" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="12" Margin="4,0"/>
</Grid> </Grid>
</Border> </Border>

View File

@@ -136,10 +136,11 @@ namespace HC_APTBS.Models
public string ReportLogoPath { get; set; } = string.Empty; public string ReportLogoPath { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Comma-separated <c>user:password</c> credential pairs for operator authentication /// Comma-separated <c>user:salt:hash</c> credential entries for operator authentication
/// before report generation. /// before report generation. Salt is 16-byte Base64, hash is PBKDF2-HMAC-SHA256 (600 000 iterations, 32-byte output) Base64.
/// Legacy <c>user:password</c> entries are auto-migrated on first load.
/// </summary> /// </summary>
public string Users { get; set; } = "admin:admin"; public string Users { get; set; } = string.Empty;
// ── K-Line port ─────────────────────────────────────────────────────── // ── K-Line port ───────────────────────────────────────────────────────

322
Resources/Strings.en.xaml Normal file
View File

@@ -0,0 +1,322 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
<!-- ══════════════════════════════════════════════════════════════════
English (ENG)
══════════════════════════════════════════════════════════════════ -->
<!-- ── App ──────────────────────────────────────────────────────────── -->
<sys:String x:Key="App.Title">HC_APTBS — Herlic Test Bench</sys:String>
<sys:String x:Key="App.LanguageLabel">ESP</sys:String>
<!-- ── Menu ─────────────────────────────────────────────────────────── -->
<sys:String x:Key="Menu.Settings">Settings</sys:String>
<!-- ── Status bar / connection indicators ───────────────────────────── -->
<sys:String x:Key="Status.Label">Status:</sys:String>
<sys:String x:Key="Status.Can">CAN</sys:String>
<sys:String x:Key="Status.Bench">Bench</sys:String>
<sys:String x:Key="Status.Pump">Pump</sys:String>
<sys:String x:Key="Status.KLine">K-Line</sys:String>
<sys:String x:Key="Status.Connected">Connected</sys:String>
<sys:String x:Key="Status.ConnectionFailed">Connection failed</sys:String>
<sys:String x:Key="Status.Disconnected">Disconnected</sys:String>
<!-- ── Bench section ────────────────────────────────────────────────── -->
<sys:String x:Key="Bench.Header">Bench</sys:String>
<sys:String x:Key="Bench.ConnectCan">Connect CAN</sys:String>
<sys:String x:Key="Bench.DisconnectCan">Disconnect CAN</sys:String>
<sys:String x:Key="Bench.Rpm">rpm</sys:String>
<sys:String x:Key="Bench.Target">Target:</sys:String>
<sys:String x:Key="Bench.Voltage">V:</sys:String>
<sys:String x:Key="Bench.TempIn">T. In:</sys:String>
<sys:String x:Key="Bench.TempOut">T. Out:</sys:String>
<sys:String x:Key="Bench.Temp4">T. 4:</sys:String>
<sys:String x:Key="Bench.TempTank">T. Tank:</sys:String>
<sys:String x:Key="Bench.P1">P1:</sys:String>
<sys:String x:Key="Bench.P2">P2:</sys:String>
<sys:String x:Key="Bench.QDelivery">Q-Del.:</sys:String>
<sys:String x:Key="Bench.QOver">Q-Over:</sys:String>
<sys:String x:Key="Bench.CcStroke">cc/stroke</sys:String>
<sys:String x:Key="Bench.PumpRpm">P-RPM:</sys:String>
<sys:String x:Key="Bench.PumpTemp">P-Temp:</sys:String>
<sys:String x:Key="Bench.PumpMe">P-ME:</sys:String>
<sys:String x:Key="Bench.PumpFbkw">P-FBkW:</sys:String>
<sys:String x:Key="Bench.PsgEncoder">PSG Encoder:</sys:String>
<!-- ── Bench controls ───────────────────────────────────────────────── -->
<sys:String x:Key="Bench.Direction">Direction</sys:String>
<sys:String x:Key="Bench.Left">LEFT</sys:String>
<sys:String x:Key="Bench.Right">RIGHT</sys:String>
<sys:String x:Key="Bench.Motor">Bench Motor</sys:String>
<sys:String x:Key="Bench.Start">START</sys:String>
<sys:String x:Key="Bench.Stop">STOP</sys:String>
<sys:String x:Key="Bench.SetRpm">Set RPM:</sys:String>
<sys:String x:Key="Bench.Go">GO</sys:String>
<sys:String x:Key="Bench.OilPump">Oil Pump</sys:String>
<sys:String x:Key="Bench.OilOff">OIL OFF</sys:String>
<sys:String x:Key="Bench.OilOn">OIL ON</sys:String>
<sys:String x:Key="Bench.Counter">Counter</sys:String>
<sys:String x:Key="Bench.Turns">Turns:</sys:String>
<sys:String x:Key="Bench.Send">Send</sys:String>
<sys:String x:Key="Bench.Relays">Relays</sys:String>
<sys:String x:Key="Bench.Electronic">Electronic</sys:String>
<sys:String x:Key="Bench.DepositCooler">Deposit Cooler</sys:String>
<sys:String x:Key="Bench.DepositHeater">Deposit Heater</sys:String>
<!-- ── Pump live data ───────────────────────────────────────────────── -->
<sys:String x:Key="Pump.THyb">T-hyb</sys:String>
<sys:String x:Key="Pump.Rpm">RPM</sys:String>
<sys:String x:Key="Pump.TEin">T-ein</sys:String>
<sys:String x:Key="Pump.UnitRpm">1/min</sys:String>
<!-- ── Pump identification ──────────────────────────────────────────── -->
<sys:String x:Key="PumpId.Label">Pump:</sys:String>
<sys:String x:Key="PumpId.Dfi">DFI:</sys:String>
<sys:String x:Key="PumpId.PumpId">Pump ID:</sys:String>
<sys:String x:Key="PumpId.SerialNo">Serial No:</sys:String>
<sys:String x:Key="PumpId.ModelRef">Model Ref:</sys:String>
<sys:String x:Key="PumpId.DataRecord">Data Record:</sys:String>
<sys:String x:Key="PumpId.PumpCtrl">Pump Ctrl:</sys:String>
<sys:String x:Key="PumpId.ModelIndex">Model Index:</sys:String>
<sys:String x:Key="PumpId.SwVer1">SW Ver 1:</sys:String>
<sys:String x:Key="PumpId.SwVer2">SW Ver 2:</sys:String>
<sys:String x:Key="PumpId.Errors">Errors:</sys:String>
<sys:String x:Key="PumpId.Error">Error:</sys:String>
<sys:String x:Key="PumpId.ReadKLine">Read K-Line</sys:String>
<sys:String x:Key="PumpId.Disconnect">Disconnect</sys:String>
<sys:String x:Key="PumpId.NoKLineDevice">No K-Line device found</sys:String>
<!-- ── DFI management ───────────────────────────────────────────────── -->
<sys:String x:Key="Dfi.Read">READ</sys:String>
<sys:String x:Key="Dfi.Write">WRITE</sys:String>
<sys:String x:Key="Dfi.Label">DFI:</sys:String>
<sys:String x:Key="Dfi.Auto">AUTO</sys:String>
<!-- ── Pump control sliders ─────────────────────────────────────────── -->
<sys:String x:Key="PumpCtrl.Fbkw">FBKW - Advance Control</sys:String>
<sys:String x:Key="PumpCtrl.Me">ME - Quantity Control</sys:String>
<sys:String x:Key="PumpCtrl.PreInj">ME - Pre-inj Quantity</sys:String>
<sys:String x:Key="PumpCtrl.MinStepMax">Min / Step / Max</sys:String>
<sys:String x:Key="PumpCtrl.Min">Min</sys:String>
<sys:String x:Key="PumpCtrl.Step">Step</sys:String>
<sys:String x:Key="PumpCtrl.Max">Max</sys:String>
<!-- ── Bench parameter config ───────────────────────────────────────── -->
<sys:String x:Key="BenchParam.CanBusId">CAN-Bus ID (0x)</sys:String>
<sys:String x:Key="BenchParam.ByteL">Byte L</sys:String>
<sys:String x:Key="BenchParam.ByteH">Byte H</sys:String>
<sys:String x:Key="BenchParam.FilterAlpha">Filter α</sys:String>
<sys:String x:Key="BenchParam.EnableFormula">Enable formula</sys:String>
<!-- ── Angle display ────────────────────────────────────────────────── -->
<sys:String x:Key="Angle.Header">ADVANCE MONITORING</sys:String>
<sys:String x:Key="Angle.Psg">PSG:</sys:String>
<sys:String x:Key="Angle.Inj">INJ:</sys:String>
<sys:String x:Key="Angle.AbsDeg">ABS °:</sys:String>
<sys:String x:Key="Angle.LockDeg">LOCK °:</sys:String>
<sys:String x:Key="Angle.SetPsgZero">Set PSG zero reference</sys:String>
<sys:String x:Key="Angle.SetInjZero">Set INJ zero reference</sys:String>
<!-- ── Test panel ───────────────────────────────────────────────────── -->
<sys:String x:Key="Test.StartTest">▶ START TEST</sys:String>
<sys:String x:Key="Test.Stop">■ STOP</sys:String>
<sys:String x:Key="Test.Report">📄 Report</sys:String>
<sys:String x:Key="Test.ShowValues">Show values</sys:String>
<sys:String x:Key="Test.CheckAll">Check All</sys:String>
<sys:String x:Key="Test.SecondsRemaining">s remaining</sys:String>
<sys:String x:Key="Test.Condition">Cond:</sys:String>
<sys:String x:Key="Test.Measurement">Meas:</sys:String>
<sys:String x:Key="Test.MeasPerSec">M/s:</sys:String>
<sys:String x:Key="Test.Required">Required:</sys:String>
<sys:String x:Key="Test.TestLabel">Test:</sys:String>
<sys:String x:Key="Test.Critical">Critical</sys:String>
<sys:String x:Key="Test.Started">Test started...</sys:String>
<sys:String x:Key="Test.Stopped">Test stopped.</sys:String>
<!-- ── Test types ───────────────────────────────────────────────────── -->
<sys:String x:Key="TestType.Warmup">Warm-up</sys:String>
<sys:String x:Key="TestType.Adjustment">Adjustment</sys:String>
<sys:String x:Key="TestType.Flow">Flow</sys:String>
<sys:String x:Key="TestType.ServoValve">Servo valve</sys:String>
<sys:String x:Key="TestType.Upstroke">Upstroke</sys:String>
<sys:String x:Key="TestType.PreInjection">Pre-injection</sys:String>
<!-- ── Result display ───────────────────────────────────────────────── -->
<sys:String x:Key="Result.Phase">Phase</sys:String>
<sys:String x:Key="Result.Parameter">Parameter</sys:String>
<sys:String x:Key="Result.Target">Target</sys:String>
<sys:String x:Key="Result.Tolerance">Tol ±</sys:String>
<sys:String x:Key="Result.Average">Average</sys:String>
<sys:String x:Key="Result.ResultHeader">Result</sys:String>
<sys:String x:Key="Result.AllTests">All Tests</sys:String>
<!-- ── Common strings ───────────────────────────────────────────────── -->
<sys:String x:Key="Common.Pass">PASS</sys:String>
<sys:String x:Key="Common.Fail">FAIL</sys:String>
<sys:String x:Key="Common.Accept">Accept</sys:String>
<sys:String x:Key="Common.Cancel">Cancel</sys:String>
<sys:String x:Key="Common.Close">Close</sys:String>
<sys:String x:Key="Common.Save">Save</sys:String>
<sys:String x:Key="Common.Ok">OK</sys:String>
<sys:String x:Key="Common.Yes">Yes</sys:String>
<sys:String x:Key="Common.No">No</sys:String>
<sys:String x:Key="Common.Warning">WARNING</sys:String>
<sys:String x:Key="Common.Disabled">disabled</sys:String>
<!-- ── Dialog: Report ───────────────────────────────────────────────── -->
<sys:String x:Key="Dialog.Report.Title">Generate Report</sys:String>
<sys:String x:Key="Dialog.Report.ClientList">Client List</sys:String>
<sys:String x:Key="Dialog.Report.ClientData">Client Data</sys:String>
<sys:String x:Key="Dialog.Report.CompanyData">Company Data</sys:String>
<sys:String x:Key="Dialog.Report.Name">Name:</sys:String>
<sys:String x:Key="Dialog.Report.ClientInfo">Client information</sys:String>
<sys:String x:Key="Dialog.Report.Observations">Observations</sys:String>
<sys:String x:Key="Dialog.Report.Operator">Operator:</sys:String>
<sys:String x:Key="Dialog.Report.Company">Company:</sys:String>
<sys:String x:Key="Dialog.Report.CompanyInfo">Company information</sys:String>
<sys:String x:Key="Dialog.Report.DeleteClient">Delete Client</sys:String>
<sys:String x:Key="Dialog.Report.Generate">Generate</sys:String>
<!-- ── Dialog: User authentication ──────────────────────────────────── -->
<sys:String x:Key="Dialog.UserCheck.Title">User Authentication</sys:String>
<sys:String x:Key="Dialog.UserCheck.Username">Username:</sys:String>
<sys:String x:Key="Dialog.UserCheck.Password">Password:</sys:String>
<!-- ── Dialog: Voltage warning ──────────────────────────────────────── -->
<sys:String x:Key="Dialog.Voltage.Title">Power Supply Warning</sys:String>
<sys:String x:Key="Dialog.Voltage.VoltageLabel">VOLTAGE: </sys:String>
<sys:String x:Key="Dialog.Voltage.PumpRequires">The selected pump requires </sys:String>
<sys:String x:Key="Dialog.Voltage.PowerSupply"> power supply.</sys:String>
<sys:String x:Key="Dialog.Voltage.SwitchTo">SWITCH THE POWER SUPPLY TO </sys:String>
<!-- ── Dialog: Oil pump confirmation ────────────────────────────────── -->
<sys:String x:Key="Dialog.OilPump.Title">Oil Pump Activation</sys:String>
<sys:String x:Key="Dialog.OilPump.Message">You are about to activate the oil pump. Confirm that the oil level is adequate and all connections are secure before proceeding.</sys:String>
<sys:String x:Key="Dialog.OilPump.LeaksChecked">I have checked — no leaks, connections are ready</sys:String>
<!-- ── Dialog: RPM safety warning ───────────────────────────────────── -->
<sys:String x:Key="Dialog.RpmSafety.Title">RPM Safety Warning</sys:String>
<sys:String x:Key="Dialog.RpmSafety.Message">The oil pump is OFF. Running the bench motor without oil circulation can cause bearing damage to the pump.</sys:String>
<sys:String x:Key="Dialog.RpmSafety.OilAndProceed">Turn on oil pump and start the bench</sys:String>
<sys:String x:Key="Dialog.RpmSafety.ProceedWithout">I know what I'm doing — start without oil</sys:String>
<!-- ── Dialog: Unlock progress ──────────────────────────────────────── -->
<sys:String x:Key="Dialog.Unlock.Title">Immobilizer Unlock</sys:String>
<sys:String x:Key="Dialog.Unlock.Progress">PROGRESS</sys:String>
<sys:String x:Key="Dialog.Unlock.Phase1">Phase 1: Sending unlock signals</sys:String>
<sys:String x:Key="Dialog.Unlock.Phase2Testing">Phase 2: Testing</sys:String>
<sys:String x:Key="Dialog.Unlock.Phase2Sending">Phase 2: Sending...</sys:String>
<sys:String x:Key="Dialog.Unlock.Cancelled">CANCELLED</sys:String>
<sys:String x:Key="Dialog.Unlock.Unlocked">UNLOCKED</sys:String>
<sys:String x:Key="Dialog.Unlock.Failed">UNLOCK FAILED</sys:String>
<sys:String x:Key="Dialog.Unlock.TypeLabel">Type {0}</sys:String>
<!-- ── Dialog: K-Line errors ────────────────────────────────────────── -->
<sys:String x:Key="Dialog.KlineErrors.Title">K-Line Fault Codes</sys:String>
<sys:String x:Key="Dialog.KlineErrors.Header">Fault codes:</sys:String>
<sys:String x:Key="Dialog.KlineErrors.Read">Read</sys:String>
<sys:String x:Key="Dialog.KlineErrors.Clear">Clear</sys:String>
<!-- ── Dialog: Settings ───────────────────────────────────────────────── -->
<sys:String x:Key="Dialog.Settings.Title">Settings</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.General">General</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Safety">Safety</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Pid">PID</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Motor">Motor</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Company">Company</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.KLine">K-Line</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Advanced">Advanced</sys:String>
<sys:String x:Key="Dialog.Settings.Language">Language:</sys:String>
<sys:String x:Key="Dialog.Settings.DaysKeepLogs">Days to keep logs:</sys:String>
<sys:String x:Key="Dialog.Settings.TempMax">Max. temperature (°C):</sys:String>
<sys:String x:Key="Dialog.Settings.TempMin">Min. temperature (°C):</sys:String>
<sys:String x:Key="Dialog.Settings.SecurityRpmLimit">Safety RPM limit:</sys:String>
<sys:String x:Key="Dialog.Settings.MaxPressureBar">Max. pressure (bar):</sys:String>
<sys:String x:Key="Dialog.Settings.ToleranceUp">UP tolerance extension:</sys:String>
<sys:String x:Key="Dialog.Settings.TolerancePfp">PFP tolerance extension:</sys:String>
<sys:String x:Key="Dialog.Settings.IgnoreTin">Ignore T-in by default</sys:String>
<sys:String x:Key="Dialog.Settings.PidP">Proportional (P):</sys:String>
<sys:String x:Key="Dialog.Settings.PidI">Integral (I):</sys:String>
<sys:String x:Key="Dialog.Settings.PidD">Derivative (D):</sys:String>
<sys:String x:Key="Dialog.Settings.PidLoopMs">Loop period (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.EncoderRes">Encoder resolution:</sys:String>
<sys:String x:Key="Dialog.Settings.VoltMaxRpm">Voltage for max RPM (V):</sys:String>
<sys:String x:Key="Dialog.Settings.MaxRpm">Max RPM:</sys:String>
<sys:String x:Key="Dialog.Settings.RightRelay">Right = relay ON</sys:String>
<sys:String x:Key="Dialog.Settings.Relations">RPM-Voltage Table</sys:String>
<sys:String x:Key="Dialog.Settings.RelRpm">RPM</sys:String>
<sys:String x:Key="Dialog.Settings.RelVoltage">Voltage (V)</sys:String>
<sys:String x:Key="Dialog.Settings.AddRow">Add</sys:String>
<sys:String x:Key="Dialog.Settings.RemoveRow">Remove</sys:String>
<sys:String x:Key="Dialog.Settings.CompanyName">Company name:</sys:String>
<sys:String x:Key="Dialog.Settings.CompanyInfo">Company information:</sys:String>
<sys:String x:Key="Dialog.Settings.ReportLogo">Report logo path:</sys:String>
<sys:String x:Key="Dialog.Settings.BrowseLogoTitle">Select Logo Image</sys:String>
<sys:String x:Key="Dialog.Settings.KLinePort">K-Line port (FTDI):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshPorts">Refresh</sys:String>
<sys:String x:Key="Dialog.Settings.KLineHint">Select an FTDI device or type the serial number manually.</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshBench">Bench interface (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshReading">While reading K-Line (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshCanBus">CAN bus read (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshPumpReq">Pump request (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshPumpParams">Pump params (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.BlinkInterval">Blink interval (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.FlasherInterval">Flasher interval (ms):</sys:String>
<!-- ── Error messages ───────────────────────────────────────────────── -->
<sys:String x:Key="Error.ReportGeneration">Failed to generate report:\n{0}</sys:String>
<sys:String x:Key="Error.ReportTitle">Report Error</sys:String>
<sys:String x:Key="Error.PsgSync">PSG sync pulse not detected. Check encoder connection.</sys:String>
<sys:String x:Key="Error.PsgTitle">PSG Error</sys:String>
<sys:String x:Key="Error.EmergencyStop">EMERGENCY STOP: {0}</sys:String>
<sys:String x:Key="Error.KLineNotFound">K-Line device not found. Check that the FTDI adapter is connected.</sys:String>
<sys:String x:Key="Error.KLineTitle">K-Line Error</sys:String>
<sys:String x:Key="Error.AuthInvalid">Invalid username or password.\n(Both are case-sensitive.)</sys:String>
<sys:String x:Key="Error.AuthTitle">Authentication Error</sys:String>
<!-- ── PDF Report ───────────────────────────────────────────────────── -->
<sys:String x:Key="Pdf.ReportTitle">VP44 INJECTION PUMP TEST REPORT</sys:String>
<sys:String x:Key="Pdf.GeneratedBy">Generated by HC-APTBS</sys:String>
<sys:String x:Key="Pdf.Page">Page </sys:String>
<sys:String x:Key="Pdf.Of"> of </sys:String>
<sys:String x:Key="Pdf.Date">Date: {0:dd/MM/yyyy HH:mm}</sys:String>
<sys:String x:Key="Pdf.Operator">Operator: {0}</sys:String>
<sys:String x:Key="Pdf.Client">Client: {0}</sys:String>
<sys:String x:Key="Pdf.PumpIdentification">PUMP IDENTIFICATION</sys:String>
<sys:String x:Key="Pdf.PumpId">Pump ID:</sys:String>
<sys:String x:Key="Pdf.Model">Model:</sys:String>
<sys:String x:Key="Pdf.SerialNo">Serial No.:</sys:String>
<sys:String x:Key="Pdf.Injector">Injector:</sys:String>
<sys:String x:Key="Pdf.Tube">Tube:</sys:String>
<sys:String x:Key="Pdf.Valve">Valve:</sys:String>
<sys:String x:Key="Pdf.Tension">Tension:</sys:String>
<sys:String x:Key="Pdf.Rotation">Rotation:</sys:String>
<sys:String x:Key="Pdf.LockAngle">Lock Angle:</sys:String>
<sys:String x:Key="Pdf.Measured">Measured:</sys:String>
<sys:String x:Key="Pdf.Chaveta">Chaveta:</sys:String>
<sys:String x:Key="Pdf.PreInj">Pre-Inj.:</sys:String>
<sys:String x:Key="Pdf.EcuData">ECU DATA (K-Line)</sys:String>
<sys:String x:Key="Pdf.OverallResult">OVERALL TEST RESULT</sys:String>
<sys:String x:Key="Pdf.TestsExecuted">Tests executed: {0} of {1}</sys:String>
<sys:String x:Key="Pdf.ParamsEvaluated">Parameters evaluated: {0} / {1} passed</sys:String>
<sys:String x:Key="Pdf.TestHeader">TEST: {0}</sys:String>
<sys:String x:Key="Pdf.Phase">Phase</sys:String>
<sys:String x:Key="Pdf.Parameter">Parameter</sys:String>
<sys:String x:Key="Pdf.Target">Target</sys:String>
<sys:String x:Key="Pdf.ToleranceHeader">Tolerance ±</sys:String>
<sys:String x:Key="Pdf.Average">Average</sys:String>
<sys:String x:Key="Pdf.Result">Result</sys:String>
<sys:String x:Key="Pdf.ErrorBits"> ⚠ Error bits: {0}</sys:String>
<sys:String x:Key="Pdf.NoSampleData">No sample data available for graphical display.</sys:String>
<sys:String x:Key="Pdf.ChartSamples">Samples: {0} | Target: {1} ± {2} | Average: {3} | Result: {4}</sys:String>
</ResourceDictionary>

322
Resources/Strings.es.xaml Normal file
View File

@@ -0,0 +1,322 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
<!-- ══════════════════════════════════════════════════════════════════
Spanish (ESP) — Default language
══════════════════════════════════════════════════════════════════ -->
<!-- ── App ──────────────────────────────────────────────────────────── -->
<sys:String x:Key="App.Title">HC_APTBS — Herlic Banco de Pruebas</sys:String>
<sys:String x:Key="App.LanguageLabel">ENG</sys:String>
<!-- ── Menu ─────────────────────────────────────────────────────────── -->
<sys:String x:Key="Menu.Settings">Configuración</sys:String>
<!-- ── Status bar / connection indicators ───────────────────────────── -->
<sys:String x:Key="Status.Label">Estado:</sys:String>
<sys:String x:Key="Status.Can">CAN</sys:String>
<sys:String x:Key="Status.Bench">Banco</sys:String>
<sys:String x:Key="Status.Pump">Bomba</sys:String>
<sys:String x:Key="Status.KLine">K-Line</sys:String>
<sys:String x:Key="Status.Connected">Conectado</sys:String>
<sys:String x:Key="Status.ConnectionFailed">Fallo de conexión</sys:String>
<sys:String x:Key="Status.Disconnected">Desconectado</sys:String>
<!-- ── Bench section ────────────────────────────────────────────────── -->
<sys:String x:Key="Bench.Header">Banco</sys:String>
<sys:String x:Key="Bench.ConnectCan">Conectar CAN</sys:String>
<sys:String x:Key="Bench.DisconnectCan">Desconectar CAN</sys:String>
<sys:String x:Key="Bench.Rpm">rpm</sys:String>
<sys:String x:Key="Bench.Target">Objetivo:</sys:String>
<sys:String x:Key="Bench.Voltage">V:</sys:String>
<sys:String x:Key="Bench.TempIn">T. Ent.:</sys:String>
<sys:String x:Key="Bench.TempOut">T. Sal.:</sys:String>
<sys:String x:Key="Bench.Temp4">T. 4:</sys:String>
<sys:String x:Key="Bench.TempTank">T. Tanq.:</sys:String>
<sys:String x:Key="Bench.P1">P1:</sys:String>
<sys:String x:Key="Bench.P2">P2:</sys:String>
<sys:String x:Key="Bench.QDelivery">Q-Del.:</sys:String>
<sys:String x:Key="Bench.QOver">Q-Over:</sys:String>
<sys:String x:Key="Bench.CcStroke">cc/golpe</sys:String>
<sys:String x:Key="Bench.PumpRpm">P-RPM:</sys:String>
<sys:String x:Key="Bench.PumpTemp">P-Temp:</sys:String>
<sys:String x:Key="Bench.PumpMe">P-ME:</sys:String>
<sys:String x:Key="Bench.PumpFbkw">P-FBkW:</sys:String>
<sys:String x:Key="Bench.PsgEncoder">Encoder PSG:</sys:String>
<!-- ── Bench controls ───────────────────────────────────────────────── -->
<sys:String x:Key="Bench.Direction">Dirección</sys:String>
<sys:String x:Key="Bench.Left">IZQUIERDA</sys:String>
<sys:String x:Key="Bench.Right">DERECHA</sys:String>
<sys:String x:Key="Bench.Motor">Motor del Banco</sys:String>
<sys:String x:Key="Bench.Start">ARRANCAR</sys:String>
<sys:String x:Key="Bench.Stop">PARAR</sys:String>
<sys:String x:Key="Bench.SetRpm">Fijar RPM:</sys:String>
<sys:String x:Key="Bench.Go">IR</sys:String>
<sys:String x:Key="Bench.OilPump">Bomba de Aceite</sys:String>
<sys:String x:Key="Bench.OilOff">ACEITE OFF</sys:String>
<sys:String x:Key="Bench.OilOn">ACEITE ON</sys:String>
<sys:String x:Key="Bench.Counter">Contador</sys:String>
<sys:String x:Key="Bench.Turns">Vueltas:</sys:String>
<sys:String x:Key="Bench.Send">Enviar</sys:String>
<sys:String x:Key="Bench.Relays">Relés</sys:String>
<sys:String x:Key="Bench.Electronic">Electrónico</sys:String>
<sys:String x:Key="Bench.DepositCooler">Enfriador Depósito</sys:String>
<sys:String x:Key="Bench.DepositHeater">Calentador Depósito</sys:String>
<!-- ── Pump live data ───────────────────────────────────────────────── -->
<sys:String x:Key="Pump.THyb">T-hyb</sys:String>
<sys:String x:Key="Pump.Rpm">RPM</sys:String>
<sys:String x:Key="Pump.TEin">T-ein</sys:String>
<sys:String x:Key="Pump.UnitRpm">1/min</sys:String>
<!-- ── Pump identification ──────────────────────────────────────────── -->
<sys:String x:Key="PumpId.Label">Bomba:</sys:String>
<sys:String x:Key="PumpId.Dfi">DFI:</sys:String>
<sys:String x:Key="PumpId.PumpId">ID Bomba:</sys:String>
<sys:String x:Key="PumpId.SerialNo">Nro. Serie:</sys:String>
<sys:String x:Key="PumpId.ModelRef">Ref. Modelo:</sys:String>
<sys:String x:Key="PumpId.DataRecord">Registro Datos:</sys:String>
<sys:String x:Key="PumpId.PumpCtrl">Ctrl. Bomba:</sys:String>
<sys:String x:Key="PumpId.ModelIndex">Índice Modelo:</sys:String>
<sys:String x:Key="PumpId.SwVer1">SW Ver 1:</sys:String>
<sys:String x:Key="PumpId.SwVer2">SW Ver 2:</sys:String>
<sys:String x:Key="PumpId.Errors">Errores:</sys:String>
<sys:String x:Key="PumpId.Error">Error:</sys:String>
<sys:String x:Key="PumpId.ReadKLine">Leer K-Line</sys:String>
<sys:String x:Key="PumpId.Disconnect">Desconectar</sys:String>
<sys:String x:Key="PumpId.NoKLineDevice">No se encontró dispositivo K-Line</sys:String>
<!-- ── DFI management ───────────────────────────────────────────────── -->
<sys:String x:Key="Dfi.Read">LEER</sys:String>
<sys:String x:Key="Dfi.Write">ESCRIBIR</sys:String>
<sys:String x:Key="Dfi.Label">DFI:</sys:String>
<sys:String x:Key="Dfi.Auto">AUTO</sys:String>
<!-- ── Pump control sliders ─────────────────────────────────────────── -->
<sys:String x:Key="PumpCtrl.Fbkw">FBKW - Control de Avance</sys:String>
<sys:String x:Key="PumpCtrl.Me">ME - Control de Caudal</sys:String>
<sys:String x:Key="PumpCtrl.PreInj">ME - Caudal Pre-inyección</sys:String>
<sys:String x:Key="PumpCtrl.MinStepMax">Mín / Paso / Máx</sys:String>
<sys:String x:Key="PumpCtrl.Min">Mín</sys:String>
<sys:String x:Key="PumpCtrl.Step">Paso</sys:String>
<sys:String x:Key="PumpCtrl.Max">Máx</sys:String>
<!-- ── Bench parameter config ───────────────────────────────────────── -->
<sys:String x:Key="BenchParam.CanBusId">CAN-Bus ID (0x)</sys:String>
<sys:String x:Key="BenchParam.ByteL">Byte L</sys:String>
<sys:String x:Key="BenchParam.ByteH">Byte H</sys:String>
<sys:String x:Key="BenchParam.FilterAlpha">Filtro α</sys:String>
<sys:String x:Key="BenchParam.EnableFormula">Habilitar fórmula</sys:String>
<!-- ── Angle display ────────────────────────────────────────────────── -->
<sys:String x:Key="Angle.Header">MONITOREO DE AVANCE</sys:String>
<sys:String x:Key="Angle.Psg">PSG:</sys:String>
<sys:String x:Key="Angle.Inj">INJ:</sys:String>
<sys:String x:Key="Angle.AbsDeg">ABS º:</sys:String>
<sys:String x:Key="Angle.LockDeg">LOCK º:</sys:String>
<sys:String x:Key="Angle.SetPsgZero">Fijar referencia cero PSG</sys:String>
<sys:String x:Key="Angle.SetInjZero">Fijar referencia cero INJ</sys:String>
<!-- ── Test panel ───────────────────────────────────────────────────── -->
<sys:String x:Key="Test.StartTest">▶ INICIAR TEST</sys:String>
<sys:String x:Key="Test.Stop">■ PARAR</sys:String>
<sys:String x:Key="Test.Report">📄 Informe</sys:String>
<sys:String x:Key="Test.ShowValues">Mostrar valores</sys:String>
<sys:String x:Key="Test.CheckAll">Marcar Todos</sys:String>
<sys:String x:Key="Test.SecondsRemaining">s restantes</sys:String>
<sys:String x:Key="Test.Condition">Cond:</sys:String>
<sys:String x:Key="Test.Measurement">Med:</sys:String>
<sys:String x:Key="Test.MeasPerSec">M/s:</sys:String>
<sys:String x:Key="Test.Required">Requerido:</sys:String>
<sys:String x:Key="Test.TestLabel">Test:</sys:String>
<sys:String x:Key="Test.Critical">Crítico</sys:String>
<sys:String x:Key="Test.Started">Test iniciado...</sys:String>
<sys:String x:Key="Test.Stopped">Test detenido.</sys:String>
<!-- ── Test types ───────────────────────────────────────────────────── -->
<sys:String x:Key="TestType.Warmup">Calentamiento</sys:String>
<sys:String x:Key="TestType.Adjustment">Ajuste</sys:String>
<sys:String x:Key="TestType.Flow">Caudal</sys:String>
<sys:String x:Key="TestType.ServoValve">Servoválvula</sys:String>
<sys:String x:Key="TestType.Upstroke">Carrera Ascendente</sys:String>
<sys:String x:Key="TestType.PreInjection">Pre-inyección</sys:String>
<!-- ── Result display ───────────────────────────────────────────────── -->
<sys:String x:Key="Result.Phase">Fase</sys:String>
<sys:String x:Key="Result.Parameter">Parámetro</sys:String>
<sys:String x:Key="Result.Target">Objetivo</sys:String>
<sys:String x:Key="Result.Tolerance">Tol ±</sys:String>
<sys:String x:Key="Result.Average">Promedio</sys:String>
<sys:String x:Key="Result.ResultHeader">Resultado</sys:String>
<sys:String x:Key="Result.AllTests">Todos los Tests</sys:String>
<!-- ── Common strings ───────────────────────────────────────────────── -->
<sys:String x:Key="Common.Pass">APROBADO</sys:String>
<sys:String x:Key="Common.Fail">REPROBADO</sys:String>
<sys:String x:Key="Common.Accept">Aceptar</sys:String>
<sys:String x:Key="Common.Cancel">Cancelar</sys:String>
<sys:String x:Key="Common.Close">Cerrar</sys:String>
<sys:String x:Key="Common.Save">Guardar</sys:String>
<sys:String x:Key="Common.Ok">OK</sys:String>
<sys:String x:Key="Common.Yes">Sí</sys:String>
<sys:String x:Key="Common.No">No</sys:String>
<sys:String x:Key="Common.Warning">ADVERTENCIA</sys:String>
<sys:String x:Key="Common.Disabled">deshabilitado</sys:String>
<!-- ── Dialog: Report ───────────────────────────────────────────────── -->
<sys:String x:Key="Dialog.Report.Title">Generar Informe</sys:String>
<sys:String x:Key="Dialog.Report.ClientList">Lista de Clientes</sys:String>
<sys:String x:Key="Dialog.Report.ClientData">Datos del Cliente</sys:String>
<sys:String x:Key="Dialog.Report.CompanyData">Datos de la Empresa</sys:String>
<sys:String x:Key="Dialog.Report.Name">Nombre:</sys:String>
<sys:String x:Key="Dialog.Report.ClientInfo">Información del cliente</sys:String>
<sys:String x:Key="Dialog.Report.Observations">Observaciones</sys:String>
<sys:String x:Key="Dialog.Report.Operator">Operador:</sys:String>
<sys:String x:Key="Dialog.Report.Company">Empresa:</sys:String>
<sys:String x:Key="Dialog.Report.CompanyInfo">Información de la empresa</sys:String>
<sys:String x:Key="Dialog.Report.DeleteClient">Eliminar Cliente</sys:String>
<sys:String x:Key="Dialog.Report.Generate">Generar</sys:String>
<!-- ── Dialog: User authentication ──────────────────────────────────── -->
<sys:String x:Key="Dialog.UserCheck.Title">Autenticación de Usuario</sys:String>
<sys:String x:Key="Dialog.UserCheck.Username">Usuario:</sys:String>
<sys:String x:Key="Dialog.UserCheck.Password">Contraseña:</sys:String>
<!-- ── Dialog: Voltage warning ──────────────────────────────────────── -->
<sys:String x:Key="Dialog.Voltage.Title">Advertencia de Fuente de Alimentación</sys:String>
<sys:String x:Key="Dialog.Voltage.VoltageLabel">VOLTAJE: </sys:String>
<sys:String x:Key="Dialog.Voltage.PumpRequires">La bomba seleccionada requiere </sys:String>
<sys:String x:Key="Dialog.Voltage.PowerSupply"> de alimentación.</sys:String>
<sys:String x:Key="Dialog.Voltage.SwitchTo">CAMBIE LA FUENTE DE ALIMENTACIÓN A </sys:String>
<!-- ── Dialog: Oil pump confirmation ────────────────────────────────── -->
<sys:String x:Key="Dialog.OilPump.Title">Activación de Bomba de Aceite</sys:String>
<sys:String x:Key="Dialog.OilPump.Message">Está a punto de activar la bomba de aceite. Confirme que el nivel de aceite es adecuado y que todas las conexiones están seguras antes de continuar.</sys:String>
<sys:String x:Key="Dialog.OilPump.LeaksChecked">He verificado — sin fugas, conexiones listas</sys:String>
<!-- ── Dialog: RPM safety warning ───────────────────────────────────── -->
<sys:String x:Key="Dialog.RpmSafety.Title">Advertencia de Seguridad RPM</sys:String>
<sys:String x:Key="Dialog.RpmSafety.Message">La bomba de aceite está APAGADA. Hacer funcionar el motor del banco sin circulación de aceite puede causar daño a los rodamientos de la bomba.</sys:String>
<sys:String x:Key="Dialog.RpmSafety.OilAndProceed">Encender bomba de aceite e iniciar el banco</sys:String>
<sys:String x:Key="Dialog.RpmSafety.ProceedWithout">Sé lo que hago — iniciar sin aceite</sys:String>
<!-- ── Dialog: Unlock progress ──────────────────────────────────────── -->
<sys:String x:Key="Dialog.Unlock.Title">Desbloqueo de Inmovilizador</sys:String>
<sys:String x:Key="Dialog.Unlock.Progress">PROGRESO</sys:String>
<sys:String x:Key="Dialog.Unlock.Phase1">Fase 1: Enviando señales de desbloqueo</sys:String>
<sys:String x:Key="Dialog.Unlock.Phase2Testing">Fase 2: Probando</sys:String>
<sys:String x:Key="Dialog.Unlock.Phase2Sending">Fase 2: Enviando...</sys:String>
<sys:String x:Key="Dialog.Unlock.Cancelled">CANCELADO</sys:String>
<sys:String x:Key="Dialog.Unlock.Unlocked">DESBLOQUEADO</sys:String>
<sys:String x:Key="Dialog.Unlock.Failed">DESBLOQUEO FALLIDO</sys:String>
<sys:String x:Key="Dialog.Unlock.TypeLabel">Tipo {0}</sys:String>
<!-- ── Dialog: K-Line errors ────────────────────────────────────────── -->
<sys:String x:Key="Dialog.KlineErrors.Title">Códigos de Falla K-Line</sys:String>
<sys:String x:Key="Dialog.KlineErrors.Header">Códigos de falla:</sys:String>
<sys:String x:Key="Dialog.KlineErrors.Read">Leer</sys:String>
<sys:String x:Key="Dialog.KlineErrors.Clear">Borrar</sys:String>
<!-- ── Dialog: Settings ───────────────────────────────────────────────── -->
<sys:String x:Key="Dialog.Settings.Title">Configuración</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.General">General</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Safety">Seguridad</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Pid">PID</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Motor">Motor</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Company">Empresa</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.KLine">K-Line</sys:String>
<sys:String x:Key="Dialog.Settings.Tab.Advanced">Avanzado</sys:String>
<sys:String x:Key="Dialog.Settings.Language">Idioma:</sys:String>
<sys:String x:Key="Dialog.Settings.DaysKeepLogs">Días de retención de logs:</sys:String>
<sys:String x:Key="Dialog.Settings.TempMax">Temperatura máx. (°C):</sys:String>
<sys:String x:Key="Dialog.Settings.TempMin">Temperatura mín. (°C):</sys:String>
<sys:String x:Key="Dialog.Settings.SecurityRpmLimit">Límite RPM de seguridad:</sys:String>
<sys:String x:Key="Dialog.Settings.MaxPressureBar">Presión máx. (bar):</sys:String>
<sys:String x:Key="Dialog.Settings.ToleranceUp">Extensión tolerancia UP:</sys:String>
<sys:String x:Key="Dialog.Settings.TolerancePfp">Extensión tolerancia PFP:</sys:String>
<sys:String x:Key="Dialog.Settings.IgnoreTin">Ignorar T-in por defecto</sys:String>
<sys:String x:Key="Dialog.Settings.PidP">Proporcional (P):</sys:String>
<sys:String x:Key="Dialog.Settings.PidI">Integral (I):</sys:String>
<sys:String x:Key="Dialog.Settings.PidD">Derivativo (D):</sys:String>
<sys:String x:Key="Dialog.Settings.PidLoopMs">Período del lazo (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.EncoderRes">Resolución encoder:</sys:String>
<sys:String x:Key="Dialog.Settings.VoltMaxRpm">Voltaje para RPM máx. (V):</sys:String>
<sys:String x:Key="Dialog.Settings.MaxRpm">RPM máximo:</sys:String>
<sys:String x:Key="Dialog.Settings.RightRelay">Derecha = relé ON</sys:String>
<sys:String x:Key="Dialog.Settings.Relations">Tabla RPM-Voltaje</sys:String>
<sys:String x:Key="Dialog.Settings.RelRpm">RPM</sys:String>
<sys:String x:Key="Dialog.Settings.RelVoltage">Voltaje (V)</sys:String>
<sys:String x:Key="Dialog.Settings.AddRow">Agregar</sys:String>
<sys:String x:Key="Dialog.Settings.RemoveRow">Eliminar</sys:String>
<sys:String x:Key="Dialog.Settings.CompanyName">Nombre empresa:</sys:String>
<sys:String x:Key="Dialog.Settings.CompanyInfo">Información empresa:</sys:String>
<sys:String x:Key="Dialog.Settings.ReportLogo">Ruta logo de informe:</sys:String>
<sys:String x:Key="Dialog.Settings.BrowseLogoTitle">Seleccionar imagen de logo</sys:String>
<sys:String x:Key="Dialog.Settings.KLinePort">Puerto K-Line (FTDI):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshPorts">Refrescar</sys:String>
<sys:String x:Key="Dialog.Settings.KLineHint">Seleccione un dispositivo FTDI o ingrese el número de serie manualmente.</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshBench">Interfaz banco (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshReading">Durante lectura K-Line (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshCanBus">Lectura CAN bus (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshPumpReq">Solicitud bomba (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.RefreshPumpParams">Parámetros bomba (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.BlinkInterval">Intervalo parpadeo (ms):</sys:String>
<sys:String x:Key="Dialog.Settings.FlasherInterval">Intervalo flasher (ms):</sys:String>
<!-- ── Error messages ───────────────────────────────────────────────── -->
<sys:String x:Key="Error.ReportGeneration">Error al generar informe:\n{0}</sys:String>
<sys:String x:Key="Error.ReportTitle">Error de Informe</sys:String>
<sys:String x:Key="Error.PsgSync">No se detectó el pulso de sincronización PSG. Verifique la conexión del encoder.</sys:String>
<sys:String x:Key="Error.PsgTitle">Error PSG</sys:String>
<sys:String x:Key="Error.EmergencyStop">PARADA DE EMERGENCIA: {0}</sys:String>
<sys:String x:Key="Error.KLineNotFound">Dispositivo K-Line no encontrado. Verifique que el adaptador FTDI esté conectado.</sys:String>
<sys:String x:Key="Error.KLineTitle">Error K-Line</sys:String>
<sys:String x:Key="Error.AuthInvalid">Usuario o contraseña inválidos.\n(Ambos distinguen mayúsculas y minúsculas.)</sys:String>
<sys:String x:Key="Error.AuthTitle">Error de Autenticación</sys:String>
<!-- ── PDF Report ───────────────────────────────────────────────────── -->
<sys:String x:Key="Pdf.ReportTitle">INFORME DE PRUEBA DE BOMBA INYECTORA VP44</sys:String>
<sys:String x:Key="Pdf.GeneratedBy">Generado por HC-APTBS</sys:String>
<sys:String x:Key="Pdf.Page">Página </sys:String>
<sys:String x:Key="Pdf.Of"> de </sys:String>
<sys:String x:Key="Pdf.Date">Fecha: {0:dd/MM/yyyy HH:mm}</sys:String>
<sys:String x:Key="Pdf.Operator">Operador: {0}</sys:String>
<sys:String x:Key="Pdf.Client">Cliente: {0}</sys:String>
<sys:String x:Key="Pdf.PumpIdentification">IDENTIFICACIÓN DE BOMBA</sys:String>
<sys:String x:Key="Pdf.PumpId">ID Bomba:</sys:String>
<sys:String x:Key="Pdf.Model">Modelo:</sys:String>
<sys:String x:Key="Pdf.SerialNo">Nro. Serie:</sys:String>
<sys:String x:Key="Pdf.Injector">Inyector:</sys:String>
<sys:String x:Key="Pdf.Tube">Tubo:</sys:String>
<sys:String x:Key="Pdf.Valve">Válvula:</sys:String>
<sys:String x:Key="Pdf.Tension">Tensión:</sys:String>
<sys:String x:Key="Pdf.Rotation">Rotación:</sys:String>
<sys:String x:Key="Pdf.LockAngle">Ángulo de Bloqueo:</sys:String>
<sys:String x:Key="Pdf.Measured">Medido:</sys:String>
<sys:String x:Key="Pdf.Chaveta">Chaveta:</sys:String>
<sys:String x:Key="Pdf.PreInj">Pre-Iny.:</sys:String>
<sys:String x:Key="Pdf.EcuData">DATOS ECU (K-Line)</sys:String>
<sys:String x:Key="Pdf.OverallResult">RESULTADO GENERAL DEL TEST</sys:String>
<sys:String x:Key="Pdf.TestsExecuted">Tests ejecutados: {0} de {1}</sys:String>
<sys:String x:Key="Pdf.ParamsEvaluated">Parámetros evaluados: {0} / {1} aprobados</sys:String>
<sys:String x:Key="Pdf.TestHeader">TEST: {0}</sys:String>
<sys:String x:Key="Pdf.Phase">Fase</sys:String>
<sys:String x:Key="Pdf.Parameter">Parámetro</sys:String>
<sys:String x:Key="Pdf.Target">Objetivo</sys:String>
<sys:String x:Key="Pdf.ToleranceHeader">Tolerancia ±</sys:String>
<sys:String x:Key="Pdf.Average">Promedio</sys:String>
<sys:String x:Key="Pdf.Result">Resultado</sys:String>
<sys:String x:Key="Pdf.ErrorBits"> ⚠ Bits de error: {0}</sys:String>
<sys:String x:Key="Pdf.NoSampleData">No hay datos de muestra disponibles para visualización gráfica.</sys:String>
<sys:String x:Key="Pdf.ChartSamples">Muestras: {0} | Objetivo: {1} ± {2} | Promedio: {3} | Resultado: {4}</sys:String>
</ResourceDictionary>

View File

@@ -36,6 +36,13 @@ namespace HC_APTBS.Services
/// </summary> /// </summary>
event Action<string , bool >? PhaseCompleted; // phaseName,passed event Action<string , bool >? PhaseCompleted; // phaseName,passed
/// <summary>
/// Raised when a safety check triggers an emergency stop. The bench motor
/// and pump parameters are already stopped when this fires.
/// Fires on a background thread — consumers must marshal to the UI thread.
/// </summary>
event Action<string >? EmergencyStopTriggered; //reason
// ── Active pump ─────────────────────────────────────────────────────────── // ── Active pump ───────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -213,5 +220,11 @@ namespace HC_APTBS.Services
/// Raised so the chart view can draw tolerance bands for the specified parameter. /// Raised so the chart view can draw tolerance bands for the specified parameter.
/// </summary> /// </summary>
event Action<string , double , double >? ToleranceUpdated; //parameterName, value, tolerance event Action<string , double , double >? ToleranceUpdated; //parameterName, value, tolerance
/// <summary>
/// Raised for each individual measurement sample collected during a test phase.
/// Fires on a background thread — consumers must marshal to the UI thread.
/// </summary>
event Action<string , double >? MeasurementSampled; //parameterName, value
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using HC_APTBS.Models; using HC_APTBS.Models;
namespace HC_APTBS.Services namespace HC_APTBS.Services
@@ -73,8 +74,8 @@ namespace HC_APTBS.Services
/// <summary>Validates a username/password pair against stored credentials.</summary> /// <summary>Validates a username/password pair against stored credentials.</summary>
bool ValidateUser(string username, string password); bool ValidateUser(string username, string password);
/// <summary>Returns all stored user credentials as a dictionary.</summary> /// <summary>Returns all stored usernames (passwords are never exposed).</summary>
IReadOnlyDictionary<string, string> GetUsers(); IReadOnlyList<string> GetUsers();
/// <summary>Replaces all stored user credentials and persists them.</summary> /// <summary>Replaces all stored user credentials and persists them.</summary>
void UpdateUsers(Dictionary<string, string> users); void UpdateUsers(Dictionary<string, string> users);

View File

@@ -126,6 +126,16 @@ namespace HC_APTBS.Services
/// </summary> /// </summary>
event Action<double>? DfiRead; event Action<double>? DfiRead;
// ── Fast immobilizer unlock ───────────────────────────────────────────────
/// <summary>
/// Attempts a fast immobilizer unlock by sending a KWP custom command
/// over an existing K-Line session. Returns <see langword="true"/> if the
/// command was acknowledged (pump already unlocked), <see langword="false"/>
/// if it was rejected or no session is active.
/// </summary>
Task<bool> TryFastUnlockAsync();
// ── Power cycle callbacks ───────────────────────────────────────────────── // ── Power cycle callbacks ─────────────────────────────────────────────────
/// <summary> /// <summary>

View File

@@ -0,0 +1,39 @@
using System;
namespace HC_APTBS.Services
{
/// <summary>
/// Provides runtime language switching and localised string retrieval.
/// </summary>
/// <remarks>
/// XAML bindings use <c>{DynamicResource Key}</c> which update automatically
/// when the merged <see cref="System.Windows.ResourceDictionary"/> is swapped.
/// C# code uses <see cref="GetString"/> for the same keys.
/// </remarks>
public interface ILocalizationService
{
/// <summary>Current language code — <c>"ESP"</c> or <c>"ENG"</c>.</summary>
string CurrentLanguage { get; }
/// <summary>
/// Switches the active UI language by swapping the merged resource dictionary
/// and persisting the choice to <c>config.xml</c>.
/// Must be called from the UI thread.
/// </summary>
/// <param name="languageCode"><c>"ESP"</c> for Spanish or <c>"ENG"</c> for English.</param>
void SetLanguage(string languageCode);
/// <summary>
/// Retrieves a localised string by resource key.
/// Returns the key itself when no matching resource is found (fail-visible).
/// </summary>
/// <param name="key">Resource key defined in <c>Resources/Strings.*.xaml</c>.</param>
string GetString(string key);
/// <summary>
/// Raised after the active language dictionary has been swapped.
/// ViewModels subscribe to refresh any cached localised strings.
/// </summary>
event Action? LanguageChanged;
}
}

View File

@@ -20,9 +20,18 @@ namespace HC_APTBS.Services
/// <summary> /// <summary>
/// Runs the immobilizer unlock sequence for the given pump. /// Runs the immobilizer unlock sequence for the given pump.
/// Returns immediately if <see cref="PumpDefinition.UnlockType"/> is 0 (no unlock needed). /// Returns immediately if <see cref="PumpDefinition.UnlockType"/> is 0 (no unlock needed).
/// The persistent CAN senders remain active after this method returns;
/// call <see cref="StopSenders"/> when the pump is deselected.
/// </summary> /// </summary>
/// <param name="pump">Pump definition with unlock type and CAN parameters.</param> /// <param name="pump">Pump definition with unlock type and CAN parameters.</param>
/// <param name="ct">Cancellation token to abort the unlock sequence.</param> /// <param name="ct">Cancellation token to abort the unlock sequence.</param>
Task UnlockAsync(PumpDefinition pump, CancellationToken ct); Task UnlockAsync(PumpDefinition pump, CancellationToken ct);
/// <summary>
/// Stops the persistent CAN unlock senders. Call this when the pump is
/// deselected or the application is shutting down. Safe to call when no
/// senders are active.
/// </summary>
void StopSenders();
} }
} }

View File

@@ -62,6 +62,13 @@ namespace HC_APTBS.Services.Impl
private CancellationTokenSource? _relaySendCts; private CancellationTokenSource? _relaySendCts;
private volatile bool _relaySendActive; private volatile bool _relaySendActive;
// Alarm bitmask snapshot for edge detection during test phases
private int _lastAlarmMask;
// QOver zero-flow safety debounce (elapsed ms from phase stopwatch)
private long _qOverZeroSinceMs;
private const int QOverDebounceSec = 3;
// RPM PID ramp controller // RPM PID ramp controller
private BenchPidController? _pidController; private BenchPidController? _pidController;
private double _lastTargetRpm; private double _lastTargetRpm;
@@ -92,6 +99,10 @@ namespace HC_APTBS.Services.Impl
public event Action<string, double>? PumpControlValueSet; public event Action<string, double>? PumpControlValueSet;
/// <inheritdoc/> /// <inheritdoc/>
public event Action? RpmCommandSent; public event Action? RpmCommandSent;
/// <inheritdoc/>
public event Action<string, double>? MeasurementSampled;
/// <inheritdoc/>
public event Action<string>? EmergencyStopTriggered;
// ── Constructor ─────────────────────────────────────────────────────────── // ── Constructor ───────────────────────────────────────────────────────────
@@ -615,6 +626,7 @@ namespace HC_APTBS.Services.Impl
{ {
_cts?.Cancel(); _cts?.Cancel();
SetRpm(0); SetRpm(0);
ZeroPumpParameters();
_log.Info(LogId, "Test sequence stopped by operator."); _log.Info(LogId, "Test sequence stopped by operator.");
} }
@@ -633,6 +645,8 @@ namespace HC_APTBS.Services.Impl
phase.Success = true; phase.Success = true;
phase.ClearResults(); phase.ClearResults();
phase.ErrorBits.Clear(); phase.ErrorBits.Clear();
_lastAlarmMask = (int)ReadBenchParameter(BenchParameterNames.Alarms);
_qOverZeroSinceMs = 0;
// SVME test: check that the PSG encoder sync pulse is present before proceeding. // SVME test: check that the PSG encoder sync pulse is present before proceeding.
if (!phase.Name.Contains("Lock Angle") && if (!phase.Name.Contains("Lock Angle") &&
@@ -727,6 +741,8 @@ namespace HC_APTBS.Services.Impl
for (int i = 0; i * 1000 < conditioningRemainMs; i++) for (int i = 0; i * 1000 < conditioningRemainMs; i++)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
CheckQOverSafety(i * 1000L);
PollAlarms(phase);
int remaining = (int)(conditioningRemainMs / 1000) - i; int remaining = (int)(conditioningRemainMs / 1000) - i;
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {remaining}s"); VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {remaining}s");
await Task.Delay(1000, ct); await Task.Delay(1000, ct);
@@ -768,12 +784,14 @@ namespace HC_APTBS.Services.Impl
if (phase.IsCritical && !phase.Success) if (phase.IsCritical && !phase.Success)
{ {
SetRpm(0); SetRpm(0);
ZeroPumpParameters();
VerboseMessage?.Invoke($"CRITICAL failure in {phase.Name} — test halted."); VerboseMessage?.Invoke($"CRITICAL failure in {phase.Name} — test halted.");
return false; return false;
} }
// Stop pump between phases (motor cool-down). // Stop pump between phases (motor cool-down).
SetRpm(0); SetRpm(0);
ZeroPumpParameters();
} }
return success; return success;
@@ -799,6 +817,7 @@ namespace HC_APTBS.Services.Impl
while (sw.ElapsedMilliseconds <= measureMs) while (sw.ElapsedMilliseconds <= measureMs)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
CheckQOverSafety(sw.ElapsedMilliseconds);
foreach (var tp in phase.Receives) foreach (var tp in phase.Receives)
{ {
@@ -808,8 +827,10 @@ namespace HC_APTBS.Services.Impl
Timestamp = DateTime.Now.ToString(TestDefinition.TimestampFormat) Timestamp = DateTime.Now.ToString(TestDefinition.TimestampFormat)
}; };
tp.Result!.AddSample(sample); tp.Result!.AddSample(sample);
MeasurementSampled?.Invoke(tp.Name, sample.Value);
} }
PollAlarms(phase);
await Task.Delay(sleepMs, ct); await Task.Delay(sleepMs, ct);
} }
@@ -879,6 +900,114 @@ namespace HC_APTBS.Services.Impl
return target.Result?.Passed ?? false; return target.Result?.Passed ?? false;
} }
// ── Safety helpers ─────────────────────────────────────────────────────────
/// <summary>
/// Immediately zeroes all pump control parameters (ME, FBKW, PreIn) and
/// transmits the zero values over CAN. Bypasses the slew-rate IIR filter
/// by writing directly to parameter values and clearing the target fields.
/// </summary>
private void ZeroPumpParameters()
{
if (_activePump == null) return;
// Zero the slew-rate targets so the periodic sender doesn't ramp back up.
_targetMe = 0;
_targetFbkw = 0;
_targetPreIn = 0;
// Write zero directly to the parameter values (bypassing the IIR filter).
CanBusParameter? meParam = null;
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Me, out meParam))
{
meParam.Value = 0;
_can.SendMessageById(meParam.MessageId);
}
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Fbkw, out var fbkwParam))
{
fbkwParam.Value = 0;
if (meParam == null || fbkwParam.MessageId != meParam.MessageId)
_can.SendMessageById(fbkwParam.MessageId);
}
if (_activePump.HasPreInjection &&
_activePump.ParametersByName.TryGetValue(PumpParameterNames.PreIn, out var preinParam))
{
preinParam.Value = 0;
_can.SendMessageById(preinParam.MessageId);
}
_log.Debug(LogId, "ZeroPumpParameters: ME, FBKW, PreIn zeroed and transmitted.");
}
/// <summary>
/// Checks the QOver zero-flow condition. If QOver reads 0 while the bench
/// motor is above 300 RPM and the oil pump relay is energised, and this
/// condition persists for <see cref="QOverDebounceSec"/> seconds, triggers
/// an emergency stop.
/// </summary>
/// <param name="elapsedMs">Current elapsed milliseconds (from the phase stopwatch or loop counter).</param>
private void CheckQOverSafety(long elapsedMs)
{
double qOver = ReadBenchParameter(BenchParameterNames.QOver);
double benchRpm = ReadBenchParameter(BenchParameterNames.BenchRpm);
bool oilPumpOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var relay)
&& relay.State;
if (qOver == 0 && benchRpm > 300 && oilPumpOn)
{
if (_qOverZeroSinceMs == 0)
_qOverZeroSinceMs = elapsedMs;
else if (elapsedMs - _qOverZeroSinceMs >= QOverDebounceSec * 1000)
{
_log.Error(LogId,
$"QOver zero-flow safety: QOver=0, BenchRPM={benchRpm:F0}, " +
$"OilPump=ON for {QOverDebounceSec}s — emergency stop.");
PerformEmergencyStop("QOver zero-flow: oil flow blocked while motor running");
}
}
else
{
_qOverZeroSinceMs = 0;
}
}
/// <summary>
/// Immediately stops the bench motor, zeroes pump parameters, cancels the
/// test sequence, and fires <see cref="EmergencyStopTriggered"/>.
/// </summary>
private void PerformEmergencyStop(string reason)
{
SetRpm(0);
ZeroPumpParameters();
_cts?.Cancel();
EmergencyStopTriggered?.Invoke(reason);
}
/// <summary>
/// Reads the current alarm bitmask from the Alarms CAN parameter, detects
/// bits that transitioned 0→1 since the last snapshot, and records them
/// in the given phase via <see cref="PhaseDefinition.RecordErrorBit"/>.
/// </summary>
private void PollAlarms(PhaseDefinition phase)
{
int currentMask = (int)ReadBenchParameter(BenchParameterNames.Alarms);
int newBits = currentMask & ~_lastAlarmMask;
if (newBits != 0)
{
for (int bit = 0; bit < 16; bit++)
{
if ((newBits & (1 << bit)) != 0)
{
phase.RecordErrorBit(bit);
_log.Debug(LogId, $"Alarm bit {bit} recorded in phase {phase.Name}");
}
}
}
_lastAlarmMask = currentMask;
}
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private async Task WaitForParameter( private async Task WaitForParameter(

View File

@@ -2,6 +2,8 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Xml.Linq; using System.Xml.Linq;
using HC_APTBS.Models; using HC_APTBS.Models;
using Peak.Can.Basic; using Peak.Can.Basic;
@@ -460,6 +462,18 @@ namespace HC_APTBS.Services.Impl
_log.Error(LogId, $"LoadSettings failed: {ex.Message}"); _log.Error(LogId, $"LoadSettings failed: {ex.Message}");
} }
// Seed default admin account if no users are configured.
if (string.IsNullOrEmpty(_settings.Users))
{
var (salt, hash) = HashPassword("admin");
_settings.Users = $"admin:{salt}:{hash}";
_log.Info(LogId, "No users configured — created default 'admin' account.");
SaveSettings();
}
// Migrate plaintext user:password entries to hashed format.
MigrateUsersIfNeeded();
LoadSensors(); LoadSensors();
LoadClients(); LoadClients();
LoadAlarms(); LoadAlarms();
@@ -742,7 +756,83 @@ namespace HC_APTBS.Services.Impl
catch { } catch { }
} }
// ── Users ───────────────────────────────────────────────────────────────── // ── Users (PBKDF2-HMAC-SHA256 hashed credentials) ─────────────────────────
private const int SaltBytes = 16;
private const int HashBytes = 32;
private const int Pbkdf2Iterations = 600_000;
/// <summary>Generates a random salt and computes the PBKDF2-HMAC-SHA256 hash for <paramref name="password"/>.</summary>
private static (string salt, string hash) HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash));
}
/// <summary>Verifies <paramref name="password"/> against the given Base64 <paramref name="salt"/> and <paramref name="expectedHash"/>.</summary>
private static bool VerifyPassword(string password, string salt, string expectedHash)
{
byte[] saltBytes = Convert.FromBase64String(salt);
byte[] computed = Rfc2898DeriveBytes.Pbkdf2(
password, saltBytes, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(expectedHash));
}
/// <summary>
/// Detects whether <see cref="AppSettings.Users"/> contains legacy plaintext
/// <c>user:password</c> entries and migrates them to <c>user:salt:hash</c>.
/// </summary>
private void MigrateUsersIfNeeded()
{
if (string.IsNullOrEmpty(Settings.Users))
return;
string[] entries = Settings.Users.Split(',');
bool hasLegacy = false;
foreach (string entry in entries)
{
// New format always has exactly 3 colon-separated parts (user:salt:hash).
// Legacy format has exactly 2 parts (user:password).
// Base64 salt/hash never contain commas but may contain '=' padding —
// they will NOT contain additional colons, so Split(':') count is reliable.
string[] parts = entry.Split(':');
if (parts.Length == 2)
{
hasLegacy = true;
break;
}
}
if (!hasLegacy) return;
var migrated = new List<string>(entries.Length);
foreach (string entry in entries)
{
string[] parts = entry.Split(':');
if (parts.Length == 2 && parts[0].Length > 0)
{
// Legacy entry — hash the plaintext password.
var (salt, hash) = HashPassword(parts[1]);
migrated.Add($"{parts[0]}:{salt}:{hash}");
}
else if (parts.Length == 3 && parts[0].Length > 0)
{
// Already migrated entry — keep as-is.
migrated.Add(entry);
}
else
{
_log.Warning(LogId, $"Skipped malformed user entry during migration: '{entry}'");
}
}
Settings.Users = string.Join(",", migrated);
SaveSettings();
_log.Info(LogId, $"Migrated {entries.Length} user credential(s) from plaintext to PBKDF2 hashed format.");
}
/// <inheritdoc/> /// <inheritdoc/>
public bool ValidateUser(string username, string password) public bool ValidateUser(string username, string password)
@@ -750,25 +840,26 @@ namespace HC_APTBS.Services.Impl
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
return false; return false;
string check = username + ":" + password;
foreach (string entry in Settings.Users.Split(',')) foreach (string entry in Settings.Users.Split(','))
{ {
if (entry == check) return true; string[] parts = entry.Split(':');
if (parts.Length == 3 && parts[0] == username)
return VerifyPassword(password, parts[1], parts[2]);
} }
return false; return false;
} }
/// <inheritdoc/> /// <inheritdoc/>
public IReadOnlyDictionary<string, string> GetUsers() public IReadOnlyList<string> GetUsers()
{ {
var dict = new Dictionary<string, string>(); var names = new List<string>();
foreach (string kv in Settings.Users.Split(',')) foreach (string entry in Settings.Users.Split(','))
{ {
string[] parts = kv.Split(':'); string[] parts = entry.Split(':');
if (parts.Length == 2 && parts[0].Length > 0) if (parts.Length == 3 && parts[0].Length > 0)
dict[parts[0]] = parts[1]; names.Add(parts[0]);
} }
return dict; return names;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -776,7 +867,10 @@ namespace HC_APTBS.Services.Impl
{ {
var entries = new List<string>(users.Count); var entries = new List<string>(users.Count);
foreach (var kv in users) foreach (var kv in users)
entries.Add(kv.Key + ":" + kv.Value); {
var (salt, hash) = HashPassword(kv.Value);
entries.Add($"{kv.Key}:{salt}:{hash}");
}
Settings.Users = string.Join(",", entries); Settings.Users = string.Join(",", entries);
SaveSettings(); SaveSettings();

View File

@@ -608,6 +608,39 @@ namespace HC_APTBS.Services.Impl
return result; return result;
} }
// ── IKwpService: fast immobilizer unlock ──────────────────────────────────
/// <inheritdoc/>
public async Task<bool> TryFastUnlockAsync()
{
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "TryFastUnlock: no active K-Line session — skipping");
return false;
}
return await Task.Run(() =>
{
try
{
_log.Info(LogId, "TryFastUnlock: sending unlock command over K-Line");
var packets = _sessionKwp.SendCustom(
new List<byte> { 0x02, 0x88, 0x02, 0x03, 0xA8, 0x01, 0x00 });
bool nak = packets.Count == 1
&& packets[0] is HC_APTBS.Infrastructure.Kwp.Packets.NakPacket;
_log.Info(LogId, $"TryFastUnlock: {(nak ? "NAK pump rejected" : "ACK pump unlocked")}");
return !nak;
}
catch (Exception ex)
{
_log.Warning(LogId, $"TryFastUnlock failed: {ex.Message}");
return false;
}
});
}
// ── IKwpService: device detection ──────────────────────────────────────── // ── IKwpService: device detection ────────────────────────────────────────
/// <inheritdoc/> /// <inheritdoc/>

View File

@@ -0,0 +1,83 @@
using System;
using System.Windows;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Manages runtime language switching by swapping a merged
/// <see cref="ResourceDictionary"/> in <see cref="Application.Current"/>.
/// </summary>
/// <remarks>
/// On construction the service reads <see cref="Models.AppSettings.Language"/>
/// and loads the corresponding dictionary. Subsequent calls to
/// <see cref="SetLanguage"/> replace it in-place and persist the preference.
/// </remarks>
public sealed class LocalizationService : ILocalizationService
{
private const string EspUri = "pack://application:,,,/Resources/Strings.es.xaml";
private const string EngUri = "pack://application:,,,/Resources/Strings.en.xaml";
private readonly IConfigurationService _config;
private ResourceDictionary? _currentDictionary;
/// <inheritdoc />
public string CurrentLanguage { get; private set; } = "ESP";
/// <inheritdoc />
public event Action? LanguageChanged;
/// <summary>
/// Initialises the localization service and loads the language
/// stored in <see cref="Models.AppSettings.Language"/>.
/// </summary>
public LocalizationService(IConfigurationService config)
{
_config = config;
// Load persisted language without saving (already persisted).
LoadDictionary(_config.Settings.Language);
}
/// <inheritdoc />
public void SetLanguage(string languageCode)
{
var code = NormaliseCode(languageCode);
if (code == CurrentLanguage)
return;
LoadDictionary(code);
// Persist the choice.
_config.Settings.Language = code;
_config.SaveSettings();
LanguageChanged?.Invoke();
}
/// <inheritdoc />
public string GetString(string key)
{
return Application.Current.Resources[key]?.ToString() ?? key;
}
// ── Helpers ──────────────────────────────────────────────────────────────
private void LoadDictionary(string languageCode)
{
var code = NormaliseCode(languageCode);
var uri = code == "ENG" ? EngUri : EspUri;
var dict = new ResourceDictionary { Source = new Uri(uri, UriKind.Absolute) };
var merged = Application.Current.Resources.MergedDictionaries;
if (_currentDictionary != null)
merged.Remove(_currentDictionary);
merged.Add(dict);
_currentDictionary = dict;
CurrentLanguage = code;
}
private static string NormaliseCode(string code) =>
string.Equals(code, "ENG", StringComparison.OrdinalIgnoreCase) ? "ENG" : "ESP";
}
}

View File

@@ -28,12 +28,15 @@ namespace HC_APTBS.Services.Impl
public sealed class PdfService : IPdfService public sealed class PdfService : IPdfService
{ {
private readonly IConfigurationService _config; private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private readonly byte[]? _defaultLogo; private readonly byte[]? _defaultLogo;
/// <param name="configService">Provides company name, logo path, and report settings.</param> /// <param name="configService">Provides company name, logo path, and report settings.</param>
public PdfService(IConfigurationService configService) /// <param name="localizationService">Provides localised strings for report text.</param>
public PdfService(IConfigurationService configService, ILocalizationService localizationService)
{ {
_config = configService; _config = configService;
_loc = localizationService;
// QuestPDF community licence — required for open-source use. // QuestPDF community licence — required for open-source use.
QuestPDF.Settings.License = LicenseType.Community; QuestPDF.Settings.License = LicenseType.Community;
@@ -121,11 +124,11 @@ namespace HC_APTBS.Services.Impl
// Date / operator / client block. // Date / operator / client block.
row.ConstantItem(140).AlignRight().Column(col => row.ConstantItem(140).AlignRight().Column(col =>
{ {
col.Item().Text($"Date: {reportDate:dd/MM/yyyy HH:mm}") col.Item().Text(string.Format(_loc.GetString("Pdf.Date"), reportDate))
.FontSize(ReportTheme.CaptionSize + 1); .FontSize(ReportTheme.CaptionSize + 1);
col.Item().Text($"Operator: {operatorName}") col.Item().Text(string.Format(_loc.GetString("Pdf.Operator"), operatorName))
.FontSize(ReportTheme.CaptionSize + 1); .FontSize(ReportTheme.CaptionSize + 1);
col.Item().Text($"Client: {clientName}") col.Item().Text(string.Format(_loc.GetString("Pdf.Client"), clientName))
.FontSize(ReportTheme.CaptionSize + 1).Bold(); .FontSize(ReportTheme.CaptionSize + 1).Bold();
}); });
}); });
@@ -137,7 +140,7 @@ namespace HC_APTBS.Services.Impl
// Report title. // Report title.
outer.Item().PaddingTop(4).PaddingBottom(2) outer.Item().PaddingTop(4).PaddingBottom(2)
.AlignCenter() .AlignCenter()
.Text("VP44 INJECTION PUMP TEST REPORT") .Text(_loc.GetString("Pdf.ReportTitle"))
.Bold().FontSize(ReportTheme.SectionHeaderSize) .Bold().FontSize(ReportTheme.SectionHeaderSize)
.FontColor(ReportTheme.HeaderNavy); .FontColor(ReportTheme.HeaderNavy);
}); });
@@ -146,23 +149,23 @@ namespace HC_APTBS.Services.Impl
// ── Footer ──────────────────────────────────────────────────────────────── // ── Footer ────────────────────────────────────────────────────────────────
/// <summary>Renders the page footer: divider, attribution, and page numbers.</summary> /// <summary>Renders the page footer: divider, attribution, and page numbers.</summary>
private static void ComposeFooter(IContainer container) private void ComposeFooter(IContainer container)
{ {
container.Column(col => container.Column(col =>
{ {
col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine); col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine);
col.Item().PaddingTop(3).Row(row => col.Item().PaddingTop(3).Row(row =>
{ {
row.RelativeItem().Text("Generated by HC-APTBS") row.RelativeItem().Text(_loc.GetString("Pdf.GeneratedBy"))
.FontSize(ReportTheme.FooterSize) .FontSize(ReportTheme.FooterSize)
.FontColor(ReportTheme.HeaderGrey); .FontColor(ReportTheme.HeaderGrey);
row.ConstantItem(100).AlignRight().Text(t => row.ConstantItem(100).AlignRight().Text(t =>
{ {
t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize)); t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize));
t.Span("Page "); t.Span(_loc.GetString("Pdf.Page"));
t.CurrentPageNumber(); t.CurrentPageNumber();
t.Span(" of "); t.Span(_loc.GetString("Pdf.Of"));
t.TotalPages(); t.TotalPages();
}); });
}); });
@@ -172,7 +175,7 @@ namespace HC_APTBS.Services.Impl
// ── Content ─────────────────────────────────────────────────────────────── // ── Content ───────────────────────────────────────────────────────────────
/// <summary>Composes the full report body: pump info, ECU data, verdict, test sections.</summary> /// <summary>Composes the full report body: pump info, ECU data, verdict, test sections.</summary>
private static void ComposeContent(IContainer container, PumpDefinition pump) private void ComposeContent(IContainer container, PumpDefinition pump)
{ {
container.PaddingTop(6).Column(col => container.PaddingTop(6).Column(col =>
{ {
@@ -202,7 +205,7 @@ namespace HC_APTBS.Services.Impl
// ── Pump info table ─────────────────────────────────────────────────────── // ── Pump info table ───────────────────────────────────────────────────────
/// <summary>Renders the pump identification table with alternating row stripes.</summary> /// <summary>Renders the pump identification table with alternating row stripes.</summary>
private static void ComposePumpInfoTable(IContainer container, PumpDefinition pump) private void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
{ {
container.Table(table => container.Table(table =>
{ {
@@ -219,7 +222,7 @@ namespace HC_APTBS.Services.Impl
header.Cell().ColumnSpan(4) header.Cell().ColumnSpan(4)
.Background(ReportTheme.HeaderNavy) .Background(ReportTheme.HeaderNavy)
.Padding(5) .Padding(5)
.Text("PUMP IDENTIFICATION") .Text(_loc.GetString("Pdf.PumpIdentification"))
.FontColor(Colors.White).Bold() .FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize); .FontSize(ReportTheme.SectionHeaderSize);
}); });
@@ -239,20 +242,20 @@ namespace HC_APTBS.Services.Impl
.Text(value2).FontSize(ReportTheme.BodySize); .Text(value2).FontSize(ReportTheme.BodySize);
} }
AddRow("Pump ID:", pump.Id, "Model:", pump.Model); AddRow(_loc.GetString("Pdf.PumpId"), pump.Id, _loc.GetString("Pdf.Model"), pump.Model);
AddRow("Serial No.:", pump.SerialNumber, "Injector:", pump.Injector); AddRow(_loc.GetString("Pdf.SerialNo"), pump.SerialNumber, _loc.GetString("Pdf.Injector"), pump.Injector);
AddRow("Tube:", pump.Tube, "Valve:", pump.Valve); AddRow(_loc.GetString("Pdf.Tube"), pump.Tube, _loc.GetString("Pdf.Valve"), pump.Valve);
AddRow("Tension:", pump.Tension, "Rotation:", pump.Rotation); AddRow(_loc.GetString("Pdf.Tension"), pump.Tension, _loc.GetString("Pdf.Rotation"), pump.Rotation);
AddRow("Lock Angle:", $"{pump.LockAngle:F2}\u00B0", AddRow(_loc.GetString("Pdf.LockAngle"), $"{pump.LockAngle:F2}\u00B0",
"Measured:", $"{pump.LockAngleResult:F2}\u00B0"); _loc.GetString("Pdf.Measured"), $"{pump.LockAngleResult:F2}\u00B0");
AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No"); AddRow(_loc.GetString("Pdf.Chaveta"), pump.Chaveta, _loc.GetString("Pdf.PreInj"), pump.HasPreInjection ? _loc.GetString("Common.Yes") : _loc.GetString("Common.No"));
}); });
} }
// ── K-Line table ────────────────────────────────────────────────────────── // ── K-Line table ──────────────────────────────────────────────────────────
/// <summary>Renders the K-Line ECU data table with alternating row stripes.</summary> /// <summary>Renders the K-Line ECU data table with alternating row stripes.</summary>
private static void ComposeKlineTable(IContainer container, PumpDefinition pump) private void ComposeKlineTable(IContainer container, PumpDefinition pump)
{ {
container.Table(table => container.Table(table =>
{ {
@@ -269,7 +272,7 @@ namespace HC_APTBS.Services.Impl
header.Cell().ColumnSpan(4) header.Cell().ColumnSpan(4)
.Background(ReportTheme.HeaderNavy) .Background(ReportTheme.HeaderNavy)
.Padding(5) .Padding(5)
.Text("ECU DATA (K-Line)") .Text(_loc.GetString("Pdf.EcuData"))
.FontColor(Colors.White).Bold() .FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize); .FontSize(ReportTheme.SectionHeaderSize);
}); });
@@ -301,7 +304,7 @@ namespace HC_APTBS.Services.Impl
// ── Verdict section ─────────────────────────────────────────────────────── // ── Verdict section ───────────────────────────────────────────────────────
/// <summary>Renders the overall test result badge with summary statistics.</summary> /// <summary>Renders the overall test result badge with summary statistics.</summary>
private static void ComposeVerdictSection(IContainer container, PumpDefinition pump) private void ComposeVerdictSection(IContainer container, PumpDefinition pump)
{ {
// Compute summary statistics. // Compute summary statistics.
var testsWithResults = pump.Tests.Where(t => t.HasResults()).ToList(); var testsWithResults = pump.Tests.Where(t => t.HasResults()).ToList();
@@ -339,16 +342,16 @@ namespace HC_APTBS.Services.Impl
// Summary statistics. // Summary statistics.
row.RelativeItem().PaddingLeft(12).Column(col => row.RelativeItem().PaddingLeft(12).Column(col =>
{ {
col.Item().Text("OVERALL TEST RESULT") col.Item().Text(_loc.GetString("Pdf.OverallResult"))
.Bold().FontSize(ReportTheme.SectionHeaderSize) .Bold().FontSize(ReportTheme.SectionHeaderSize)
.FontColor(ReportTheme.HeaderNavy); .FontColor(ReportTheme.HeaderNavy);
col.Item().PaddingTop(4).Text( col.Item().PaddingTop(4).Text(
$"Tests executed: {testedCount} of {totalTests}") string.Format(_loc.GetString("Pdf.TestsExecuted"), testedCount, totalTests))
.FontSize(ReportTheme.BodySize); .FontSize(ReportTheme.BodySize);
col.Item().Text( col.Item().Text(
$"Parameters evaluated: {passedPhases} / {totalPhases} passed") string.Format(_loc.GetString("Pdf.ParamsEvaluated"), passedPhases, totalPhases))
.FontSize(ReportTheme.BodySize); .FontSize(ReportTheme.BodySize);
// Per-test mini indicators. // Per-test mini indicators.
@@ -383,7 +386,7 @@ namespace HC_APTBS.Services.Impl
// ── Test results section ────────────────────────────────────────────────── // ── Test results section ──────────────────────────────────────────────────
/// <summary>Renders a single test: results table followed by measurement charts.</summary> /// <summary>Renders a single test: results table followed by measurement charts.</summary>
private static void ComposeTestSection(IContainer container, TestDefinition test) private void ComposeTestSection(IContainer container, TestDefinition test)
{ {
container.Column(col => container.Column(col =>
{ {
@@ -391,7 +394,7 @@ namespace HC_APTBS.Services.Impl
col.Item() col.Item()
.Background(ReportTheme.HeaderNavy) .Background(ReportTheme.HeaderNavy)
.Padding(5) .Padding(5)
.Text($"TEST: {test.Name}") .Text(string.Format(_loc.GetString("Pdf.TestHeader"), test.Name))
.FontColor(Colors.White).Bold() .FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize); .FontSize(ReportTheme.SectionHeaderSize);
@@ -405,7 +408,7 @@ namespace HC_APTBS.Services.Impl
} }
/// <summary>Renders the pass/fail results table for one test.</summary> /// <summary>Renders the pass/fail results table for one test.</summary>
private static void ComposeResultsTable(IContainer container, TestDefinition test) private void ComposeResultsTable(IContainer container, TestDefinition test)
{ {
container.Table(table => container.Table(table =>
{ {
@@ -421,7 +424,7 @@ namespace HC_APTBS.Services.Impl
table.Header(header => table.Header(header =>
{ {
foreach (var h in new[] { "Phase", "Parameter", "Target", "Tolerance \u00B1", "Average", "Result" }) foreach (var h in new[] { _loc.GetString("Pdf.Phase"), _loc.GetString("Pdf.Parameter"), _loc.GetString("Pdf.Target"), _loc.GetString("Pdf.ToleranceHeader"), _loc.GetString("Pdf.Average"), _loc.GetString("Pdf.Result") })
header.Cell() header.Cell()
.Background(ReportTheme.AccentBlue) .Background(ReportTheme.AccentBlue)
.Padding(ReportTheme.CellPad) .Padding(ReportTheme.CellPad)
@@ -440,7 +443,7 @@ namespace HC_APTBS.Services.Impl
if (tp.Result == null) continue; if (tp.Result == null) continue;
bool passed = tp.Result.Passed; bool passed = tp.Result.Passed;
string resultText = passed ? "PASS" : "FAIL"; string resultText = passed ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail");
// Alternating base row colour, tinted by pass/fail. // Alternating base row colour, tinted by pass/fail.
string bgColor = passed string bgColor = passed
@@ -471,7 +474,7 @@ namespace HC_APTBS.Services.Impl
table.Cell().ColumnSpan(6) table.Cell().ColumnSpan(6)
.Background(ReportTheme.WarningBg) .Background(ReportTheme.WarningBg)
.Padding(ReportTheme.CellPad) .Padding(ReportTheme.CellPad)
.Text($" \u26A0 Error bits: {string.Join(", ", phase.ErrorBits)}") .Text(string.Format(_loc.GetString("Pdf.ErrorBits"), string.Join(", ", phase.ErrorBits)))
.FontSize(ReportTheme.CaptionSize + 1) .FontSize(ReportTheme.CaptionSize + 1)
.FontColor(ReportTheme.WarningText); .FontColor(ReportTheme.WarningText);
} }
@@ -480,7 +483,7 @@ namespace HC_APTBS.Services.Impl
} }
/// <summary>Renders measurement charts for each parameter that has sample data.</summary> /// <summary>Renders measurement charts for each parameter that has sample data.</summary>
private static void ComposeTestCharts(IContainer container, TestDefinition test) private void ComposeTestCharts(IContainer container, TestDefinition test)
{ {
container.Column(col => container.Column(col =>
{ {
@@ -512,11 +515,13 @@ namespace HC_APTBS.Services.Impl
.FitWidth(); .FitWidth();
// Chart caption. // Chart caption.
string passFailText = tp.Result.Passed
? _loc.GetString("Common.Pass")
: _loc.GetString("Common.Fail");
col.Item().PaddingBottom(ReportTheme.SubsectionGap) col.Item().PaddingBottom(ReportTheme.SubsectionGap)
.Text($"Samples: {tp.Result.Samples.Count} | " + .Text(string.Format(_loc.GetString("Pdf.ChartSamples"),
$"Target: {tp.Value:F2} \u00B1 {tp.Tolerance:F2} | " + tp.Result.Samples.Count, tp.Value, tp.Tolerance,
$"Average: {tp.Result.Average:F2} | " + tp.Result.Average, passFailText))
$"Result: {(tp.Result.Passed ? "PASS" : "FAIL")}")
.FontSize(ReportTheme.CaptionSize) .FontSize(ReportTheme.CaptionSize)
.FontColor(ReportTheme.HeaderGrey); .FontColor(ReportTheme.HeaderGrey);
} }
@@ -525,7 +530,7 @@ namespace HC_APTBS.Services.Impl
if (!anyChart) if (!anyChart)
{ {
col.Item().PaddingTop(2).PaddingBottom(4) col.Item().PaddingTop(2).PaddingBottom(4)
.Text("No sample data available for graphical display.") .Text(_loc.GetString("Pdf.NoSampleData"))
.FontSize(ReportTheme.CaptionSize).Italic() .FontSize(ReportTheme.CaptionSize).Italic()
.FontColor(ReportTheme.HeaderGrey); .FontColor(ReportTheme.HeaderGrey);
} }

View File

@@ -7,31 +7,43 @@ namespace HC_APTBS.Services.Impl
{ {
/// <summary> /// <summary>
/// Implements the immobilizer unlock sequence for Ford VP44 pump ECUs. /// Implements the immobilizer unlock sequence for Ford VP44 pump ECUs.
/// The unlock has two phases: /// <para>The CAN flood messages must start before unlocking and continue running
/// after unlock completes — stopping them causes the pump to re-lock. Call
/// <see cref="StopSenders"/> only when the pump is deselected.</para>
/// <list type="number"> /// <list type="number">
/// <item>Continuous CAN message sends for ~10 minutes (600.5 s)</item> /// <item>Start persistent CAN senders (run until explicitly stopped)</item>
/// <item>A state-machine handshake that cycles through command bytes on 0x700</item> /// <item>Begin the 600 s CAN wait with progress reporting</item>
/// <item>In parallel, wait for K-Line to become Connected, then try the fast
/// unlock (RAM timer shortcut) — if the pump verifies unlocked, cancel
/// the remaining wait</item>
/// <item>TestUnlock state-machine handshake on 0x700</item>
/// <item>Verify via CAN TestUnlock parameter</item>
/// </list> /// </list>
/// </summary> /// </summary>
public sealed class UnlockService : IUnlockService public sealed class UnlockService : IUnlockService
{ {
private readonly ICanService _can; private readonly ICanService _can;
private readonly IKwpService _kwp;
private readonly IAppLogger _log; private readonly IAppLogger _log;
private const string LogId = "UnlockService"; private const string LogId = "UnlockService";
/// <summary>Total duration of the Phase 1 continuous send (milliseconds).</summary> /// <summary>Total duration of the Phase 1 wait (milliseconds).</summary>
private const int UnlockDurationMs = 600_500; private const int UnlockDurationMs = 600_500;
/// <summary>CTS for the persistent CAN senders — lives beyond <see cref="UnlockAsync"/>.</summary>
private CancellationTokenSource? _senderCts;
/// <inheritdoc/> /// <inheritdoc/>
public event Action<string>? StatusChanged; public event Action<string>? StatusChanged;
/// <inheritdoc/> /// <inheritdoc/>
public event Action<bool>? UnlockCompleted; public event Action<bool>? UnlockCompleted;
/// <summary>Creates the unlock service wired to the CAN bus.</summary> /// <summary>Creates the unlock service wired to the CAN and K-Line buses.</summary>
public UnlockService(ICanService canService, IAppLogger logger) public UnlockService(ICanService canService, IKwpService kwpService, IAppLogger logger)
{ {
_can = canService; _can = canService;
_kwp = kwpService;
_log = logger; _log = logger;
} }
@@ -41,17 +53,26 @@ namespace HC_APTBS.Services.Impl
if (pump.UnlockType == 0) return; if (pump.UnlockType == 0) return;
_log.Info(LogId, $"Starting immobilizer unlock (type {pump.UnlockType}) for {pump.Id}"); _log.Info(LogId, $"Starting immobilizer unlock (type {pump.UnlockType}) for {pump.Id}");
// ── Start persistent CAN senders FIRST ───────────────────────────────
// These must be active before any unlock attempt and must continue
// running after the unlock completes to prevent re-locking.
StartSenders(pump.UnlockType);
StatusChanged?.Invoke("Unlocking..."); StatusChanged?.Invoke("Unlocking...");
// ── Phase 1: Continuous sends for ~10 minutes ───────────────────────── // ── 600 s CAN wait + parallel K-Line fast unlock attempt ─────────────
await RunPhase1Async(pump.UnlockType, ct); // The fast unlock shortens the pump's internal 10 min timer via K-Line.
// It can only be attempted once the K-Line session is Connected (the
// read-all-info must finish first). If the fast unlock succeeds AND
// the CAN TestUnlock parameter confirms it, we skip the remaining wait.
await WaitWithFastUnlockAsync(pump, ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// ── Phase 2: TestUnlock state machine ──────────────────────────────── // ── Phase 2: TestUnlock state machine ────────────────────────────────
StatusChanged?.Invoke("Testing unlock..."); StatusChanged?.Invoke("Testing unlock...");
RunTestUnlockSequence(pump.UnlockType); RunTestUnlockSequence(pump.UnlockType);
// ── Verify unlock status ────────────────────────────────────────────── // ── Verify unlock status via CAN TestUnlock parameter ────────────────
bool success = VerifyUnlock(pump); bool success = VerifyUnlock(pump);
_log.Info(LogId, $"Unlock complete — success={success}"); _log.Info(LogId, $"Unlock complete — success={success}");
@@ -59,11 +80,27 @@ namespace HC_APTBS.Services.Impl
UnlockCompleted?.Invoke(success); UnlockCompleted?.Invoke(success);
} }
// ── Phase 1 ────────────────────────────────────────────────────────────── /// <inheritdoc/>
public void StopSenders()
private async Task RunPhase1Async(int unlockType, CancellationToken ct)
{ {
// Build message payloads based on unlock type. if (_senderCts == null) return;
_log.Info(LogId, "Stopping persistent CAN unlock senders");
_senderCts.Cancel();
_senderCts.Dispose();
_senderCts = null;
}
// ── Persistent CAN senders ───────────────────────────────────────────────
/// <summary>
/// Starts the two continuous CAN message senders. They run indefinitely
/// until <see cref="StopSenders"/> is called (on pump deselection).
/// </summary>
private void StartSenders(int unlockType)
{
// Stop any leftover senders from a previous unlock.
StopSenders();
byte[] msg1Data = new byte[8]; byte[] msg1Data = new byte[8];
uint msg1Id; uint msg1Id;
byte[] msg2Data = new byte[8]; byte[] msg2Data = new byte[8];
@@ -88,66 +125,174 @@ namespace HC_APTBS.Services.Impl
return; return;
} }
// Run two parallel senders for the full unlock duration. _senderCts = new CancellationTokenSource();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); var senderCt = _senderCts.Token;
cts.CancelAfter(UnlockDurationMs);
var linkedCt = cts.Token;
var sender1 = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
while (!linkedCt.IsCancellationRequested) while (!senderCt.IsCancellationRequested)
{ {
_can.SendRawMessage(msg1Id, msg1Data); _can.SendRawMessage(msg1Id, msg1Data);
await Task.Delay(500, linkedCt); await Task.Delay(500, senderCt);
} }
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
}, linkedCt); }, senderCt);
var sender2 = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
while (!linkedCt.IsCancellationRequested) while (!senderCt.IsCancellationRequested)
{ {
_can.SendRawMessage(msg2Id, msg2Data); _can.SendRawMessage(msg2Id, msg2Data);
await Task.Delay(50, linkedCt); await Task.Delay(50, senderCt);
} }
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
}, linkedCt); }, senderCt);
// Report progress periodically. _log.Info(LogId, $"Persistent CAN senders started (type {unlockType})");
var progressTask = Task.Run(async () => }
{
var start = DateTime.UtcNow;
try
{
while (!linkedCt.IsCancellationRequested)
{
await Task.Delay(1000, linkedCt);
var elapsed = DateTime.UtcNow - start;
int pct = (int)(elapsed.TotalMilliseconds * 100 / UnlockDurationMs);
string time = $"{(int)elapsed.TotalMinutes:D2}:{elapsed.Seconds:D2}";
StatusChanged?.Invoke($"Unlocking... {Math.Min(pct, 100)}% ({time})");
}
}
catch (OperationCanceledException) { }
}, linkedCt);
await Task.WhenAll(sender1, sender2, progressTask); // ── Wait with parallel fast-unlock ───────────────────────────────────────
/// <summary>
/// Runs the 600 s progress wait. In parallel, monitors the K-Line session:
/// once it becomes Connected, checks if the pump is still locked, sends the
/// fast unlock command, and if the pump verifies unlocked, cancels the
/// remaining wait time.
/// </summary>
private async Task WaitWithFastUnlockAsync(PumpDefinition pump, CancellationToken ct)
{
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
waitCts.CancelAfter(UnlockDurationMs);
var waitCt = waitCts.Token;
// Progress reporting task.
var progressTask = ReportProgressAsync(waitCt);
// Parallel fast-unlock task — awaits K-Line session, then attempts shortcut.
var fastTask = TryFastUnlockWhenReadyAsync(pump, waitCts, ct);
// Wait for either: the full duration elapses, or the fast unlock succeeds
// and cancels the wait CTS.
await Task.WhenAll(progressTask, fastTask);
// If the outer ct was cancelled (user stop), propagate. // If the outer ct was cancelled (user stop), propagate.
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
} }
/// <summary>Reports progress every second until the wait token is cancelled.</summary>
private async Task ReportProgressAsync(CancellationToken waitCt)
{
var start = DateTime.UtcNow;
try
{
while (!waitCt.IsCancellationRequested)
{
await Task.Delay(1000, waitCt);
var elapsed = DateTime.UtcNow - start;
int pct = (int)(elapsed.TotalMilliseconds * 100 / UnlockDurationMs);
string time = $"{(int)elapsed.TotalMinutes:D2}:{elapsed.Seconds:D2}";
StatusChanged?.Invoke($"Unlocking... {Math.Min(pct, 100)}% ({time})");
}
}
catch (OperationCanceledException) { }
}
/// <summary>
/// Waits for the K-Line session to become Connected, then attempts the
/// fast unlock. If the pump verifies unlocked afterward, cancels <paramref name="waitCts"/>
/// to skip the remaining 600 s wait.
/// </summary>
private async Task TryFastUnlockWhenReadyAsync(
PumpDefinition pump, CancellationTokenSource waitCts, CancellationToken ct)
{
try
{
// Wait for K-Line session to become Connected.
if (_kwp.KLineState != KLineConnectionState.Connected)
{
_log.Info(LogId, "Waiting for K-Line session to connect...");
var connectedTcs = new TaskCompletionSource<bool>();
void OnStateChanged(KLineConnectionState state)
{
if (state == KLineConnectionState.Connected)
connectedTcs.TrySetResult(true);
}
_kwp.KLineStateChanged += OnStateChanged;
try
{
// Check again after subscribing (race guard).
if (_kwp.KLineState == KLineConnectionState.Connected)
connectedTcs.TrySetResult(true);
// Wait for connection or cancellation (user cancel or 600 s elapsed).
using var reg = ct.Register(() => connectedTcs.TrySetCanceled());
using var waitReg = waitCts.Token.Register(() => connectedTcs.TrySetCanceled());
await connectedTcs.Task;
}
finally
{
_kwp.KLineStateChanged -= OnStateChanged;
}
}
// K-Line is now connected. Check if the pump is still locked.
if (VerifyUnlock(pump))
{
_log.Info(LogId, "Pump already unlocked — skipping wait");
waitCts.Cancel();
return;
}
// Pump is locked — attempt the fast K-Line unlock (RAM timer shortcut).
_log.Info(LogId, "Attempting K-Line fast unlock (timer shortcut)...");
StatusChanged?.Invoke("Fast unlock attempt...");
bool ack = await _kwp.TryFastUnlockAsync();
if (!ack)
{
_log.Info(LogId, "Fast unlock NAK or failed — continuing normal wait");
StatusChanged?.Invoke("Unlocking...");
return;
}
_log.Info(LogId, "Fast unlock ACK — waiting briefly for pump to process");
// Give the pump a moment to process the timer shortcut, then verify.
await Task.Delay(2000, ct);
if (VerifyUnlock(pump))
{
_log.Info(LogId, "Fast unlock verified — skipping remaining wait");
waitCts.Cancel();
}
else
{
_log.Info(LogId, "Fast unlock ACK'd but pump still locked — continuing normal wait");
StatusChanged?.Invoke("Unlocking...");
}
}
catch (OperationCanceledException)
{
// Wait elapsed or user cancelled — fast unlock window closed, that's fine.
}
catch (Exception ex)
{
_log.Warning(LogId, $"Fast unlock attempt error: {ex.Message}");
}
}
// ── Phase 2: TestUnlock state machine ──────────────────────────────────── // ── Phase 2: TestUnlock state machine ────────────────────────────────────
private void RunTestUnlockSequence(int unlockType) private void RunTestUnlockSequence(int unlockType)
{ {
// The state machine cycles through 4 command bytes, twice.
byte[][] type1Cmds = byte[][] type1Cmds =
{ {
new byte[] { 0xB2, 0, 0, 0, 0, 0, 0, 0 }, new byte[] { 0xB2, 0, 0, 0, 0, 0, 0, 0 },
@@ -188,8 +333,9 @@ namespace HC_APTBS.Services.Impl
switch (pump.UnlockType) switch (pump.UnlockType)
{ {
case 1: case 1:
// Type 1: unlocked when TestUnlock value is non-zero. // Type 1: unlocked when TestUnlock value is zero.
return unlockParam.Value != 0; // Old code: Lock = valor != 0 (non-zero = locked).
return unlockParam.Value == 0;
case 2: case 2:
// Type 2: unlocked when TestUnlock value equals 0xE4 (228). // Type 2: unlocked when TestUnlock value equals 0xE4 (228).
return (int)unlockParam.Value == 0xE4; return (int)unlockParam.Value == 0xE4;

View File

@@ -4,6 +4,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models; using HC_APTBS.Models;
using HC_APTBS.Services; using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels namespace HC_APTBS.ViewModels
{ {
@@ -86,6 +88,22 @@ namespace HC_APTBS.ViewModels
partial void OnIsOilPumpOnChanged(bool value) partial void OnIsOilPumpOnChanged(bool value)
{ {
// Show confirmation dialog when turning oil pump ON (WAcceptOilTurnOn equivalent).
if (value)
{
var vm = new OilPumpConfirmViewModel();
var dlg = new OilPumpConfirmDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (!vm.Accepted)
{
// Revert without re-triggering this handler.
_isOilPumpOn = false;
OnPropertyChanged(nameof(IsOilPumpOn));
return;
}
}
_bench.SetRelay(RelayNames.OilPump, value); _bench.SetRelay(RelayNames.OilPump, value);
} }
@@ -100,22 +118,31 @@ namespace HC_APTBS.ViewModels
/// <summary> /// <summary>
/// Starts the bench motor at the RPM specified in <see cref="RpmInputText"/>. /// Starts the bench motor at the RPM specified in <see cref="RpmInputText"/>.
/// Warns the operator if the oil pump is off. /// Shows a safety warning dialog if the oil pump is off.
/// </summary> /// </summary>
[RelayCommand] [RelayCommand]
private void StartBench() private void StartBench()
{ {
if (!int.TryParse(RpmInputText, out int rpm) || rpm <= 0) return; if (!int.TryParse(RpmInputText, out int rpm) || rpm <= 0) return;
// Safety warning if oil pump is not running. // Safety warning if oil pump is not running (WCareOnRpmOn equivalent).
if (!IsOilPumpOn) if (!IsOilPumpOn)
{ {
var result = MessageBox.Show( var vm = new RpmSafetyWarningViewModel();
"Oil pump is OFF. Start bench without oil circulation?", var dlg = new RpmSafetyWarningDialog(vm) { Owner = Application.Current.MainWindow };
"Oil Pump Warning", dlg.ShowDialog();
MessageBoxButton.YesNo,
MessageBoxImage.Warning); switch (vm.Result)
if (result != MessageBoxResult.Yes) return; {
case RpmSafetyResult.Cancel:
return;
case RpmSafetyResult.ProceedWithOil:
IsOilPumpOn = true;
break;
case RpmSafetyResult.ProceedWithoutOil:
// Operator accepted the risk.
break;
}
} }
// Ensure direction relays are set. // Ensure direction relays are set.

View File

@@ -22,15 +22,17 @@ namespace HC_APTBS.ViewModels
private readonly IKwpService _kwp; private readonly IKwpService _kwp;
private readonly IConfigurationService _config; private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private const string LogId = "DfiManageViewModel"; private const string LogId = "DfiManageViewModel";
// ── Constructor ─────────────────────────────────────────────────────────── // ── Constructor ───────────────────────────────────────────────────────────
/// <summary>Initialises the ViewModel with the required services.</summary> /// <summary>Initialises the ViewModel with the required services.</summary>
public DfiManageViewModel(IKwpService kwpService, IConfigurationService configService) public DfiManageViewModel(IKwpService kwpService, IConfigurationService configService, ILocalizationService loc)
{ {
_kwp = kwpService; _kwp = kwpService;
_config = configService; _config = configService;
_loc = loc;
// Update the slider and LCD display in real time when the DFI is // Update the slider and LCD display in real time when the DFI is
// read during a full K-Line read (PumpIdentificationViewModel flow). // read during a full K-Line read (PumpIdentificationViewModel flow).
@@ -106,8 +108,8 @@ namespace HC_APTBS.ViewModels
string? port = _kwp.DetectKLinePort(); string? port = _kwp.DetectKLinePort();
if (string.IsNullOrEmpty(port)) if (string.IsNullOrEmpty(port))
{ {
MessageBox.Show("K-Line device not found. Check that the FTDI adapter is connected.", MessageBox.Show(_loc.GetString("Error.KLineNotFound"),
"K-Line Error", MessageBoxButton.OK, MessageBoxImage.Warning); _loc.GetString("Error.KLineTitle"), MessageBoxButton.OK, MessageBoxImage.Warning);
return; return;
} }
@@ -138,8 +140,8 @@ namespace HC_APTBS.ViewModels
string? port = _kwp.DetectKLinePort(); string? port = _kwp.DetectKLinePort();
if (string.IsNullOrEmpty(port)) if (string.IsNullOrEmpty(port))
{ {
MessageBox.Show("K-Line device not found. Check that the FTDI adapter is connected.", MessageBox.Show(_loc.GetString("Error.KLineNotFound"),
"K-Line Error", MessageBoxButton.OK, MessageBoxImage.Warning); _loc.GetString("Error.KLineTitle"), MessageBoxButton.OK, MessageBoxImage.Warning);
return; return;
} }

View File

@@ -20,6 +20,7 @@ namespace HC_APTBS.ViewModels.Dialogs
private readonly IKwpService _kwp; private readonly IKwpService _kwp;
private readonly IConfigurationService _config; private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
// ── Constructor ─────────────────────────────────────────────────────────── // ── Constructor ───────────────────────────────────────────────────────────
@@ -27,10 +28,12 @@ namespace HC_APTBS.ViewModels.Dialogs
public KlineErrorsViewModel( public KlineErrorsViewModel(
IKwpService kwpService, IKwpService kwpService,
IConfigurationService configService, IConfigurationService configService,
ILocalizationService loc,
string initialErrors = "") string initialErrors = "")
{ {
_kwp = kwpService; _kwp = kwpService;
_config = configService; _config = configService;
_loc = loc;
ErrorText = initialErrors; ErrorText = initialErrors;
_kwp.ProgressChanged += OnProgress; _kwp.ProgressChanged += OnProgress;
@@ -120,8 +123,8 @@ namespace HC_APTBS.ViewModels.Dialogs
if (!string.IsNullOrEmpty(port)) return port; if (!string.IsNullOrEmpty(port)) return port;
MessageBox.Show( MessageBox.Show(
"K-Line device not found. Check that the FTDI adapter is connected.", _loc.GetString("Error.KLineNotFound"),
"K-Line Error", MessageBoxButton.OK, MessageBoxImage.Warning); _loc.GetString("Error.KLineTitle"), MessageBoxButton.OK, MessageBoxImage.Warning);
return null; return null;
} }

View File

@@ -0,0 +1,51 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the oil pump confirmation dialog shown before activating
/// the oil pump relay. The operator must confirm that oil level and
/// connections have been checked.
/// Equivalent to the old <c>WAcceptOilTurnOn</c> dialog.
/// </summary>
public sealed partial class OilPumpConfirmViewModel : ObservableObject
{
// ── Dialog result ─────────────────────────────────────────────────────────
/// <summary>True if the operator confirmed and accepted.</summary>
public bool Accepted { get; private set; }
// ── Checkbox ──────────────────────────────────────────────────────────────
/// <summary>True when the "I have checked for leaks" checkbox is ticked.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AcceptCommand))]
private bool _leaksChecked;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Confirms the oil pump activation and closes the dialog.</summary>
[RelayCommand(CanExecute = nameof(CanAccept))]
private void Accept()
{
Accepted = true;
RequestClose?.Invoke();
}
private bool CanAccept() => LeaksChecked;
/// <summary>Cancels the oil pump activation and closes the dialog.</summary>
[RelayCommand]
private void Cancel()
{
Accepted = false;
RequestClose?.Invoke();
}
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event System.Action? RequestClose;
}
}

View File

@@ -0,0 +1,72 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// Result of the RPM safety warning dialog.
/// </summary>
public enum RpmSafetyResult
{
/// <summary>User cancelled — do not start the motor.</summary>
Cancel,
/// <summary>Turn on oil pump first, then start the motor.</summary>
ProceedWithOil,
/// <summary>Proceed without oil pump (operator acknowledges risk).</summary>
ProceedWithoutOil
}
/// <summary>
/// ViewModel for the RPM safety warning dialog shown when the operator
/// starts the bench motor while the oil pump is OFF.
/// Equivalent to the old <c>WCareOnRpmOn</c> dialog.
/// </summary>
public sealed partial class RpmSafetyWarningViewModel : ObservableObject
{
// ── Dialog result ─────────────────────────────────────────────────────────
/// <summary>The operator's chosen action.</summary>
public RpmSafetyResult Result { get; private set; } = RpmSafetyResult.Cancel;
// ── Radio button selection ────────────────────────────────────────────────
/// <summary>True when the "turn on oil pump and proceed" option is selected.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AcceptCommand))]
private bool _isOilAndProceedSelected;
/// <summary>True when the "proceed without oil" option is selected.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AcceptCommand))]
private bool _isProceedWithoutOilSelected;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Accepts the selected option and closes the dialog.</summary>
[RelayCommand(CanExecute = nameof(CanAccept))]
private void Accept()
{
Result = IsOilAndProceedSelected
? RpmSafetyResult.ProceedWithOil
: RpmSafetyResult.ProceedWithoutOil;
RequestClose?.Invoke();
}
private bool CanAccept() => IsOilAndProceedSelected || IsProceedWithoutOilSelected;
/// <summary>Cancels and closes the dialog.</summary>
[RelayCommand]
private void Cancel()
{
Result = RpmSafetyResult.Cancel;
RequestClose?.Invoke();
}
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event System.Action? RequestClose;
}
}

View File

@@ -0,0 +1,290 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the application settings dialog.
/// Loads a local copy of every <see cref="AppSettings"/> property so that
/// Cancel discards all changes.
/// </summary>
public sealed partial class SettingsViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
// ── Dialog result ─────────────────────────────────────────────────────
/// <summary>True when the user clicked Accept.</summary>
public bool Accepted { get; private set; }
/// <summary>Raised to close the owning dialog window.</summary>
public event Action? RequestClose;
// ── Collections ───────────────────────────────────────────────────────
/// <summary>Available language codes for the language dropdown.</summary>
public ObservableCollection<string> AvailableLanguages { get; } = new() { "ESP", "ENG" };
/// <summary>RPM-voltage lookup table, editable via DataGrid.</summary>
public ObservableCollection<RpmVoltageRelation> Relations { get; } = new();
/// <summary>Available FTDI device serial numbers for K-Line port selection.</summary>
public ObservableCollection<string> AvailablePorts { get; } = new();
// ── General ───────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedLanguage = "ESP";
[ObservableProperty] private int _daysKeepLogs = 7;
// ── Safety ────────────────────────────────────────────────────────────
[ObservableProperty] private int _tempMax = 45;
[ObservableProperty] private int _tempMin = 35;
[ObservableProperty] private int _securityRpmLimit = 2500;
[ObservableProperty] private int _maxPressureBar = 26;
[ObservableProperty] private double _toleranceUpExtension = 0.08;
[ObservableProperty] private double _tolerancePfpExtension = 0.1;
[ObservableProperty] private bool _defaultIgnoreTin = true;
// ── PID ───────────────────────────────────────────────────────────────
[ObservableProperty] private double _pidP = 0.1;
[ObservableProperty] private double _pidI = 0.1;
[ObservableProperty] private double _pidD = 0.04;
[ObservableProperty] private int _pidLoopMs = 250;
// ── Motor ─────────────────────────────────────────────────────────────
[ObservableProperty] private int _encoderResolution = 4096;
[ObservableProperty] private double _voltageForMaxRpm = 10;
[ObservableProperty] private int _maxRpm = 2500;
[ObservableProperty] private bool _rightRelayValue = true;
// ── Company ───────────────────────────────────────────────────────────
[ObservableProperty] private string _companyName = string.Empty;
[ObservableProperty] private string _companyInfo = string.Empty;
[ObservableProperty] private string _reportLogoPath = string.Empty;
// ── K-Line ────────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedKLinePort = string.Empty;
// ── Advanced (refresh intervals) ──────────────────────────────────────
[ObservableProperty] private int _refreshBenchInterfaceMs = 20;
[ObservableProperty] private int _refreshWhileReadingMs = 1500;
[ObservableProperty] private int _refreshCanBusReadMs = 2;
[ObservableProperty] private int _refreshPumpRequestMs = 250;
[ObservableProperty] private int _refreshPumpParamsMs = 4;
[ObservableProperty] private int _blinkIntervalMs = 1000;
[ObservableProperty] private int _flasherIntervalMs = 800;
// ── Constructor ───────────────────────────────────────────────────────
/// <param name="configService">Configuration service for loading/saving settings.</param>
/// <param name="localizationService">Localization service for language switching.</param>
public SettingsViewModel(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
EnumerateFtdiDevices();
}
// ── Commands ──────────────────────────────────────────────────────────
/// <summary>Copies all local values back to AppSettings, saves, and closes.</summary>
[RelayCommand]
private void Accept()
{
var s = _config.Settings;
// General
s.DaysKeepLogs = DaysKeepLogs;
// Safety
s.TempMax = TempMax;
s.TempMin = TempMin;
s.SecurityRpmLimit = SecurityRpmLimit;
s.MaxPressureBar = MaxPressureBar;
s.ToleranceUpExtension = ToleranceUpExtension;
s.TolerancePfpExtension = TolerancePfpExtension;
s.DefaultIgnoreTin = DefaultIgnoreTin;
// PID
s.PidP = PidP;
s.PidI = PidI;
s.PidD = PidD;
s.PidLoopMs = PidLoopMs;
// Motor
s.EncoderResolution = EncoderResolution;
s.VoltageForMaxRpm = VoltageForMaxRpm;
s.MaxRpm = MaxRpm;
s.RightRelayValue = RightRelayValue;
s.Relations = Relations.Select(r => new RpmVoltageRelation(r.Voltage, r.Rpm)).ToList();
// Company
s.CompanyName = CompanyName;
s.CompanyInfo = CompanyInfo;
s.ReportLogoPath = ReportLogoPath;
// K-Line
s.KLinePort = SelectedKLinePort;
// Advanced
s.RefreshBenchInterfaceMs = RefreshBenchInterfaceMs;
s.RefreshWhileReadingMs = RefreshWhileReadingMs;
s.RefreshCanBusReadMs = RefreshCanBusReadMs;
s.RefreshPumpRequestMs = RefreshPumpRequestMs;
s.RefreshPumpParamsMs = RefreshPumpParamsMs;
s.BlinkIntervalMs = BlinkIntervalMs;
s.FlasherIntervalMs = FlasherIntervalMs;
// Language — switch if changed (also persists via LocalizationService)
if (SelectedLanguage != _loc.CurrentLanguage)
_loc.SetLanguage(SelectedLanguage);
_config.SaveSettings();
Accepted = true;
RequestClose?.Invoke();
}
/// <summary>Discards changes and closes.</summary>
[RelayCommand]
private void Cancel()
{
Accepted = false;
RequestClose?.Invoke();
}
/// <summary>Opens a file dialog to select a company logo image.</summary>
[RelayCommand]
private void BrowseLogo()
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = _loc.GetString("Dialog.Settings.BrowseLogoTitle"),
Filter = "Image files|*.png;*.jpg;*.jpeg;*.bmp|All files|*.*"
};
if (!string.IsNullOrEmpty(ReportLogoPath))
{
try { dlg.InitialDirectory = System.IO.Path.GetDirectoryName(ReportLogoPath); }
catch { /* ignore invalid path */ }
}
if (dlg.ShowDialog() == true)
ReportLogoPath = dlg.FileName;
}
/// <summary>Re-enumerates connected FTDI devices into <see cref="AvailablePorts"/>.</summary>
[RelayCommand]
private void RefreshPorts()
{
EnumerateFtdiDevices();
}
/// <summary>Appends a new empty row to the RPM-voltage relation table.</summary>
[RelayCommand]
private void AddRelation()
{
Relations.Add(new RpmVoltageRelation(0.0, 0));
}
/// <summary>Removes the selected row from the RPM-voltage relation table.</summary>
[RelayCommand]
private void RemoveRelation(RpmVoltageRelation? relation)
{
if (relation != null)
Relations.Remove(relation);
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>
/// Populates <see cref="AvailablePorts"/> with serial numbers of connected
/// FTDI devices. Fails silently if the FTDI driver DLL is not present.
/// </summary>
private void EnumerateFtdiDevices()
{
AvailablePorts.Clear();
try
{
uint count = FtdiInterface.GetDevicesCount();
if (count == 0) return;
var list = new FT_DEVICE_INFO_NODE[count];
FtdiInterface.GetDeviceList(list);
foreach (var device in list)
{
if (!string.IsNullOrEmpty(device.SerialNumber))
AvailablePorts.Add(device.SerialNumber);
}
}
catch
{
// FTDI DLL not loaded or no devices — leave list empty.
}
}
}
}

View File

@@ -0,0 +1,152 @@
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the Ford VP44 immobilizer unlock progress dialog.
/// Tracks Phase 1 (CAN flood ~600 s), Phase 2 (handshake ~4 s), and verification.
/// Equivalent to the old <c>WUnlocker</c> window.
/// </summary>
public sealed partial class UnlockProgressViewModel : ObservableObject, IDisposable
{
private readonly IUnlockService _unlockService;
private readonly ILocalizationService _loc;
private readonly CancellationTokenSource _cts;
/// <summary>Regex to extract percentage and elapsed time from Phase 1 status messages.</summary>
private static readonly Regex ProgressRegex =
new(@"Unlocking\.\.\. (\d+)% \((\d{2}:\d{2})\)", RegexOptions.Compiled);
/// <summary>Creates the ViewModel and subscribes to unlock service events.</summary>
/// <param name="unlockService">The unlock service to monitor.</param>
/// <param name="unlockType">Pump unlock type (1 or 2).</param>
/// <param name="cts">Cancellation token source to cancel the unlock.</param>
public UnlockProgressViewModel(IUnlockService unlockService, int unlockType, CancellationTokenSource cts, ILocalizationService loc)
{
_unlockService = unlockService;
_loc = loc;
_cts = cts;
_unlockTypeLabel = string.Format(_loc.GetString("Dialog.Unlock.TypeLabel"), unlockType);
_phaseText = _loc.GetString("Dialog.Unlock.Phase1");
_elapsedTime = "00:00";
_isCancellable = true;
_unlockService.StatusChanged += OnStatusChanged;
_unlockService.UnlockCompleted += OnUnlockCompleted;
}
// ── Observable properties ────────────────────────────────────────────────
/// <summary>Progress percentage (0100).</summary>
[ObservableProperty] private int _progress;
/// <summary>Elapsed time formatted as MM:SS.</summary>
[ObservableProperty] private string _elapsedTime;
/// <summary>Current phase description.</summary>
[ObservableProperty] private string _phaseText;
/// <summary>Result text shown after completion.</summary>
[ObservableProperty] private string _resultText = string.Empty;
/// <summary>Label for unlock type (e.g. "Type 1").</summary>
[ObservableProperty] private string _unlockTypeLabel;
/// <summary>True when the unlock sequence has finished (success, failure, or cancelled).</summary>
[NotifyCanExecuteChangedFor(nameof(CloseCommand))]
[ObservableProperty] private bool _isComplete;
/// <summary>True while cancellation is allowed (Phase 1 only).</summary>
[NotifyCanExecuteChangedFor(nameof(CancelCommand))]
[ObservableProperty] private bool _isCancellable;
/// <summary>Tri-state result: null = in progress, true = success, false = failure.</summary>
[ObservableProperty] private bool? _isSuccess;
// ── Commands ─────────────────────────────────────────────────────────────
/// <summary>Cancels the unlock sequence (only available during Phase 1).</summary>
[RelayCommand(CanExecute = nameof(IsCancellable))]
private void Cancel()
{
_cts.Cancel();
IsCancellable = false;
IsComplete = true;
IsSuccess = false;
ResultText = _loc.GetString("Dialog.Unlock.Cancelled");
}
/// <summary>Closes the dialog (only available after completion).</summary>
[RelayCommand(CanExecute = nameof(IsComplete))]
private void Close()
{
RequestClose?.Invoke();
}
// ── Events ───────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event Action? RequestClose;
// ── Service event handlers ───────────────────────────────────────────────
private void OnStatusChanged(string msg)
{
Application.Current?.Dispatcher?.Invoke(() =>
{
var match = ProgressRegex.Match(msg);
if (match.Success)
{
Progress = int.Parse(match.Groups[1].Value);
ElapsedTime = match.Groups[2].Value;
return;
}
if (msg == "Fast unlock attempt...")
{
PhaseText = _loc.GetString("Dialog.Unlock.FastAttempt");
}
else if (msg == "Unlocking...")
{
PhaseText = _loc.GetString("Dialog.Unlock.Phase1");
}
else if (msg == "Testing unlock...")
{
PhaseText = _loc.GetString("Dialog.Unlock.Phase2Testing");
IsCancellable = false;
Progress = 100;
}
else if (msg == "Sending...")
{
PhaseText = _loc.GetString("Dialog.Unlock.Phase2Sending");
}
});
}
private void OnUnlockCompleted(bool success)
{
Application.Current?.Dispatcher?.Invoke(() =>
{
IsComplete = true;
IsCancellable = false;
IsSuccess = success;
ResultText = success ? _loc.GetString("Dialog.Unlock.Unlocked") : _loc.GetString("Dialog.Unlock.Failed");
});
}
// ── IDisposable ──────────────────────────────────────────────────────────
/// <summary>Unsubscribes from service events to prevent leaks.</summary>
public void Dispose()
{
_unlockService.StatusChanged -= OnStatusChanged;
_unlockService.UnlockCompleted -= OnUnlockCompleted;
}
}
}

View File

@@ -12,11 +12,13 @@ namespace HC_APTBS.ViewModels.Dialogs
public sealed partial class UserCheckViewModel : ObservableObject public sealed partial class UserCheckViewModel : ObservableObject
{ {
private readonly IConfigurationService _config; private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>Initialises the dialog, optionally pre-filling the last used username.</summary> /// <summary>Initialises the dialog, optionally pre-filling the last used username.</summary>
public UserCheckViewModel(IConfigurationService config, string lastUsername = "") public UserCheckViewModel(IConfigurationService config, ILocalizationService loc, string lastUsername = "")
{ {
_config = config; _config = config;
_loc = loc;
_username = lastUsername; _username = lastUsername;
} }
@@ -55,8 +57,8 @@ namespace HC_APTBS.ViewModels.Dialogs
else else
{ {
MessageBox.Show( MessageBox.Show(
"Invalid username or password.\n(Both are case-sensitive.)", _loc.GetString("Error.AuthInvalid"),
"Authentication Error", _loc.GetString("Error.AuthTitle"),
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Stop); MessageBoxImage.Stop);
} }

View File

@@ -0,0 +1,41 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the voltage warning dialog shown when a pump requiring
/// a specific supply voltage (27 V or 13.5 V) is selected.
/// Equivalent to the old <c>WAlert27v</c> dialog.
/// </summary>
public sealed partial class VoltageWarningViewModel : ObservableObject
{
/// <summary>
/// Creates the voltage warning ViewModel for the specified voltage.
/// </summary>
/// <param name="voltage">Voltage string to display (e.g. "27 V" or "13.5 V").</param>
public VoltageWarningViewModel(string voltage)
{
Voltage = voltage;
}
// ── Display properties ────────────────────────────────────────────────────
/// <summary>The required voltage string shown in the dialog (e.g. "27 V").</summary>
[ObservableProperty] private string _voltage = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Acknowledges the warning and closes the dialog.</summary>
[RelayCommand]
private void Acknowledge()
{
RequestClose?.Invoke();
}
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event System.Action? RequestClose;
}
}

View File

@@ -39,6 +39,7 @@ namespace HC_APTBS.ViewModels
private readonly IConfigurationService _config; private readonly IConfigurationService _config;
private readonly IPdfService _pdf; private readonly IPdfService _pdf;
private readonly IUnlockService _unlock; private readonly IUnlockService _unlock;
private readonly ILocalizationService _loc;
private readonly IAppLogger _log; private readonly IAppLogger _log;
private const string LogId = "MainViewModel"; private const string LogId = "MainViewModel";
@@ -46,9 +47,23 @@ namespace HC_APTBS.ViewModels
private CancellationTokenSource? _testCts; private CancellationTokenSource? _testCts;
// ── Unlock tracking ──────────────────────────────────────────────────────
/// <summary>CTS for the currently running immobilizer unlock, if any.</summary>
private CancellationTokenSource? _unlockCts;
/// <summary>ViewModel for the non-modal unlock progress window.</summary>
private UnlockProgressViewModel? _unlockVm;
/// <summary>The non-modal unlock progress window, if open.</summary>
private UnlockProgressDialog? _unlockDlg;
/// <summary>Remembers the last authenticated username to pre-fill the next auth dialog.</summary> /// <summary>Remembers the last authenticated username to pre-fill the next auth dialog.</summary>
private string _lastAuthenticatedUser = string.Empty; private string _lastAuthenticatedUser = string.Empty;
/// <summary>Tracks whether the last selected pump required 27 V, for transition-based voltage warnings.</summary>
private bool _lastPumpWas27V;
// ── Child ViewModels ────────────────────────────────────────────────────── // ── Child ViewModels ──────────────────────────────────────────────────────
/// <summary>ViewModel for pump selection and K-Line ECU identification.</summary> /// <summary>ViewModel for pump selection and K-Line ECU identification.</summary>
@@ -58,10 +73,10 @@ namespace HC_APTBS.ViewModels
public DfiManageViewModel DfiViewModel { get; } public DfiManageViewModel DfiViewModel { get; }
/// <summary>ViewModel for the test panel showing all test sections and phase cards.</summary> /// <summary>ViewModel for the test panel showing all test sections and phase cards.</summary>
public TestPanelViewModel TestPanel { get; } = new(); public TestPanelViewModel TestPanel { get; }
/// <summary>ViewModel for the measurement results table.</summary> /// <summary>ViewModel for the measurement results table.</summary>
public ResultDisplayViewModel ResultDisplay { get; } = new(); public ResultDisplayViewModel ResultDisplay { get; }
/// <summary>ViewModel for the manual pump control sliders (FBKW, ME, PreIn).</summary> /// <summary>ViewModel for the manual pump control sliders (FBKW, ME, PreIn).</summary>
public PumpControlViewModel PumpControl { get; private set; } = null!; public PumpControlViewModel PumpControl { get; private set; } = null!;
@@ -94,6 +109,7 @@ namespace HC_APTBS.ViewModels
IConfigurationService configService, IConfigurationService configService,
IPdfService pdfService, IPdfService pdfService,
IUnlockService unlockService, IUnlockService unlockService,
ILocalizationService localizationService,
IAppLogger logger) IAppLogger logger)
{ {
_can = canService; _can = canService;
@@ -102,10 +118,15 @@ namespace HC_APTBS.ViewModels
_config = configService; _config = configService;
_pdf = pdfService; _pdf = pdfService;
_unlock = unlockService; _unlock = unlockService;
_loc = localizationService;
_log = logger; _log = logger;
PumpIdentification = new PumpIdentificationViewModel(kwpService, configService, logger); _loc.LanguageChanged += RefreshLocalisedStrings;
DfiViewModel = new DfiManageViewModel(kwpService, configService);
TestPanel = new TestPanelViewModel(localizationService);
ResultDisplay = new ResultDisplayViewModel(localizationService);
PumpIdentification = new PumpIdentificationViewModel(kwpService, configService, localizationService, logger);
DfiViewModel = new DfiManageViewModel(kwpService, configService, localizationService);
PumpControl = new PumpControlViewModel(benchService); PumpControl = new PumpControlViewModel(benchService);
BenchControl = new BenchControlViewModel(benchService, configService); BenchControl = new BenchControlViewModel(benchService, configService);
AngleDisplay = new AngleDisplayViewModel(configService); AngleDisplay = new AngleDisplayViewModel(configService);
@@ -159,6 +180,18 @@ namespace HC_APTBS.ViewModels
FlowmeterChart.SetTolerance(paramName, value, tolerance); FlowmeterChart.SetTolerance(paramName, value, tolerance);
}); });
_bench.MeasurementSampled += (name, value) => App.Current.Dispatcher.Invoke(() =>
{
if (name == BenchParameterNames.QDelivery)
FlowmeterChart.Delivery.AddValue(value);
else if (name == BenchParameterNames.QOver)
FlowmeterChart.Over.AddValue(value);
});
_bench.EmergencyStopTriggered += reason => App.Current.Dispatcher.Invoke(() =>
{
VerboseStatus = string.Format(_loc.GetString("Error.EmergencyStop"), reason);
});
// Angle display: lock angle and PSG zero from test phases // Angle display: lock angle and PSG zero from test phases
_bench.LockAngleFaseReady += () => App.Current.Dispatcher.Invoke(() => _bench.LockAngleFaseReady += () => App.Current.Dispatcher.Invoke(() =>
{ {
@@ -226,12 +259,73 @@ namespace HC_APTBS.ViewModels
// Notify commands that depend on pump availability. // Notify commands that depend on pump availability.
StartTestCommand.NotifyCanExecuteChanged(); StartTestCommand.NotifyCanExecuteChanged();
GenerateReportCommand.NotifyCanExecuteChanged(); GenerateReportCommand.NotifyCanExecuteChanged();
// Show voltage warning on 27V ↔ 13.5V transitions (WAlert27v equivalent).
CheckVoltageWarning(pump);
// Start immobilizer unlock if this pump requires it (Ford VP44).
StartUnlockIfRequired(pump);
}
// ── Immobilizer unlock ────────────────────────────────────────────────────
/// <summary>
/// Starts the immobilizer unlock sequence in a non-modal window if the pump
/// requires it (UnlockType != 0). Cancels any previously running unlock first.
/// </summary>
private void StartUnlockIfRequired(PumpDefinition pump)
{
// Cancel and close any previous unlock window.
CloseUnlockDialog();
if (pump.UnlockType == 0) return;
_unlockCts = new CancellationTokenSource();
_unlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
_unlockDlg = new UnlockProgressDialog(_unlockVm)
{ Owner = Application.Current.MainWindow };
// Start unlock in background — ViewModel tracks via event subscriptions.
var unlockTask = _unlock.UnlockAsync(pump, _unlockCts.Token);
_ = unlockTask.ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnFaulted);
_unlockDlg.Show(); // Non-modal — user can continue working.
}
/// <summary>
/// Cancels any running unlock, stops persistent CAN senders, closes the
/// window, and disposes resources. Safe to call when no unlock is active.
/// </summary>
private void CloseUnlockDialog()
{
// Stop the persistent CAN unlock senders (prevents re-lock until
// this point — only called when the pump is deselected).
_unlock.StopSenders();
if (_unlockCts != null)
{
_unlockCts.Cancel();
_unlockCts.Dispose();
_unlockCts = null;
}
if (_unlockVm != null)
{
_unlockVm.Dispose();
_unlockVm = null;
}
if (_unlockDlg != null)
{
_unlockDlg.ForceClose();
_unlockDlg = null;
}
} }
// ── CAN connection ──────────────────────────────────────────────────────── // ── CAN connection ────────────────────────────────────────────────────────
/// <summary>CAN bus status display text.</summary> /// <summary>CAN bus status display text.</summary>
[ObservableProperty] private string _canStatusText = "Disconnected"; [ObservableProperty] private string _canStatusText = string.Empty;
/// <summary>True when the CAN bus adapter is connected.</summary> /// <summary>True when the CAN bus adapter is connected.</summary>
[ObservableProperty] private bool _isCanConnected; [ObservableProperty] private bool _isCanConnected;
@@ -243,7 +337,7 @@ namespace HC_APTBS.ViewModels
_can.SetParameters(_config.Bench.ParametersById); _can.SetParameters(_config.Bench.ParametersById);
_can.RegisterBenchMessageIds(GetReceiveMessageIds(_config.Bench.ParametersById)); _can.RegisterBenchMessageIds(GetReceiveMessageIds(_config.Bench.ParametersById));
bool ok = _can.Connect(); bool ok = _can.Connect();
CanStatusText = ok ? "Connected" : "Connection failed"; CanStatusText = ok ? _loc.GetString("Status.Connected") : _loc.GetString("Status.ConnectionFailed");
IsCanConnected = ok; IsCanConnected = ok;
if (ok) if (ok)
@@ -265,7 +359,7 @@ namespace HC_APTBS.ViewModels
_bench.StopPumpSender(); _bench.StopPumpSender();
_can.Disconnect(); _can.Disconnect();
IsCanConnected = false; IsCanConnected = false;
CanStatusText = "Disconnected"; CanStatusText = _loc.GetString("Status.Disconnected");
} }
// ── Live bench readings ─────────────────────────────────────────────────── // ── Live bench readings ───────────────────────────────────────────────────
@@ -361,18 +455,24 @@ namespace HC_APTBS.ViewModels
{ {
if (CurrentPump == null) return; if (CurrentPump == null) return;
// Block test start if an unlock is still in progress.
if (_unlockVm != null && !_unlockVm.IsComplete)
{
VerboseStatus = _loc.GetString("Status.UnlockInProgress");
return;
}
// Block test start if the unlock failed or was cancelled.
if (CurrentPump.UnlockType != 0 && _unlockVm?.IsSuccess != true)
{
VerboseStatus = _loc.GetString("Status.UnlockRequired");
return;
}
_testCts = new CancellationTokenSource(); _testCts = new CancellationTokenSource();
IsTestRunning = true; IsTestRunning = true;
IsTestSaved = false; IsTestSaved = false;
// Run immobilizer unlock if required (e.g. Ford pumps).
if (CurrentPump.UnlockType != 0)
{
VerboseStatus = "Immobilizer unlock in progress...";
await _unlock.UnlockAsync(CurrentPump, _testCts.Token);
if (_testCts.Token.IsCancellationRequested) return;
}
await _bench.RunTestsAsync(CurrentPump, _testCts.Token); await _bench.RunTestsAsync(CurrentPump, _testCts.Token);
} }
@@ -418,7 +518,7 @@ namespace HC_APTBS.ViewModels
if (CurrentPump == null) return; if (CurrentPump == null) return;
// Step 1: Authenticate operator. // Step 1: Authenticate operator.
var authVm = new UserCheckViewModel(_config, _lastAuthenticatedUser); var authVm = new UserCheckViewModel(_config, _loc, _lastAuthenticatedUser);
var authDlg = new UserCheckDialog(authVm) { Owner = Application.Current.MainWindow }; var authDlg = new UserCheckDialog(authVm) { Owner = Application.Current.MainWindow };
authDlg.ShowDialog(); authDlg.ShowDialog();
if (!authVm.Accepted) return; if (!authVm.Accepted) return;
@@ -444,22 +544,43 @@ namespace HC_APTBS.ViewModels
catch (Exception ex) catch (Exception ex)
{ {
_log.Error(LogId, $"GenerateReport: {ex.Message}"); _log.Error(LogId, $"GenerateReport: {ex.Message}");
MessageBox.Show($"Failed to generate report:\n{ex.Message}", MessageBox.Show(string.Format(_loc.GetString("Error.ReportGeneration"), ex.Message),
"Report Error", MessageBoxButton.OK, MessageBoxImage.Error); _loc.GetString("Error.ReportTitle"), MessageBoxButton.OK, MessageBoxImage.Error);
} }
} }
private bool CanGenerateReport() private bool CanGenerateReport()
=> CurrentPump != null && !IsTestRunning && CurrentPump.Tests.Count > 0; => CurrentPump != null && !IsTestRunning && CurrentPump.Tests.Count > 0;
// ── Commands: language toggle ──────────────────────────────────────────────
/// <summary>Toggles the UI language between Spanish and English.</summary>
[RelayCommand]
private void ToggleLanguage()
{
_loc.SetLanguage(_loc.CurrentLanguage == "ESP" ? "ENG" : "ESP");
}
/// <summary>Refreshes all ViewModel-cached localised strings after a language change.</summary>
private void RefreshLocalisedStrings()
{
CanStatusText = IsCanConnected
? _loc.GetString("Status.Connected")
: _loc.GetString("Status.Disconnected");
}
// ── Commands: settings ──────────────────────────────────────────────────── // ── Commands: settings ────────────────────────────────────────────────────
/// <summary>Saves all current settings and bench configuration to disk.</summary> /// <summary>Opens the settings dialog for editing application configuration.</summary>
[RelayCommand] [RelayCommand]
private void SaveSettings() private void OpenSettings()
{ {
_config.SaveSettings(); var vm = new SettingsViewModel(_config, _loc);
_config.SaveBench(); var dlg = new SettingsDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (vm.Accepted && _refreshTimer != null)
_refreshTimer.Interval = TimeSpan.FromMilliseconds(_config.Settings.RefreshBenchInterfaceMs);
} }
// ── Initialisation ──────────────────────────────────────────────────────── // ── Initialisation ────────────────────────────────────────────────────────
@@ -571,7 +692,7 @@ namespace HC_APTBS.ViewModels
=> App.Current.Dispatcher.Invoke(() => => App.Current.Dispatcher.Invoke(() =>
{ {
IsTestRunning = true; IsTestRunning = true;
VerboseStatus = "Test started..."; VerboseStatus = _loc.GetString("Test.Started");
TestPanel.IsRunning = true; TestPanel.IsRunning = true;
TestPanel.ResetResults(); TestPanel.ResetResults();
ResultDisplay.Clear(); ResultDisplay.Clear();
@@ -585,7 +706,7 @@ namespace HC_APTBS.ViewModels
{ {
IsTestRunning = false; IsTestRunning = false;
LastTestSuccess = !interrupted && success; LastTestSuccess = !interrupted && success;
VerboseStatus = interrupted ? "Test stopped." : (success ? "PASS" : "FAIL"); VerboseStatus = interrupted ? _loc.GetString("Test.Stopped") : (success ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail"));
TestPanel.IsRunning = false; TestPanel.IsRunning = false;
_bench.StopPumpSender(); _bench.StopPumpSender();
StartTestCommand.NotifyCanExecuteChanged(); StartTestCommand.NotifyCanExecuteChanged();
@@ -611,10 +732,40 @@ namespace HC_APTBS.ViewModels
_bench.SetRelay(RelayNames.Electronic, true); _bench.SetRelay(RelayNames.Electronic, true);
}); });
private static void ShowPsgSyncError() private void ShowPsgSyncError()
=> MessageBox.Show( => MessageBox.Show(
"PSG sync pulse not detected. Check encoder connection.", _loc.GetString("Error.PsgSync"),
"PSG Error", MessageBoxButton.OK, MessageBoxImage.Warning); _loc.GetString("Error.PsgTitle"), MessageBoxButton.OK, MessageBoxImage.Warning);
// ── Voltage warning ────────────────────────────────────────────────────────
/// <summary>
/// Shows a voltage warning dialog when the pump supply voltage requirement
/// changes between 27 V and 13.5 V (or vice versa). Only triggers on
/// state transitions, matching the old <c>WAlert27v</c> behaviour.
/// </summary>
private void CheckVoltageWarning(PumpDefinition pump)
{
bool is27V = !string.IsNullOrEmpty(pump.Tension)
&& pump.Tension.Contains("27");
if (is27V && !_lastPumpWas27V)
{
var vm = new Dialogs.VoltageWarningViewModel("27 V");
var dlg = new Views.Dialogs.VoltageWarningDialog(vm)
{ Owner = Application.Current.MainWindow };
dlg.ShowDialog();
_lastPumpWas27V = true;
}
else if (!is27V && _lastPumpWas27V)
{
var vm = new Dialogs.VoltageWarningViewModel("13.5 V");
var dlg = new Views.Dialogs.VoltageWarningDialog(vm)
{ Owner = Application.Current.MainWindow };
dlg.ShowDialog();
_lastPumpWas27V = false;
}
}
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models; using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels namespace HC_APTBS.ViewModels
{ {
@@ -12,6 +13,10 @@ namespace HC_APTBS.ViewModels
/// </summary> /// </summary>
public sealed partial class PhaseCardViewModel : ObservableObject public sealed partial class PhaseCardViewModel : ObservableObject
{ {
private readonly ILocalizationService _loc;
/// <summary>Initialises a new phase card with a localization service.</summary>
public PhaseCardViewModel(ILocalizationService loc) => _loc = loc;
// ── Identity ────────────────────────────────────────────────────────────── // ── Identity ──────────────────────────────────────────────────────────────
/// <summary>Display name of the phase (e.g. "1 - S_001").</summary> /// <summary>Display name of the phase (e.g. "1 - S_001").</summary>
@@ -82,7 +87,7 @@ namespace HC_APTBS.ViewModels
if (Source != null) if (Source != null)
Source.Enabled = value; Source.Enabled = value;
ResultText = value ? "\u2013" : "disabled"; ResultText = value ? "\u2013" : _loc.GetString("Common.Disabled");
// Notify parent. // Notify parent.
EnabledChanged?.Invoke(this); EnabledChanged?.Invoke(this);
@@ -96,7 +101,7 @@ namespace HC_APTBS.ViewModels
IsActive = false; IsActive = false;
IsPassed = false; IsPassed = false;
IsFailed = false; IsFailed = false;
ResultText = IsEnabled ? "\u2013" : "disabled"; ResultText = IsEnabled ? "\u2013" : _loc.GetString("Common.Disabled");
foreach (var indicator in ResultIndicators) foreach (var indicator in ResultIndicators)
indicator.Reset(); indicator.Reset();

View File

@@ -26,6 +26,7 @@ namespace HC_APTBS.ViewModels
private readonly IKwpService _kwp; private readonly IKwpService _kwp;
private readonly IConfigurationService _config; private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private readonly IAppLogger _log; private readonly IAppLogger _log;
private const string LogId = "PumpIdentVM"; private const string LogId = "PumpIdentVM";
@@ -35,10 +36,12 @@ namespace HC_APTBS.ViewModels
public PumpIdentificationViewModel( public PumpIdentificationViewModel(
IKwpService kwpService, IKwpService kwpService,
IConfigurationService configService, IConfigurationService configService,
ILocalizationService loc,
IAppLogger logger) IAppLogger logger)
{ {
_kwp = kwpService; _kwp = kwpService;
_config = configService; _config = configService;
_loc = loc;
_log = logger; _log = logger;
// Wire KWP progress events to local properties. // Wire KWP progress events to local properties.
@@ -171,7 +174,7 @@ namespace HC_APTBS.ViewModels
if (string.IsNullOrEmpty(port)) if (string.IsNullOrEmpty(port))
{ {
App.Current.Dispatcher.Invoke(() => App.Current.Dispatcher.Invoke(() =>
KlineConnectError = "No K-Line device found"); KlineConnectError = _loc.GetString("Error.KLineNotFound"));
return; return;
} }

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models; using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels namespace HC_APTBS.ViewModels
{ {
@@ -10,6 +11,11 @@ namespace HC_APTBS.ViewModels
/// </summary> /// </summary>
public sealed partial class ResultRowViewModel : ObservableObject public sealed partial class ResultRowViewModel : ObservableObject
{ {
private readonly ILocalizationService _loc;
/// <summary>Initialises a new result row with a localization service.</summary>
public ResultRowViewModel(ILocalizationService loc) => _loc = loc;
[ObservableProperty] private string _phaseName = string.Empty; [ObservableProperty] private string _phaseName = string.Empty;
[ObservableProperty] private string _parameterName = string.Empty; [ObservableProperty] private string _parameterName = string.Empty;
[ObservableProperty] private double _target; [ObservableProperty] private double _target;
@@ -17,8 +23,8 @@ namespace HC_APTBS.ViewModels
[ObservableProperty] private double _average; [ObservableProperty] private double _average;
[ObservableProperty] private bool _passed; [ObservableProperty] private bool _passed;
/// <summary>"PASS" or "FAIL".</summary> /// <summary>Localised "PASS" or "FAIL" label.</summary>
public string ResultLabel => Passed ? "PASS" : "FAIL"; public string ResultLabel => Passed ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail");
} }
/// <summary> /// <summary>
@@ -31,6 +37,11 @@ namespace HC_APTBS.ViewModels
/// </summary> /// </summary>
public sealed partial class ResultDisplayViewModel : ObservableObject public sealed partial class ResultDisplayViewModel : ObservableObject
{ {
private readonly ILocalizationService _loc;
/// <summary>Initialises a new result display with a localization service.</summary>
public ResultDisplayViewModel(ILocalizationService loc) => _loc = loc;
// ── Properties ──────────────────────────────────────────────────────────── // ── Properties ────────────────────────────────────────────────────────────
/// <summary>Name of the test whose results are displayed.</summary> /// <summary>Name of the test whose results are displayed.</summary>
@@ -60,7 +71,7 @@ namespace HC_APTBS.ViewModels
{ {
if (tp.Result == null) continue; if (tp.Result == null) continue;
allPassed = allPassed && tp.Result.Passed; allPassed = allPassed && tp.Result.Passed;
Results.Add(new ResultRowViewModel Results.Add(new ResultRowViewModel(_loc)
{ {
PhaseName = phase.Name, PhaseName = phase.Name,
ParameterName = tp.Name, ParameterName = tp.Name,
@@ -86,7 +97,7 @@ namespace HC_APTBS.ViewModels
return; return;
} }
} }
Results.Add(new ResultRowViewModel Results.Add(new ResultRowViewModel(_loc)
{ {
PhaseName = phaseName, PhaseName = phaseName,
ParameterName = paramName, ParameterName = paramName,
@@ -115,7 +126,7 @@ namespace HC_APTBS.ViewModels
{ {
if (tp.Result == null) continue; if (tp.Result == null) continue;
allPassed = allPassed && tp.Result.Passed; allPassed = allPassed && tp.Result.Passed;
Results.Add(new ResultRowViewModel Results.Add(new ResultRowViewModel(_loc)
{ {
PhaseName = phase.Name, PhaseName = phase.Name,
ParameterName = tp.Name, ParameterName = tp.Name,
@@ -128,7 +139,7 @@ namespace HC_APTBS.ViewModels
} }
} }
TestName = tests.Count > 0 ? "All Tests" : string.Empty; TestName = tests.Count > 0 ? _loc.GetString("Result.AllTests") : string.Empty;
OverallPassed = allPassed && Results.Count > 0; OverallPassed = allPassed && Results.Count > 0;
} }

View File

@@ -1,6 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models; using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels namespace HC_APTBS.ViewModels
{ {
@@ -39,6 +40,10 @@ namespace HC_APTBS.ViewModels
/// </summary> /// </summary>
public sealed partial class TestDisplayViewModel : ObservableObject public sealed partial class TestDisplayViewModel : ObservableObject
{ {
private readonly ILocalizationService _loc;
/// <summary>Initialises a new test display with a localization service.</summary>
public TestDisplayViewModel(ILocalizationService loc) => _loc = loc;
// ── Properties ──────────────────────────────────────────────────────────── // ── Properties ────────────────────────────────────────────────────────────
/// <summary>Name of the test currently being executed (e.g. "F", "SVME").</summary> /// <summary>Name of the test currently being executed (e.g. "F", "SVME").</summary>
@@ -72,7 +77,7 @@ namespace HC_APTBS.ViewModels
{ {
Name = phase.Name, Name = phase.Name,
IsEnabled = phase.Enabled, IsEnabled = phase.Enabled,
ResultText = phase.Enabled ? "" : "disabled" ResultText = phase.Enabled ? "\u2013" : _loc.GetString("Common.Disabled")
}); });
} }
} }
@@ -98,7 +103,7 @@ namespace HC_APTBS.ViewModels
row.IsActive = false; row.IsActive = false;
row.IsPassed = passed; row.IsPassed = passed;
row.IsFailed = !passed; row.IsFailed = !passed;
row.ResultText = passed ? "PASS" : "FAIL"; row.ResultText = passed ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail");
break; break;
} }
} }
@@ -111,7 +116,7 @@ namespace HC_APTBS.ViewModels
row.IsActive = false; row.IsActive = false;
row.IsPassed = false; row.IsPassed = false;
row.IsFailed = false; row.IsFailed = false;
row.ResultText = row.IsEnabled ? "" : "disabled"; row.ResultText = row.IsEnabled ? "\u2013" : _loc.GetString("Common.Disabled");
} }
} }
} }

View File

@@ -3,6 +3,7 @@ using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models; using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels namespace HC_APTBS.ViewModels
{ {
@@ -18,6 +19,11 @@ namespace HC_APTBS.ViewModels
/// </summary> /// </summary>
public sealed partial class TestPanelViewModel : ObservableObject public sealed partial class TestPanelViewModel : ObservableObject
{ {
private readonly ILocalizationService _loc;
/// <summary>Initialises a new test panel with a localization service.</summary>
public TestPanelViewModel(ILocalizationService loc) => _loc = loc;
// ── Cached active phase for fast live-indicator lookup ───────────────────── // ── Cached active phase for fast live-indicator lookup ─────────────────────
private PhaseCardViewModel? _activePhaseCard; private PhaseCardViewModel? _activePhaseCard;
@@ -91,7 +97,7 @@ namespace HC_APTBS.ViewModels
foreach (var testDef in pump.Tests) foreach (var testDef in pump.Tests)
{ {
var section = TestSectionViewModel.FromDefinition(testDef, ShowOperationValues); var section = TestSectionViewModel.FromDefinition(testDef, ShowOperationValues, _loc);
Tests.Add(section); Tests.Add(section);
} }
@@ -152,7 +158,7 @@ namespace HC_APTBS.ViewModels
phase.IsActive = false; phase.IsActive = false;
phase.IsPassed = passed; phase.IsPassed = passed;
phase.IsFailed = !passed; phase.IsFailed = !passed;
phase.ResultText = passed ? "PASS" : "FAIL"; phase.ResultText = passed ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail");
break; break;
} }

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models; using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels namespace HC_APTBS.ViewModels
{ {
@@ -11,6 +12,11 @@ namespace HC_APTBS.ViewModels
/// </summary> /// </summary>
public sealed partial class TestSectionViewModel : ObservableObject public sealed partial class TestSectionViewModel : ObservableObject
{ {
private readonly ILocalizationService _loc;
/// <summary>Initialises a new test section with a localization service.</summary>
public TestSectionViewModel(ILocalizationService loc) => _loc = loc;
// ── Suppress cascade guard ──────────────────────────────────────────────── // ── Suppress cascade guard ────────────────────────────────────────────────
private bool _suppressCascade; private bool _suppressCascade;
@@ -105,12 +111,13 @@ namespace HC_APTBS.ViewModels
/// </summary> /// </summary>
/// <param name="test">Source test definition.</param> /// <param name="test">Source test definition.</param>
/// <param name="showValues">Initial show-operation-values state.</param> /// <param name="showValues">Initial show-operation-values state.</param>
public static TestSectionViewModel FromDefinition(TestDefinition test, bool showValues) /// <param name="loc">Localization service for user-facing strings.</param>
public static TestSectionViewModel FromDefinition(TestDefinition test, bool showValues, ILocalizationService loc)
{ {
var section = new TestSectionViewModel var section = new TestSectionViewModel(loc)
{ {
TestName = test.Name, TestName = test.Name,
Description = MapDescription(test.Name), Description = loc.GetString(MapDescriptionKey(test.Name)),
ConditioningTimeSec = test.ConditioningTimeSec, ConditioningTimeSec = test.ConditioningTimeSec,
MeasurementTimeSec = test.MeasurementTimeSec, MeasurementTimeSec = test.MeasurementTimeSec,
MeasurementsPerSecond = test.MeasurementsPerSecond, MeasurementsPerSecond = test.MeasurementsPerSecond,
@@ -119,12 +126,12 @@ namespace HC_APTBS.ViewModels
foreach (var phaseDef in test.Phases) foreach (var phaseDef in test.Phases)
{ {
var card = new PhaseCardViewModel var card = new PhaseCardViewModel(loc)
{ {
Name = phaseDef.Name, Name = phaseDef.Name,
IsCritical = phaseDef.IsCritical, IsCritical = phaseDef.IsCritical,
IsEnabled = phaseDef.Enabled, IsEnabled = phaseDef.Enabled,
ResultText = phaseDef.Enabled ? "\u2013" : "disabled", ResultText = phaseDef.Enabled ? "\u2013" : loc.GetString("Common.Disabled"),
ShowOperationValues = showValues, ShowOperationValues = showValues,
Source = phaseDef, Source = phaseDef,
EnabledChanged = section.OnChildEnabledChanged EnabledChanged = section.OnChildEnabledChanged
@@ -157,16 +164,17 @@ namespace HC_APTBS.ViewModels
} }
/// <summary> /// <summary>
/// Maps a test type identifier to a human-readable description. /// Maps a test type identifier to a localization resource key.
/// Returns the test name itself for unknown types (fail-visible).
/// </summary> /// </summary>
private static string MapDescription(string testName) => testName switch private static string MapDescriptionKey(string testName) => testName switch
{ {
TestType.Wl => "Warm-up", TestType.Wl => "TestType.Warmup",
TestType.Dfi => "Adjustment", TestType.Dfi => "TestType.Adjustment",
TestType.F => "Flow", TestType.F => "TestType.Flow",
TestType.Svme => "Servo valve", TestType.Svme => "TestType.ServoValve",
TestType.Up => "Upstroke", TestType.Up => "TestType.Upstroke",
TestType.Pfp => "Pre-injection", TestType.Pfp => "TestType.PreInjection",
_ => testName _ => testName
}; };
} }

View File

@@ -4,7 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d" mc:Ignorable="d"
Title="K-Line Fault Codes" Title="{DynamicResource Dialog.KlineErrors.Title}"
Height="400" Width="600" Height="400" Width="600"
ResizeMode="CanResize" ResizeMode="CanResize"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
@@ -28,12 +28,12 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Fault codes:" VerticalAlignment="Center" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="{DynamicResource Dialog.KlineErrors.Header}" VerticalAlignment="Center" FontSize="14" FontWeight="SemiBold"/>
<Button Grid.Column="1" Content="Read" Margin="0,2,8,2" <Button Grid.Column="1" Content="{DynamicResource Dialog.KlineErrors.Read}" Margin="0,2,8,2"
Width="75" Height="24" Width="75" Height="24"
Command="{Binding ReadErrorsCommand}"/> Command="{Binding ReadErrorsCommand}"/>
<Button Grid.Column="2" Content="Clear" Margin="0,2,0,2" <Button Grid.Column="2" Content="{DynamicResource Dialog.KlineErrors.Clear}" Margin="0,2,0,2"
Width="75" Height="24" Width="75" Height="24"
Command="{Binding ClearErrorsCommand}"/> Command="{Binding ClearErrorsCommand}"/>
</Grid> </Grid>
@@ -56,7 +56,7 @@
<ProgressBar Value="{Binding ProgressPercent, Mode=OneWay}" <ProgressBar Value="{Binding ProgressPercent, Mode=OneWay}"
Minimum="0" Maximum="100" Minimum="0" Maximum="100"
VerticalAlignment="Center" Margin="0,0,10,0"/> VerticalAlignment="Center" Margin="0,0,10,0"/>
<Button Grid.Column="1" Content="Close" Width="80" Height="24" <Button Grid.Column="1" Content="{DynamicResource Common.Close}" Width="80" Height="24"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding CloseCommand}"/> Command="{Binding CloseCommand}"/>
</Grid> </Grid>

View File

@@ -0,0 +1,57 @@
<Window x:Class="HC_APTBS.Views.Dialogs.OilPumpConfirmDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
Title="{DynamicResource Dialog.OilPump.Title}"
Height="220" Width="440"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner">
<Grid Margin="16,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="48"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<!-- Warning icon -->
<TextBlock Grid.RowSpan="4" Text="⚠"
FontSize="36" Foreground="DarkOrange"
VerticalAlignment="Top" HorizontalAlignment="Center"
Margin="0,4,8,0"/>
<!-- Title -->
<TextBlock Grid.Column="1" Text="{DynamicResource Common.Warning}"
FontSize="18" FontWeight="Bold" Foreground="DarkOrange"
Margin="0,0,0,8"/>
<!-- Warning message -->
<TextBlock Grid.Row="1" Grid.Column="1" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{DynamicResource Dialog.OilPump.Message}"/>
<!-- Confirmation checkbox -->
<CheckBox Grid.Row="2" Grid.Column="1" Margin="0,4"
IsChecked="{Binding LeaksChecked}"
FontSize="14" FontWeight="SemiBold">
<TextBlock Text="{DynamicResource Dialog.OilPump.LeaksChecked}" TextWrapping="Wrap"/>
</CheckBox>
<!-- Buttons -->
<StackPanel Grid.Row="4" Grid.Column="1"
Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="{DynamicResource Common.Accept}" Width="80" Height="26" Margin="0,0,8,0"
Command="{Binding AcceptCommand}"/>
<Button Content="{DynamicResource Common.Cancel}" Width="80" Height="26"
Command="{Binding CancelCommand}" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,20 @@
using System.Windows;
using HC_APTBS.ViewModels.Dialogs;
namespace HC_APTBS.Views.Dialogs
{
/// <summary>
/// Confirmation dialog shown before activating the oil pump relay.
/// Equivalent to the old <c>WAcceptOilTurnOn</c>.
/// </summary>
public partial class OilPumpConfirmDialog : Window
{
/// <summary>Creates the dialog and wires the ViewModel.</summary>
public OilPumpConfirmDialog(OilPumpConfirmViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.RequestClose += Close;
}
}
}

View File

@@ -4,7 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d" mc:Ignorable="d"
Title="Generate Report" Title="{DynamicResource Dialog.Report.Title}"
Height="450" Width="800" Height="450" Width="800"
ResizeMode="NoResize" ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
@@ -28,11 +28,11 @@
Grid.Column="2" Grid.RowSpan="5" Margin="-3,10,0,0"/> Grid.Column="2" Grid.RowSpan="5" Margin="-3,10,0,0"/>
<!-- ── Section headers ─────────────────────────────────────────────── --> <!-- ── Section headers ─────────────────────────────────────────────── -->
<TextBlock Text="Client List" FontSize="14" FontWeight="SemiBold" <TextBlock Text="{DynamicResource Dialog.Report.ClientList}" FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,10"/> HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,10"/>
<TextBlock Grid.Column="1" Text="Client Data" FontSize="20" FontWeight="SemiBold" <TextBlock Grid.Column="1" Text="{DynamicResource Dialog.Report.ClientData}" FontSize="20" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,10"/> HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,10"/>
<TextBlock Grid.Column="2" Text="Company Data" FontSize="20" FontWeight="SemiBold" <TextBlock Grid.Column="2" Text="{DynamicResource Dialog.Report.CompanyData}" FontSize="20" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,10"/> HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,10"/>
<!-- ── Client list ─────────────────────────────────────────────────── --> <!-- ── Client list ─────────────────────────────────────────────────── -->
@@ -47,17 +47,17 @@
<ColumnDefinition/> <ColumnDefinition/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Label Content="Name:" VerticalAlignment="Center" FontSize="13"/> <Label Content="{DynamicResource Dialog.Report.Name}" VerticalAlignment="Center" FontSize="13"/>
<ComboBox Grid.Column="1" IsEditable="True" <ComboBox Grid.Column="1" IsEditable="True"
ItemsSource="{Binding ClientNames}" ItemsSource="{Binding ClientNames}"
Text="{Binding SelectedClientName, UpdateSourceTrigger=PropertyChanged}" Text="{Binding SelectedClientName, UpdateSourceTrigger=PropertyChanged}"
VerticalAlignment="Center" FontSize="13" Margin="4,0"/> VerticalAlignment="Center" FontSize="13" Margin="4,0"/>
<Button Grid.Column="2" Content="Save" Width="60" Height="24" <Button Grid.Column="2" Content="{DynamicResource Common.Save}" Width="60" Height="24"
Command="{Binding SaveClientCommand}"/> Command="{Binding SaveClientCommand}"/>
</Grid> </Grid>
<!-- ── Client info text ─────────────────────────────────────────────── --> <!-- ── Client info text ─────────────────────────────────────────────── -->
<GroupBox Grid.Column="1" Grid.Row="2" Header="Client information" <GroupBox Grid.Column="1" Grid.Row="2" Header="{DynamicResource Dialog.Report.ClientInfo}"
Margin="8,0,8,4" FontSize="13"> Margin="8,0,8,4" FontSize="13">
<TextBox Text="{Binding ClientInfo, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding ClientInfo, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" AcceptsReturn="True" TextWrapping="Wrap" AcceptsReturn="True"
@@ -65,7 +65,7 @@
</GroupBox> </GroupBox>
<!-- ── Observations ─────────────────────────────────────────────────── --> <!-- ── Observations ─────────────────────────────────────────────────── -->
<GroupBox Grid.Column="1" Grid.Row="3" Header="Observations" <GroupBox Grid.Column="1" Grid.Row="3" Header="{DynamicResource Dialog.Report.Observations}"
Margin="8,0,8,4" FontSize="13"> Margin="8,0,8,4" FontSize="13">
<TextBox Text="{Binding Observations, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding Observations, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" TextWrapping="Wrap"
@@ -78,7 +78,7 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition/> <ColumnDefinition/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Label Content="Operator:" VerticalAlignment="Center" FontSize="13"/> <Label Content="{DynamicResource Dialog.Report.Operator}" VerticalAlignment="Center" FontSize="13"/>
<TextBox Grid.Column="1" Text="{Binding OperatorName, UpdateSourceTrigger=PropertyChanged}" <TextBox Grid.Column="1" Text="{Binding OperatorName, UpdateSourceTrigger=PropertyChanged}"
VerticalAlignment="Center" FontSize="13" Margin="4,0" Height="24" VerticalAlignment="Center" FontSize="13" Margin="4,0" Height="24"
IsReadOnly="True" Background="#F0F0F0"/> IsReadOnly="True" Background="#F0F0F0"/>
@@ -95,11 +95,11 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition/> <ColumnDefinition/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Label Content="Company:" VerticalAlignment="Center" FontSize="13"/> <Label Content="{DynamicResource Dialog.Report.Company}" VerticalAlignment="Center" FontSize="13"/>
<TextBox Grid.Column="1" Text="{Binding CompanyName, UpdateSourceTrigger=PropertyChanged}" <TextBox Grid.Column="1" Text="{Binding CompanyName, UpdateSourceTrigger=PropertyChanged}"
VerticalAlignment="Center" FontSize="13" Margin="4,0" Height="24"/> VerticalAlignment="Center" FontSize="13" Margin="4,0" Height="24"/>
</Grid> </Grid>
<GroupBox Grid.Row="1" Header="Company information" <GroupBox Grid.Row="1" Header="{DynamicResource Dialog.Report.CompanyInfo}"
Margin="0,4,0,0" FontSize="13"> Margin="0,4,0,0" FontSize="13">
<TextBox Text="{Binding CompanyInfo, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding CompanyInfo, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" AcceptsReturn="True" TextWrapping="Wrap" AcceptsReturn="True"
@@ -109,15 +109,15 @@
<!-- ── Buttons row ──────────────────────────────────────────────────── --> <!-- ── Buttons row ──────────────────────────────────────────────────── -->
<Button Grid.Row="4" Grid.Column="0" <Button Grid.Row="4" Grid.Column="0"
Content="Delete Client" Padding="6,2" Margin="10,8,0,8" Content="{DynamicResource Dialog.Report.DeleteClient}" Padding="6,2" Margin="10,8,0,8"
HorizontalAlignment="Left" HorizontalAlignment="Left"
Command="{Binding DeleteClientCommand}"/> Command="{Binding DeleteClientCommand}"/>
<StackPanel Grid.Row="4" Grid.Column="2" <StackPanel Grid.Row="4" Grid.Column="2"
Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,10,8"> Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,10,8">
<Button Content="Generate" Width="80" Height="24" Margin="0,0,8,0" <Button Content="{DynamicResource Dialog.Report.Generate}" Width="80" Height="24" Margin="0,0,8,0"
Command="{Binding AcceptCommand}"/> Command="{Binding AcceptCommand}"/>
<Button Content="Cancel" Width="80" Height="24" <Button Content="{DynamicResource Common.Cancel}" Width="80" Height="24"
Command="{Binding CancelCommand}"/> Command="{Binding CancelCommand}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>

View File

@@ -0,0 +1,63 @@
<Window x:Class="HC_APTBS.Views.Dialogs.RpmSafetyWarningDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
Title="{DynamicResource Dialog.RpmSafety.Title}"
Height="260" Width="460"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner">
<Grid Margin="16,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="48"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<!-- Warning icon -->
<TextBlock Grid.RowSpan="5" Text="⚠"
FontSize="36" Foreground="DarkOrange"
VerticalAlignment="Top" HorizontalAlignment="Center"
Margin="0,4,8,0"/>
<!-- Title -->
<TextBlock Grid.Column="1" Text="{DynamicResource Common.Warning}"
FontSize="18" FontWeight="Bold" Foreground="DarkOrange"
Margin="0,0,0,8"/>
<!-- Warning message -->
<TextBlock Grid.Row="1" Grid.Column="1" TextWrapping="Wrap"
Margin="0,0,0,12"
Text="{DynamicResource Dialog.RpmSafety.Message}"/>
<!-- Option 1: Turn on oil and proceed -->
<RadioButton Grid.Row="2" Grid.Column="1" Margin="0,4"
GroupName="RpmSafety"
IsChecked="{Binding IsOilAndProceedSelected}"
Content="{DynamicResource Dialog.RpmSafety.OilAndProceed}"/>
<!-- Option 2: Proceed without oil -->
<RadioButton Grid.Row="3" Grid.Column="1" Margin="0,4"
GroupName="RpmSafety"
IsChecked="{Binding IsProceedWithoutOilSelected}"
Content="{DynamicResource Dialog.RpmSafety.ProceedWithout}"/>
<!-- Buttons -->
<StackPanel Grid.Row="5" Grid.Column="1"
Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="{DynamicResource Common.Accept}" Width="80" Height="26" Margin="0,0,8,0"
Command="{Binding AcceptCommand}"/>
<Button Content="{DynamicResource Common.Cancel}" Width="80" Height="26"
Command="{Binding CancelCommand}" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,20 @@
using System.Windows;
using HC_APTBS.ViewModels.Dialogs;
namespace HC_APTBS.Views.Dialogs
{
/// <summary>
/// Safety warning dialog shown when the operator starts the bench motor
/// while the oil pump is OFF. Equivalent to the old <c>WCareOnRpmOn</c>.
/// </summary>
public partial class RpmSafetyWarningDialog : Window
{
/// <summary>Creates the dialog and wires the ViewModel.</summary>
public RpmSafetyWarningDialog(RpmSafetyWarningViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.RequestClose += Close;
}
}
}

View File

@@ -0,0 +1,333 @@
<Window x:Class="HC_APTBS.Views.Dialogs.SettingsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="{DynamicResource Dialog.Settings.Title}"
Height="560" Width="680"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
FontFamily="Ebrima"
Background="#FFEDEDED">
<DockPanel Margin="8">
<!-- ── Bottom button bar ─────────────────────────────────────────── -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="{DynamicResource Common.Accept}" Width="80" Height="26"
Margin="0,0,8,0" Command="{Binding AcceptCommand}" IsDefault="True"/>
<Button Content="{DynamicResource Common.Cancel}" Width="80" Height="26"
Command="{Binding CancelCommand}" IsCancel="True"/>
</StackPanel>
<!-- ── Tab control ───────────────────────────────────────────────── -->
<TabControl>
<!-- ══ General ══════════════════════════════════════════════════ -->
<TabItem Header="{DynamicResource Dialog.Settings.Tab.General}">
<StackPanel Margin="16">
<TextBlock Text="{DynamicResource Dialog.Settings.Language}"
FontWeight="SemiBold" Margin="0,0,0,4"/>
<ComboBox ItemsSource="{Binding AvailableLanguages}"
SelectedItem="{Binding SelectedLanguage}"
Width="120" HorizontalAlignment="Left"/>
<TextBlock Text="{DynamicResource Dialog.Settings.DaysKeepLogs}"
FontWeight="SemiBold" Margin="0,16,0,4"/>
<TextBox Text="{Binding DaysKeepLogs, UpdateSourceTrigger=LostFocus}"
Width="80" HorizontalAlignment="Left"/>
</StackPanel>
</TabItem>
<!-- ══ Safety ═══════════════════════════════════════════════════ -->
<TabItem Header="{DynamicResource Dialog.Settings.Tab.Safety}">
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{DynamicResource Dialog.Settings.TempMax}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding TempMax, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="{DynamicResource Dialog.Settings.TempMin}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TempMin, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="{DynamicResource Dialog.Settings.SecurityRpmLimit}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding SecurityRpmLimit, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="{DynamicResource Dialog.Settings.MaxPressureBar}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding MaxPressureBar, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="{DynamicResource Dialog.Settings.ToleranceUp}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding ToleranceUpExtension, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="{DynamicResource Dialog.Settings.TolerancePfp}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding TolerancePfpExtension, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<CheckBox Grid.Row="6" Grid.Column="0" Grid.ColumnSpan="2"
Content="{DynamicResource Dialog.Settings.IgnoreTin}"
IsChecked="{Binding DefaultIgnoreTin}" Margin="0,4,0,0"/>
</Grid>
</TabItem>
<!-- ══ PID ══════════════════════════════════════════════════════ -->
<TabItem Header="{DynamicResource Dialog.Settings.Tab.Pid}">
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{DynamicResource Dialog.Settings.PidP}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding PidP, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="{DynamicResource Dialog.Settings.PidI}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding PidI, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="{DynamicResource Dialog.Settings.PidD}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PidD, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="{DynamicResource Dialog.Settings.PidLoopMs}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding PidLoopMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
</Grid>
</TabItem>
<!-- ══ Motor ════════════════════════════════════════════════════ -->
<TabItem Header="{DynamicResource Dialog.Settings.Tab.Motor}">
<DockPanel Margin="16">
<!-- Top: motor parameters -->
<Grid DockPanel.Dock="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{DynamicResource Dialog.Settings.EncoderRes}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding EncoderResolution, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="{DynamicResource Dialog.Settings.VoltMaxRpm}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding VoltageForMaxRpm, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="{DynamicResource Dialog.Settings.MaxRpm}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding MaxRpm, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<CheckBox Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2"
Content="{DynamicResource Dialog.Settings.RightRelay}"
IsChecked="{Binding RightRelayValue}" Margin="0,4,0,0"/>
</Grid>
<!-- RPM-Voltage relation table -->
<GroupBox Margin="0,12,0,0">
<GroupBox.Header>
<TextBlock Text="{DynamicResource Dialog.Settings.Relations}" FontWeight="SemiBold"/>
</GroupBox.Header>
<DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,4,0,0">
<Button Content="{DynamicResource Dialog.Settings.AddRow}"
Command="{Binding AddRelationCommand}"
Width="75" Margin="0,0,8,0"/>
<Button Content="{DynamicResource Dialog.Settings.RemoveRow}"
Command="{Binding RemoveRelationCommand}"
CommandParameter="{Binding SelectedItem, ElementName=RelationsGrid}"
Width="75"/>
</StackPanel>
<DataGrid x:Name="RelationsGrid"
ItemsSource="{Binding Relations}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
SelectionMode="Single"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
MinHeight="120">
<DataGrid.Columns>
<DataGridTemplateColumn Width="*">
<DataGridTemplateColumn.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{DynamicResource Dialog.Settings.RelRpm}"/>
</DataTemplate>
</DataGridTemplateColumn.HeaderTemplate>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Rpm}" VerticalAlignment="Center" Margin="4,0"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
<DataGridTemplateColumn.CellEditingTemplate>
<DataTemplate>
<TextBox Text="{Binding Rpm, UpdateSourceTrigger=PropertyChanged}"/>
</DataTemplate>
</DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="*">
<DataGridTemplateColumn.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{DynamicResource Dialog.Settings.RelVoltage}"/>
</DataTemplate>
</DataGridTemplateColumn.HeaderTemplate>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Voltage}" VerticalAlignment="Center" Margin="4,0"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
<DataGridTemplateColumn.CellEditingTemplate>
<DataTemplate>
<TextBox Text="{Binding Voltage, UpdateSourceTrigger=PropertyChanged}"/>
</DataTemplate>
</DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</GroupBox>
</DockPanel>
</TabItem>
<!-- ══ Company ══════════════════════════════════════════════════ -->
<TabItem Header="{DynamicResource Dialog.Settings.Tab.Company}">
<StackPanel Margin="16">
<TextBlock Text="{DynamicResource Dialog.Settings.CompanyName}"
FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBox Text="{Binding CompanyName, UpdateSourceTrigger=LostFocus}"/>
<TextBlock Text="{DynamicResource Dialog.Settings.CompanyInfo}"
FontWeight="SemiBold" Margin="0,12,0,4"/>
<TextBox Text="{Binding CompanyInfo, UpdateSourceTrigger=LostFocus}"
TextWrapping="Wrap" AcceptsReturn="True" Height="80"
VerticalScrollBarVisibility="Auto"/>
<TextBlock Text="{DynamicResource Dialog.Settings.ReportLogo}"
FontWeight="SemiBold" Margin="0,12,0,4"/>
<DockPanel>
<Button DockPanel.Dock="Right" Content="..." Width="30"
Margin="4,0,0,0" Command="{Binding BrowseLogoCommand}"/>
<TextBox Text="{Binding ReportLogoPath, UpdateSourceTrigger=LostFocus}"
IsReadOnly="True" Background="#F0F0F0"/>
</DockPanel>
</StackPanel>
</TabItem>
<!-- ══ K-Line ═══════════════════════════════════════════════════ -->
<TabItem Header="{DynamicResource Dialog.Settings.Tab.KLine}">
<StackPanel Margin="16">
<TextBlock Text="{DynamicResource Dialog.Settings.KLinePort}"
FontWeight="SemiBold" Margin="0,0,0,4"/>
<DockPanel>
<Button DockPanel.Dock="Right"
Content="{DynamicResource Dialog.Settings.RefreshPorts}"
Margin="8,0,0,0" Width="80"
Command="{Binding RefreshPortsCommand}"/>
<ComboBox ItemsSource="{Binding AvailablePorts}"
SelectedItem="{Binding SelectedKLinePort}"
IsEditable="True"
Text="{Binding SelectedKLinePort, UpdateSourceTrigger=LostFocus}"/>
</DockPanel>
<TextBlock Text="{DynamicResource Dialog.Settings.KLineHint}"
FontStyle="Italic" Foreground="Gray" Margin="0,8,0,0"
TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<!-- ══ Advanced ═════════════════════════════════════════════════ -->
<TabItem Header="{DynamicResource Dialog.Settings.Tab.Advanced}">
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{DynamicResource Dialog.Settings.RefreshBench}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding RefreshBenchInterfaceMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="{DynamicResource Dialog.Settings.RefreshReading}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding RefreshWhileReadingMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="{DynamicResource Dialog.Settings.RefreshCanBus}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding RefreshCanBusReadMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="{DynamicResource Dialog.Settings.RefreshPumpReq}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding RefreshPumpRequestMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="{DynamicResource Dialog.Settings.RefreshPumpParams}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding RefreshPumpParamsMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="{DynamicResource Dialog.Settings.BlinkInterval}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding BlinkIntervalMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="{DynamicResource Dialog.Settings.FlasherInterval}"
VerticalAlignment="Center" Margin="0,0,12,6"/>
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding FlasherIntervalMs, UpdateSourceTrigger=LostFocus}"
Margin="0,0,0,6"/>
</Grid>
</TabItem>
</TabControl>
</DockPanel>
</Window>

View File

@@ -0,0 +1,18 @@
using System.Windows;
using HC_APTBS.ViewModels.Dialogs;
namespace HC_APTBS.Views.Dialogs
{
/// <summary>
/// Dialog for editing all application settings.
/// </summary>
public partial class SettingsDialog : Window
{
public SettingsDialog(SettingsViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.RequestClose += Close;
}
}
}

View File

@@ -0,0 +1,138 @@
<Window x:Class="HC_APTBS.Views.Dialogs.UnlockProgressDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
Title="{DynamicResource Dialog.Unlock.Title}"
Height="360" Width="340"
WindowStyle="None" ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Topmost="True"
Background="#FF2B2929"
MouseLeftButtonDown="OnMouseDrag"
Closing="OnWindowClosing">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</Window.Resources>
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="210"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Unlock type label -->
<TextBlock Grid.Row="0"
Text="{Binding UnlockTypeLabel, Mode=OneWay}"
FontSize="14" Foreground="#AAAAAA"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<!-- Progress ring area -->
<Grid Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- Background ring -->
<Ellipse Width="200" Height="200"
Stroke="#4D4D4D" StrokeThickness="10"
Fill="Transparent"/>
<!-- Content inside ring -->
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="{DynamicResource Dialog.Unlock.Progress}"
FontSize="12" Foreground="#888888"
HorizontalAlignment="Center" Margin="0,0,0,4"/>
<!-- Percentage -->
<TextBlock FontSize="60" FontFamily="Courier New"
Foreground="White" HorizontalAlignment="Center">
<TextBlock.Text>
<Binding Path="Progress" Mode="OneWay"
StringFormat="{}{0}%"/>
</TextBlock.Text>
</TextBlock>
<!-- Elapsed time -->
<TextBlock Text="{Binding ElapsedTime, Mode=OneWay}"
FontSize="16" FontFamily="Courier New"
Foreground="#CCCCCC" HorizontalAlignment="Center"
Margin="0,2,0,0"/>
</StackPanel>
</Grid>
<!-- Phase text -->
<TextBlock Grid.Row="2"
Text="{Binding PhaseText, Mode=OneWay}"
FontSize="16" Foreground="White"
HorizontalAlignment="Center" Margin="0,4,0,6"/>
<!-- Progress bar -->
<ProgressBar Grid.Row="3"
Value="{Binding Progress, Mode=OneWay}"
Minimum="0" Maximum="100"
Height="12" Margin="8,0"
Foreground="#00EC00" Background="#3D3D3D"/>
<!-- Result text — overlays the spacer row so it never displaces buttons -->
<TextBlock Grid.Row="4"
Text="{Binding ResultText, Mode=OneWay}"
FontSize="22" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding IsComplete, Converter={StaticResource BoolToVis}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#FF5858"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuccess}" Value="True">
<Setter Property="Foreground" Value="#00EC00"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- Buttons -->
<StackPanel Grid.Row="5" Orientation="Horizontal"
HorizontalAlignment="Center" Margin="0,8,0,0">
<!-- Cancel button -->
<Button Content="{DynamicResource Common.Cancel}" Width="90" Height="30"
Margin="0,0,12,0"
Command="{Binding CancelCommand}"
Foreground="White" FontWeight="Bold">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#FF5858"/>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="#4D4D4D"/>
<Setter Property="Foreground" Value="#888888"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- Close button -->
<Button Content="{DynamicResource Common.Close}" Width="90" Height="30"
Command="{Binding CloseCommand}"
Foreground="White" FontWeight="Bold">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#4D4D4D"/>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Background" Value="#337AB7"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="#888888"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,49 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using HC_APTBS.ViewModels.Dialogs;
namespace HC_APTBS.Views.Dialogs
{
/// <summary>
/// Non-modal window showing immobilizer unlock progress.
/// Prevents user-initiated closing until the unlock sequence completes;
/// programmatic close via <see cref="ForceClose"/> always succeeds.
/// Equivalent to the old <c>WUnlocker</c> window.
/// </summary>
public partial class UnlockProgressDialog : Window
{
private bool _forceClose;
/// <summary>Creates the dialog and wires the ViewModel.</summary>
public UnlockProgressDialog(UnlockProgressViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.RequestClose += ForceClose;
}
/// <summary>Closes the window unconditionally (bypasses the completion guard).</summary>
public void ForceClose()
{
_forceClose = true;
Close();
}
/// <summary>Allows dragging the borderless window by clicking anywhere.</summary>
private void OnMouseDrag(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left)
DragMove();
}
/// <summary>Prevents user-initiated closing while the unlock sequence is still running.</summary>
private void OnWindowClosing(object? sender, CancelEventArgs e)
{
if (_forceClose) return;
if (DataContext is UnlockProgressViewModel vm && !vm.IsComplete)
e.Cancel = true;
}
}
}

View File

@@ -4,7 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d" mc:Ignorable="d"
Title="User Authentication" Title="{DynamicResource Dialog.UserCheck.Title}"
Height="170" Width="420" Height="170" Width="420"
ResizeMode="NoResize" ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
@@ -21,12 +21,12 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Username --> <!-- Username -->
<Label Content="Username:" VerticalAlignment="Center" HorizontalAlignment="Right"/> <Label Content="{DynamicResource Dialog.UserCheck.Username}" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBox Grid.Column="1" Margin="8,4" Height="26" VerticalContentAlignment="Center" <TextBox Grid.Column="1" Margin="8,4" Height="26" VerticalContentAlignment="Center"
Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"/> Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"/>
<!-- Password --> <!-- Password -->
<Label Grid.Row="1" Content="Password:" VerticalAlignment="Center" HorizontalAlignment="Right"/> <Label Grid.Row="1" Content="{DynamicResource Dialog.UserCheck.Password}" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<PasswordBox x:Name="PBPassword" Grid.Row="1" Grid.Column="1" <PasswordBox x:Name="PBPassword" Grid.Row="1" Grid.Column="1"
Margin="8,4" Height="26" VerticalContentAlignment="Center" Margin="8,4" Height="26" VerticalContentAlignment="Center"
PasswordChanged="OnPasswordChanged"/> PasswordChanged="OnPasswordChanged"/>
@@ -34,9 +34,9 @@
<!-- Buttons --> <!-- Buttons -->
<StackPanel Grid.Row="2" Grid.Column="1" <StackPanel Grid.Row="2" Grid.Column="1"
Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Accept" Width="80" Height="26" Margin="0,0,8,0" <Button Content="{DynamicResource Common.Accept}" Width="80" Height="26" Margin="0,0,8,0"
Command="{Binding AcceptCommand}" IsDefault="True"/> Command="{Binding AcceptCommand}" IsDefault="True"/>
<Button Content="Cancel" Width="80" Height="26" <Button Content="{DynamicResource Common.Cancel}" Width="80" Height="26"
Command="{Binding CancelCommand}" IsCancel="True"/> Command="{Binding CancelCommand}" IsCancel="True"/>
</StackPanel> </StackPanel>
</Grid> </Grid>

View File

@@ -0,0 +1,59 @@
<Window x:Class="HC_APTBS.Views.Dialogs.VoltageWarningDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
Title="{DynamicResource Dialog.Voltage.Title}"
Height="200" Width="420"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner">
<Grid Margin="16,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="48"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<!-- Voltage icon -->
<TextBlock Grid.RowSpan="4" Text="⚡"
FontSize="36" Foreground="#D4A017"
VerticalAlignment="Top" HorizontalAlignment="Center"
Margin="0,4,8,0"/>
<!-- Title with voltage -->
<TextBlock Grid.Column="1" FontSize="18" FontWeight="Bold" Foreground="DarkOrange"
Margin="0,0,0,8">
<Run Text="VOLTAGE: "/>
<Run Text="{Binding Voltage, Mode=OneWay}"/>
</TextBlock>
<!-- Info message -->
<TextBlock Grid.Row="1" Grid.Column="1" TextWrapping="Wrap" Margin="0,0,0,4">
<Run Text="The selected pump requires "/>
<Run Text="{Binding Voltage, Mode=OneWay}" FontWeight="Bold"/>
<Run Text=" power supply."/>
</TextBlock>
<!-- Action text -->
<TextBlock Grid.Row="2" Grid.Column="1" TextWrapping="Wrap"
FontSize="14" FontWeight="Bold" Foreground="DarkRed"
Margin="0,4,0,0">
<Run Text="SWITCH THE POWER SUPPLY TO "/>
<Run Text="{Binding Voltage, Mode=OneWay}"/>
</TextBlock>
<!-- Acknowledge button -->
<Button Grid.Row="4" Grid.Column="1"
Content="{DynamicResource Common.Ok}" Width="80" Height="26"
HorizontalAlignment="Right" Margin="0,12,0,0"
Command="{Binding AcknowledgeCommand}" IsDefault="True"/>
</Grid>
</Window>

View File

@@ -0,0 +1,20 @@
using System.Windows;
using HC_APTBS.ViewModels.Dialogs;
namespace HC_APTBS.Views.Dialogs
{
/// <summary>
/// Informational dialog warning the operator to switch the power supply
/// voltage before testing a pump. Equivalent to the old <c>WAlert27v</c>.
/// </summary>
public partial class VoltageWarningDialog : Window
{
/// <summary>Creates the dialog and wires the ViewModel.</summary>
public VoltageWarningDialog(VoltageWarningViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.RequestClose += Close;
}
}
}

View File

@@ -19,7 +19,7 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Header --> <!-- Header -->
<TextBlock Grid.ColumnSpan="3" Text="ADVANCE MONITORING" <TextBlock Grid.ColumnSpan="3" Text="{DynamicResource Angle.Header}"
HorizontalAlignment="Center" FontSize="20" HorizontalAlignment="Center" FontSize="20"
FontWeight="Bold" FontStyle="Italic" FontWeight="Bold" FontStyle="Italic"
Foreground="DimGray" Margin="0,0,0,4"/> Foreground="DimGray" Margin="0,0,0,4"/>
@@ -32,10 +32,10 @@
<Button Content="0" Width="28" Height="28" FontWeight="Bold" <Button Content="0" Width="28" Height="28" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,4,0" VerticalAlignment="Center" Margin="0,0,4,0"
Command="{Binding SetPsgZeroCommand}" Command="{Binding SetPsgZeroCommand}"
ToolTip="Set PSG zero reference"/> ToolTip="{DynamicResource Angle.SetPsgZero}"/>
<Border Style="{DynamicResource LcdBlue}" Padding="4,0" Width="170"> <Border Style="{DynamicResource LcdBlue}" Padding="4,0" Width="170">
<DockPanel> <DockPanel>
<TextBlock Text="PSG:" DockPanel.Dock="Left" <TextBlock Text="{DynamicResource Angle.Psg}" DockPanel.Dock="Left"
VerticalAlignment="Bottom" Margin="2,0,0,8" VerticalAlignment="Bottom" Margin="2,0,0,8"
FontSize="10" FontWeight="Bold" FontSize="10" FontWeight="Bold"
Foreground="{Binding PsgAngleForeground}"/> Foreground="{Binding PsgAngleForeground}"/>
@@ -56,10 +56,10 @@
<Button Content="0" Width="28" Height="28" FontWeight="Bold" <Button Content="0" Width="28" Height="28" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,4,0" VerticalAlignment="Center" Margin="0,0,4,0"
Command="{Binding SetInjZeroCommand}" Command="{Binding SetInjZeroCommand}"
ToolTip="Set INJ zero reference"/> ToolTip="{DynamicResource Angle.SetInjZero}"/>
<Border Style="{DynamicResource LcdBlue}" Padding="4,0" Width="170"> <Border Style="{DynamicResource LcdBlue}" Padding="4,0" Width="170">
<DockPanel> <DockPanel>
<TextBlock Text="INJ:" DockPanel.Dock="Left" <TextBlock Text="{DynamicResource Angle.Inj}" DockPanel.Dock="Left"
VerticalAlignment="Bottom" Margin="2,0,0,8" VerticalAlignment="Bottom" Margin="2,0,0,8"
FontSize="10" FontWeight="Bold" FontSize="10" FontWeight="Bold"
Foreground="{Binding InjAngleForeground}"/> Foreground="{Binding InjAngleForeground}"/>
@@ -105,7 +105,7 @@
<!-- ABS (manual encoder) --> <!-- ABS (manual encoder) -->
<Border Style="{DynamicResource LcdBlue}" Height="56" Margin="0,2" Padding="4,0"> <Border Style="{DynamicResource LcdBlue}" Height="56" Margin="0,2" Padding="4,0">
<DockPanel> <DockPanel>
<TextBlock Text="ABS º:" DockPanel.Dock="Left" <TextBlock Text="{DynamicResource Angle.AbsDeg}" DockPanel.Dock="Left"
VerticalAlignment="Bottom" Margin="4,0,0,8" VerticalAlignment="Bottom" Margin="4,0,0,8"
FontSize="10" FontWeight="Bold" Foreground="#FFEBEBFF"/> FontSize="10" FontWeight="Bold" Foreground="#FFEBEBFF"/>
<TextBlock Text="{Binding ManualAngleText}" <TextBlock Text="{Binding ManualAngleText}"
@@ -118,7 +118,7 @@
<!-- LOCK angle result --> <!-- LOCK angle result -->
<Border Style="{DynamicResource LcdBlue}" Height="56" Margin="0,2" Padding="4,0"> <Border Style="{DynamicResource LcdBlue}" Height="56" Margin="0,2" Padding="4,0">
<DockPanel> <DockPanel>
<TextBlock Text="LOCK º:" DockPanel.Dock="Left" <TextBlock Text="{DynamicResource Angle.LockDeg}" DockPanel.Dock="Left"
VerticalAlignment="Bottom" Margin="4,0,0,8" VerticalAlignment="Bottom" Margin="4,0,0,8"
FontSize="10" FontWeight="Bold" Foreground="#FFEBEBFF"/> FontSize="10" FontWeight="Bold" Foreground="#FFEBEBFF"/>
<TextBlock Text="{Binding LockAngleDisplay}" <TextBlock Text="{Binding LockAngleDisplay}"

View File

@@ -20,27 +20,27 @@
<!-- CAN frame fields --> <!-- CAN frame fields -->
<WrapPanel Margin="0,0,0,4"> <WrapPanel Margin="0,0,0,4">
<Label Content="CAN-Bus ID (0x)" VerticalAlignment="Bottom" Foreground="Black"/> <Label Content="{DynamicResource BenchParam.CanBusId}" VerticalAlignment="Bottom" Foreground="Black"/>
<TextBox Text="{Binding MessageIdHex, UpdateSourceTrigger=LostFocus}" <TextBox Text="{Binding MessageIdHex, UpdateSourceTrigger=LostFocus}"
Width="40" VerticalAlignment="Center" Width="40" VerticalAlignment="Center"
Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/> Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/>
<Label Content="Byte L" VerticalAlignment="Bottom" Foreground="Black" Margin="8,0,0,0"/> <Label Content="{DynamicResource BenchParam.ByteL}" VerticalAlignment="Bottom" Foreground="Black" Margin="8,0,0,0"/>
<TextBox Text="{Binding ByteL, UpdateSourceTrigger=LostFocus}" <TextBox Text="{Binding ByteL, UpdateSourceTrigger=LostFocus}"
Width="35" VerticalAlignment="Center" Width="35" VerticalAlignment="Center"
Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/> Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/>
<Label Content="Byte H" VerticalAlignment="Bottom" Foreground="Black" Margin="8,0,0,0"/> <Label Content="{DynamicResource BenchParam.ByteH}" VerticalAlignment="Bottom" Foreground="Black" Margin="8,0,0,0"/>
<TextBox Text="{Binding ByteH, UpdateSourceTrigger=LostFocus}" <TextBox Text="{Binding ByteH, UpdateSourceTrigger=LostFocus}"
Width="35" VerticalAlignment="Center" Width="35" VerticalAlignment="Center"
Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/> Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/>
<Label Content="Filter α" VerticalAlignment="Bottom" Foreground="Black" Margin="8,0,0,0"/> <Label Content="{DynamicResource BenchParam.FilterAlpha}" VerticalAlignment="Bottom" Foreground="Black" Margin="8,0,0,0"/>
<TextBox Text="{Binding Alpha, UpdateSourceTrigger=LostFocus}" <TextBox Text="{Binding Alpha, UpdateSourceTrigger=LostFocus}"
Width="40" VerticalAlignment="Center" Width="40" VerticalAlignment="Center"
Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/> Background="#66FFFFFF" BorderBrush="{x:Null}" Height="19"/>
<CheckBox Content="Enable formula" IsChecked="{Binding FormulaEnabled}" <CheckBox Content="{DynamicResource BenchParam.EnableFormula}" IsChecked="{Binding FormulaEnabled}"
VerticalAlignment="Center" Foreground="Black" Margin="12,0,0,0"/> VerticalAlignment="Center" Foreground="Black" Margin="12,0,0,0"/>
</WrapPanel> </WrapPanel>

View File

@@ -4,7 +4,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignHeight="150" d:DesignWidth="800" MaxHeight="150"> d:DesignHeight="150" d:DesignWidth="460" MaxHeight="150">
<UserControl.Resources> <UserControl.Resources>
<Style x:Key="LcdGreen" TargetType="Border"> <Style x:Key="LcdGreen" TargetType="Border">
@@ -29,18 +29,18 @@
</Style> </Style>
</UserControl.Resources> </UserControl.Resources>
<Grid Margin="50,0"> <Grid Margin="16,8">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="75"/> <RowDefinition Height="75"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/> <ColumnDefinition Width="144" MinWidth="60"/>
<ColumnDefinition Width="450"/> <ColumnDefinition MinWidth="200"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- LEFT: version picker + read/write buttons --> <!-- LEFT: version picker + read/write buttons -->
<ComboBox Margin="5" VerticalAlignment="Bottom" <ComboBox Margin="4" VerticalAlignment="Bottom"
SelectedIndex="{Binding VersionIndex}"> SelectedIndex="{Binding VersionIndex}">
<ComboBoxItem Content="V1"/> <ComboBoxItem Content="V1"/>
<ComboBoxItem Content="V2"/> <ComboBoxItem Content="V2"/>
@@ -48,40 +48,51 @@
<ComboBoxItem Content="V4"/> <ComboBoxItem Content="V4"/>
</ComboBox> </ComboBox>
<Grid Grid.Row="1"> <Grid Grid.Row="1" Margin="4">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition/> <ColumnDefinition/>
<ColumnDefinition Width="4"/>
<ColumnDefinition/> <ColumnDefinition/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button Content="READ" Margin="2" <Button Content="{DynamicResource Dfi.Read}"
Command="{Binding ReadDfiCommand}" Command="{Binding ReadDfiCommand}"
FontSize="12" FontWeight="Bold" Padding="4"/> FontSize="12" FontWeight="Bold" Padding="4"/>
<Button Grid.Column="1" Content="WRITE" Margin="2" <Button Grid.Column="2" Content="{DynamicResource Dfi.Write}"
Command="{Binding WriteDfiCommand}" Command="{Binding WriteDfiCommand}"
FontSize="12" FontWeight="Bold" Padding="4"/> FontSize="12" FontWeight="Bold" Padding="4"/>
</Grid> </Grid>
<!-- TOP RIGHT: DFI value LCD + auto checkbox --> <!-- TOP RIGHT: DFI value LCD + auto checkbox -->
<Grid Grid.Row="0" Grid.Column="1" Margin="150,5"> <Grid Grid.Row="0" Grid.Column="1" Margin="4">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition/> <ColumnDefinition Width="Auto" MinWidth="200"/>
<ColumnDefinition/> <ColumnDefinition MinWidth="100"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Border Grid.ColumnSpan="2" Style="{StaticResource LcdGreen}"/> <Border Grid.ColumnSpan="1" Style="{StaticResource LcdGreen}" Margin="0,0,12,0" >
<Border Grid.ColumnSpan="2" BorderThickness="1" BorderBrush="Black" SnapsToDevicePixels="True"/> <Grid Grid.Row="1" >
<TextBlock Text="DFI:" <Grid.ColumnDefinitions>
HorizontalAlignment="Center" VerticalAlignment="Bottom" <ColumnDefinition/>
Foreground="Black" FontSize="18" FontFamily="Consolas"/> <ColumnDefinition/>
<TextBlock Text="{Binding CurrentDfi, StringFormat=F2, Mode=OneWay}" </Grid.ColumnDefinitions>
Grid.Column="1" <TextBlock Text="{DynamicResource Dfi.Label}"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="26" FontWeight="Bold" Foreground="Black"/> Foreground="Black" FontSize="18" FontFamily="Consolas"/>
<TextBlock Text="{Binding CurrentDfi, StringFormat=F2, Mode=OneWay}"
Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="26" FontWeight="Bold" Foreground="Black"/>
</Grid>
</Border>
<Border Grid.ColumnSpan="1" BorderThickness="1" BorderBrush="Black" Margin="0,0,12,0" SnapsToDevicePixels="True"/>
</Grid> </Grid>
<!-- AUTO checkbox — sits outside the column pair; placed in Column=1 outside normal layout --> <!-- AUTO checkbox — sits outside the column pair; placed in Column=1 outside normal layout -->
<CheckBox IsChecked="{Binding IsAutoMode}" <CheckBox IsChecked="{Binding IsAutoMode}"
Content="AUTO" Content="{DynamicResource Dfi.Auto}"
Grid.Row="0" Grid.Column="1" Grid.Row="0" Grid.Column="1"
Height="Auto"
HorizontalAlignment="Right" VerticalAlignment="Center" HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,10,0" Margin="0,0,10,0"
Foreground="Black" FontSize="20"/> Foreground="Black" FontSize="20"/>

View File

@@ -36,7 +36,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="FBKW - Advance Control" HorizontalAlignment="Center" <TextBlock Text="{DynamicResource PumpCtrl.Fbkw}" HorizontalAlignment="Center"
FontSize="13" Foreground="Black" Margin="0,0,0,2"/> FontSize="13" Foreground="Black" Margin="0,0,0,2"/>
<DockPanel Grid.Row="1" Margin="4,0,4,2"> <DockPanel Grid.Row="1" Margin="4,0,4,2">
@@ -45,7 +45,7 @@
Width="28" Height="28" Margin="0,0,4,0" Width="28" Height="28" Margin="0,0,4,0"
Background="Transparent" BorderBrush="Transparent" Background="Transparent" BorderBrush="Transparent"
Content="..." FontWeight="Bold" FontSize="14" Content="..." FontWeight="Bold" FontSize="14"
ToolTip="Min / Step / Max"/> ToolTip="{DynamicResource PumpCtrl.MinStepMax}"/>
<!-- Numeric text box --> <!-- Numeric text box -->
<TextBox DockPanel.Dock="Right" Width="50" Height="28" Margin="4,0,0,0" <TextBox DockPanel.Dock="Right" Width="50" Height="28" Margin="4,0,0,0"
TextAlignment="Center" VerticalContentAlignment="Center" TextAlignment="Center" VerticalContentAlignment="Center"
@@ -68,17 +68,17 @@
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding FbkwMin, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding FbkwMin, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Min" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Min}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding FbkwStep, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding FbkwStep, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Step" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Step}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding FbkwMax, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding FbkwMax, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Max" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Max}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
</UniformGrid> </UniformGrid>
</Border> </Border>
@@ -92,7 +92,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="ME - Quantity Control" HorizontalAlignment="Center" <TextBlock Text="{DynamicResource PumpCtrl.Me}" HorizontalAlignment="Center"
FontSize="13" Foreground="Black" Margin="0,0,0,2"/> FontSize="13" Foreground="Black" Margin="0,0,0,2"/>
<DockPanel Grid.Row="1" Margin="4,0,4,2"> <DockPanel Grid.Row="1" Margin="4,0,4,2">
@@ -100,7 +100,7 @@
Width="28" Height="28" Margin="0,0,4,0" Width="28" Height="28" Margin="0,0,4,0"
Background="Transparent" BorderBrush="Transparent" Background="Transparent" BorderBrush="Transparent"
Content="..." FontWeight="Bold" FontSize="14" Content="..." FontWeight="Bold" FontSize="14"
ToolTip="Min / Step / Max"/> ToolTip="{DynamicResource PumpCtrl.MinStepMax}"/>
<TextBox DockPanel.Dock="Right" Width="50" Height="28" Margin="4,0,0,0" <TextBox DockPanel.Dock="Right" Width="50" Height="28" Margin="4,0,0,0"
TextAlignment="Center" VerticalContentAlignment="Center" TextAlignment="Center" VerticalContentAlignment="Center"
FontWeight="Bold" FontSize="13" FontWeight="Bold" FontSize="13"
@@ -120,17 +120,17 @@
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding MeMin, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding MeMin, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Min" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Min}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding MeStep, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding MeStep, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Step" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Step}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding MeMax, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding MeMax, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Max" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Max}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
</UniformGrid> </UniformGrid>
</Border> </Border>
@@ -145,7 +145,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Text="ME - Pre-inj Quantity" HorizontalAlignment="Center" <TextBlock Text="{DynamicResource PumpCtrl.PreInj}" HorizontalAlignment="Center"
FontSize="13" Foreground="Black" Margin="0,0,0,2"/> FontSize="13" Foreground="Black" Margin="0,0,0,2"/>
<DockPanel Grid.Row="1" Margin="4,0,4,2"> <DockPanel Grid.Row="1" Margin="4,0,4,2">
@@ -153,7 +153,7 @@
Width="28" Height="28" Margin="0,0,4,0" Width="28" Height="28" Margin="0,0,4,0"
Background="Transparent" BorderBrush="Transparent" Background="Transparent" BorderBrush="Transparent"
Content="..." FontWeight="Bold" FontSize="14" Content="..." FontWeight="Bold" FontSize="14"
ToolTip="Min / Step / Max"/> ToolTip="{DynamicResource PumpCtrl.MinStepMax}"/>
<TextBox DockPanel.Dock="Right" Width="50" Height="28" Margin="4,0,0,0" <TextBox DockPanel.Dock="Right" Width="50" Height="28" Margin="4,0,0,0"
TextAlignment="Center" VerticalContentAlignment="Center" TextAlignment="Center" VerticalContentAlignment="Center"
FontWeight="Bold" FontSize="13" FontWeight="Bold" FontSize="13"
@@ -173,17 +173,17 @@
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding PreInMin, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding PreInMin, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Min" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Min}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding PreInStep, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding PreInStep, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Step" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Step}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
<StackPanel HorizontalAlignment="Center"> <StackPanel HorizontalAlignment="Center">
<TextBox Text="{Binding PreInMax, UpdateSourceTrigger=PropertyChanged}" <TextBox Text="{Binding PreInMax, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource SettingsTextBox}"/> Style="{StaticResource SettingsTextBox}"/>
<TextBlock Text="Max" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/> <TextBlock Text="{DynamicResource PumpCtrl.Max}" Style="{StaticResource SettingsLabel}" HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
</UniformGrid> </UniformGrid>
</Border> </Border>

View File

@@ -15,7 +15,7 @@
<ColumnDefinition/> <ColumnDefinition/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Pump:" VerticalAlignment="Center" Margin="0,0,8,0" FontSize="14"/> <TextBlock Text="{DynamicResource PumpId.Label}" VerticalAlignment="Center" Margin="0,0,8,0" FontSize="14"/>
<ComboBox Grid.Column="1" <ComboBox Grid.Column="1"
ItemsSource="{Binding PumpIds}" ItemsSource="{Binding PumpIds}"
SelectedItem="{Binding SelectedPumpId}" SelectedItem="{Binding SelectedPumpId}"
@@ -36,43 +36,43 @@
<StackPanel> <StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="DFI:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.Dfi}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineDfi}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlineDfi}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Pump ID:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.PumpId}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlinePumpId}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlinePumpId}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Serial No:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.SerialNo}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineSerialNumber}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlineSerialNumber}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Model Ref:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.ModelRef}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineModelRef}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlineModelRef}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Data Record:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.DataRecord}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineDataRecord}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlineDataRecord}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Pump Ctrl:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.PumpCtrl}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlinePumpControl}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlinePumpControl}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Model Index:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.ModelIndex}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineModelIndex}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlineModelIndex}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="SW Ver 1:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.SwVer1}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineSwVersion1}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlineSwVersion1}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="SW Ver 2:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.SwVer2}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineSwVersion2}" FontSize="12" FontFamily="Consolas"/> <TextBlock Text="{Binding KlineSwVersion2}" FontSize="12" FontFamily="Consolas"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Text="Errors:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.Errors}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineErrors}" FontSize="12" FontFamily="Consolas" Foreground="DarkRed"/> <TextBlock Text="{Binding KlineErrors}" FontSize="12" FontFamily="Consolas" Foreground="DarkRed"/>
</StackPanel> </StackPanel>
<!-- ConnectError row: auto-collapses when empty via DataTrigger --> <!-- ConnectError row: auto-collapses when empty via DataTrigger -->
@@ -86,15 +86,15 @@
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</StackPanel.Style> </StackPanel.Style>
<TextBlock Text="Error:" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/> <TextBlock Text="{DynamicResource PumpId.Error}" FontSize="12" Margin="0,0,4,0" Foreground="Gray" Width="90"/>
<TextBlock Text="{Binding KlineConnectError}" FontSize="12" FontFamily="Consolas" Foreground="Red"/> <TextBlock Text="{Binding KlineConnectError}" FontSize="12" FontFamily="Consolas" Foreground="Red"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0"> <StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0">
<Button Content="Read K-Line" Width="90" Margin="0,2" <Button Content="{DynamicResource PumpId.ReadKLine}" Width="90" Margin="0,2"
Command="{Binding ReadKlineCommand}"/> Command="{Binding ReadKlineCommand}"/>
<Button Content="Disconnect" Width="90" Margin="0,2" <Button Content="{DynamicResource PumpId.Disconnect}" Width="90" Margin="0,2"
Command="{Binding DisconnectKLineCommand}"/> Command="{Binding DisconnectKLineCommand}"/>
<!-- Progress bar shown during K-Line read --> <!-- Progress bar shown during K-Line read -->
<ProgressBar Value="{Binding ProgressPercent, Mode=OneWay}" <ProgressBar Value="{Binding ProgressPercent, Mode=OneWay}"

View File

@@ -72,12 +72,12 @@
<ColumnDefinition Width="1*"/> <ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/> <ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Phase" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray"/> <TextBlock Text="{DynamicResource Result.Phase}" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray"/>
<TextBlock Text="Parameter" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="1"/> <TextBlock Text="{DynamicResource Result.Parameter}" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="1"/>
<TextBlock Text="Target" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="2"/> <TextBlock Text="{DynamicResource Result.Target}" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="2"/>
<TextBlock Text="Tol ±" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="3"/> <TextBlock Text="{DynamicResource Result.Tolerance}" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="3"/>
<TextBlock Text="Average" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="4"/> <TextBlock Text="{DynamicResource Result.Average}" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="4"/>
<TextBlock Text="Result" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="5"/> <TextBlock Text="{DynamicResource Result.ResultHeader}" FontSize="10" FontWeight="Bold" Margin="3,0" Foreground="Gray" Grid.Column="5"/>
</Grid> </Grid>
<!-- Result rows --> <!-- Result rows -->

View File

@@ -168,7 +168,7 @@
Margin="16,0,0,0"/> Margin="16,0,0,0"/>
<!-- Critical indicator --> <!-- Critical indicator -->
<TextBlock Text="Critical" FontSize="9" <TextBlock Text="{DynamicResource Test.Critical}" FontSize="9"
Foreground="#E65100" FontWeight="Bold" Foreground="#E65100" FontWeight="Bold"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding IsCritical, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding IsCritical, Converter={StaticResource BoolToVis}}"/>
@@ -178,13 +178,13 @@
Margin="0,3,0,0"> Margin="0,3,0,0">
<!-- Ready values --> <!-- Ready values -->
<StackPanel Visibility="{Binding ReadyValues.Count, FallbackValue=Collapsed}"> <StackPanel Visibility="{Binding ReadyValues.Count, FallbackValue=Collapsed}">
<TextBlock Text="Required:" FontSize="9" Foreground="#666" <TextBlock Text="{DynamicResource Test.Required}" FontSize="9" Foreground="#666"
FontWeight="SemiBold" Margin="0,1,0,0"/> FontWeight="SemiBold" Margin="0,1,0,0"/>
<ItemsControl ItemsSource="{Binding ReadyValues}"/> <ItemsControl ItemsSource="{Binding ReadyValues}"/>
</StackPanel> </StackPanel>
<!-- Send values --> <!-- Send values -->
<TextBlock Text="Test:" FontSize="9" Foreground="#666" <TextBlock Text="{DynamicResource Test.TestLabel}" FontSize="9" Foreground="#666"
FontWeight="SemiBold" Margin="0,2,0,0"/> FontWeight="SemiBold" Margin="0,2,0,0"/>
<ItemsControl ItemsSource="{Binding OperationValues}"/> <ItemsControl ItemsSource="{Binding OperationValues}"/>
</StackPanel> </StackPanel>
@@ -265,17 +265,17 @@
<StackPanel Grid.Column="2" Orientation="Horizontal" <StackPanel Grid.Column="2" Orientation="Horizontal"
VerticalAlignment="Center" Margin="16,0,0,0"> VerticalAlignment="Center" Margin="16,0,0,0">
<TextBlock FontSize="10" Foreground="DimGray"> <TextBlock FontSize="10" Foreground="DimGray">
<Run Text="Cond: "/> <Run Text="{DynamicResource Test.Condition}"/>
<Run Text="{Binding ConditioningTimeSec, Mode=OneWay}"/> <Run Text="{Binding ConditioningTimeSec, Mode=OneWay}"/>
<Run Text="s"/> <Run Text="s"/>
</TextBlock> </TextBlock>
<TextBlock FontSize="10" Foreground="DimGray" Margin="10,0,0,0"> <TextBlock FontSize="10" Foreground="DimGray" Margin="10,0,0,0">
<Run Text="Meas: "/> <Run Text="{DynamicResource Test.Measurement}"/>
<Run Text="{Binding MeasurementTimeSec, Mode=OneWay}"/> <Run Text="{Binding MeasurementTimeSec, Mode=OneWay}"/>
<Run Text="s"/> <Run Text="s"/>
</TextBlock> </TextBlock>
<TextBlock FontSize="10" Foreground="DimGray" Margin="10,0,0,0"> <TextBlock FontSize="10" Foreground="DimGray" Margin="10,0,0,0">
<Run Text="M/s: "/> <Run Text="{DynamicResource Test.MeasPerSec}"/>
<Run Text="{Binding MeasurementsPerSecond, StringFormat=F1, Mode=OneWay}"/> <Run Text="{Binding MeasurementsPerSecond, StringFormat=F1, Mode=OneWay}"/>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
@@ -323,17 +323,17 @@
<ColumnDefinition/> <ColumnDefinition/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button Content="&#x25B6; START TEST" FontSize="15" FontWeight="Bold" <Button Content="{DynamicResource Test.StartTest}" FontSize="15" FontWeight="Bold"
Height="44" Margin="0,0,4,0" Height="44" Margin="0,0,4,0"
Command="{Binding DataContext.StartTestCommand, Command="{Binding DataContext.StartTestCommand,
RelativeSource={RelativeSource AncestorType=Window}}" RelativeSource={RelativeSource AncestorType=Window}}"
Foreground="DarkGreen"/> Foreground="DarkGreen"/>
<Button Grid.Column="1" Content="&#x25A0; STOP" FontSize="15" FontWeight="Bold" <Button Grid.Column="1" Content="{DynamicResource Test.Stop}" FontSize="15" FontWeight="Bold"
Height="44" Margin="4,0" Height="44" Margin="4,0"
Command="{Binding DataContext.StopTestCommand, Command="{Binding DataContext.StopTestCommand,
RelativeSource={RelativeSource AncestorType=Window}}" RelativeSource={RelativeSource AncestorType=Window}}"
Foreground="DarkRed"/> Foreground="DarkRed"/>
<Button Grid.Column="2" Content="&#x1F4C4; Report" FontSize="13" <Button Grid.Column="2" Content="{DynamicResource Test.Report}" FontSize="13"
Height="44" Margin="4,0,0,0" Height="44" Margin="4,0,0,0"
Command="{Binding DataContext.GenerateReportCommand, Command="{Binding DataContext.GenerateReportCommand,
RelativeSource={RelativeSource AncestorType=Window}}"/> RelativeSource={RelativeSource AncestorType=Window}}"/>
@@ -353,14 +353,14 @@
<!-- Show values toggle --> <!-- Show values toggle -->
<CheckBox IsChecked="{Binding ShowOperationValues}" <CheckBox IsChecked="{Binding ShowOperationValues}"
VerticalAlignment="Center"> VerticalAlignment="Center">
<TextBlock Text="Show values" FontSize="12"/> <TextBlock Text="{DynamicResource Test.ShowValues}" FontSize="12"/>
</CheckBox> </CheckBox>
<!-- Check all button --> <!-- Check all button -->
<Button Grid.Column="1" Margin="12,0,0,0" <Button Grid.Column="1" Margin="12,0,0,0"
Command="{Binding ToggleCheckAllCommand}" Command="{Binding ToggleCheckAllCommand}"
Padding="6,2" ToolTip="Enable/disable all phases"> Padding="6,2" ToolTip="Enable/disable all phases">
<TextBlock Text="Check All" FontSize="11"/> <TextBlock Text="{DynamicResource Test.CheckAll}" FontSize="11"/>
</Button> </Button>
<!-- Remaining time --> <!-- Remaining time -->
@@ -368,7 +368,7 @@
Foreground="DimGray" FontSize="12" Margin="0,0,4,0"> Foreground="DimGray" FontSize="12" Margin="0,0,4,0">
<Run Text="~"/> <Run Text="~"/>
<Run Text="{Binding RemainingSeconds, Mode=OneWay}"/> <Run Text="{Binding RemainingSeconds, Mode=OneWay}"/>
<Run Text="s remaining"/> <Run Text="{DynamicResource Test.SecondsRemaining}"/>
</TextBlock> </TextBlock>
</Grid> </Grid>
</Border> </Border>

View File

@@ -1,50 +1,37 @@
# Gap: Ford Unlock Progress UI # Gap: Ford Unlock Progress UI — RESOLVED
## Problem ## Status: Implemented
The `UnlockService` backend is fully functional (Phase 1 + Phase 2 + verification), but there is no dedicated UI for displaying unlock progress. The old app had `WUnlocker.xaml` — a modal dialog with a visual progress ring and status text.
## Current State The unlock progress dialog, service refactoring, and K-Line fast unlock are fully implemented.
- `UnlockService.StatusChanged` fires every 1000ms with `"Unlocking... {pct}% ({MM:SS})"`
- `UnlockService.UnlockCompleted` fires once with `true`/`false`
- `MainViewModel` subscribes and pipes status into `VerboseStatus` (displayed as plain text somewhere in MainWindow)
- No progress bar, no percentage display, no cancel button, no dedicated dialog
## Old UI Reference (`WUnlocker.xaml`) ## What was done
- Standalone modal `Window` (300x400px), dark background (#FF2B2929), Topmost, centered on owner
- Decorative `Ellipse` ring (200x200, #4D4D4D stroke, 10px thick) as the focal point
- Inside the ring: large percentage (Courier New 60pt), "P R O G R E S S" label, elapsed time (MM:SS)
- `LBLState` at top: live lock/immo status from CAN feedback ("Bloqueada/Desbloqueada")
- `LBLVerbose` at bottom: phase description ("Unlocking...", "Testing...", "Sending")
- "Cerrar" (Close) button disabled until progress reaches 100%
- Window close prevented via `OnWindowClosing` until completion
## Spec for New Implementation ### 1. UnlockProgressDialog (View + ViewModel)
- **`Views/Dialogs/UnlockProgressDialog.xaml`** — Dark-themed non-modal window (#2B2929), borderless with drag support, 200x200 ellipse progress ring, Courier New 60pt percentage, MM:SS elapsed time, phase indicator, linear progress bar, result text (green/red), Cancel + Close buttons
- **`Views/Dialogs/UnlockProgressDialog.xaml.cs`** — `ForceClose()` for programmatic close, `OnWindowClosing` prevents user close until `IsComplete`, `OnMouseDrag` for window dragging
- **`ViewModels/Dialogs/UnlockProgressViewModel.cs`** — `ObservableObject` + `IDisposable`, parses `StatusChanged` events via regex, marshals to UI thread, `CancelCommand` (Phase 1 only) / `CloseCommand` (after completion), `[NotifyCanExecuteChangedFor]` wiring
### UnlockDialog.xaml (View) ### 2. UnlockService rewrite (`Services/Impl/UnlockService.cs`)
- Modal dialog (MVVM, no code-behind logic) - **Persistent CAN senders** — Start before unlock, run indefinitely until `StopSenders()` is called on pump deselection. Prevents pump from re-locking after unlock.
- Progress bar (0-100%) + percentage text - **Concurrent fast unlock** — While the 600s CAN wait runs with progress reporting, a parallel task awaits K-Line session Connected state, then:
- Elapsed time display (MM:SS) 1. Checks if pump is already unlocked (via `VerifyUnlock`)
- Phase indicator: "Phase 1: Sending unlock signals" / "Phase 2: Testing" / "Verifying..." 2. Sends K-Line fast unlock command (`{0x02, 0x88, 0x02, 0x03, 0xA8, 0x01, 0x00}`) which writes to pump RAM to fast-forward the internal 10 min timer
- Current unlock type indicator (Type 1 / Type 2) 3. Waits 2s, then re-checks `VerifyUnlock`
- Cancel button (disabled during Phase 2 — it cannot be cancelled once started) 4. If verified → cancels remaining wait, proceeds to Phase 2 immediately
- Close button (enabled only after completion) - **`IUnlockService.StopSenders()`** — New interface method, called from `MainViewModel.CloseUnlockDialog()` on pump change
- Result indicator: green checkmark (success) / red X (failed)
### UnlockViewModel.cs (ViewModel) ### 3. K-Line fast unlock support
- `[ObservableProperty] double Progress` - **`IKwpService.TryFastUnlockAsync()`** — New interface method
- `[ObservableProperty] string ElapsedTime` - **`KwpService.TryFastUnlockAsync()`** — Sends custom command over active session, returns true if no NAK (command accepted, not unlock confirmation)
- `[ObservableProperty] string Phase`
- `[ObservableProperty] string Result`
- `[ObservableProperty] bool IsComplete`
- `[ObservableProperty] bool CanCancel`
- `[RelayCommand] Cancel()` — calls `CancellationTokenSource.Cancel()`
- Subscribe to `IUnlockService.StatusChanged` — parse percentage from status string
- Subscribe to `IUnlockService.UnlockCompleted` — set result and enable close
### Integration ### 4. MainViewModel integration
- Trigger: button in MainViewModel (currently exists but needs to open the dialog) - **Trigger on pump selection** — `OnPumpChanged()` calls `StartUnlockIfRequired()` for pumps with `UnlockType != 0` (both manual and K-Line auto-detect)
- The dialog should be shown via a dialog service or `Window.ShowDialog()` from MainViewModel - **Non-modal window** — `.Show()` instead of `.ShowDialog()`, user can interact with main UI during 10 min unlock
- Marshal all event handlers to UI thread - **Test start guards** — `StartTestAsync` blocks if unlock is in progress or failed
- **Cleanup on pump change** — `CloseUnlockDialog()` stops senders, cancels CTS, disposes ViewModel, force-closes window
### 5. Bug fix: Type 1 verification
**Fixed.** Old code: `Lock = valor != 0` (non-zero = LOCKED). New code had `return Value != 0` (non-zero = SUCCESS). Changed to `return Value == 0`. Type 2 (`== 0xE4`) was already correct.
## Protocol Reference ## Protocol Reference
@@ -54,7 +41,7 @@ The `UnlockService` backend is fully functional (Phase 1 + Phase 2 + verificatio
| Msg1 | 0x700 | `B2 00 00 00 00 00 00 00` | 500 ms | | Msg1 | 0x700 | `B2 00 00 00 00 00 00 00` | 500 ms |
| Msg2 | 0x300 | `01 48 50 C3 00 00 00 00` | 50 ms | | Msg2 | 0x300 | `01 48 50 C3 00 00 00 00` | 50 ms |
| TestUnlock states | 0x700 | `B2`, `B6`, `23`, `24` (byte[0]) x2 | 500 ms each | | TestUnlock states | 0x700 | `B2`, `B6`, `23`, `24` (byte[0]) x2 | 500 ms each |
| Verify | TestUnlock param | Success when value != 0 | One-shot | | Verify | TestUnlock param | Success when value == 0 | One-shot |
### Type 2 (CAN IDs 0x700 + 0x500) ### Type 2 (CAN IDs 0x700 + 0x500)
| Phase | ID | Data | Interval | | Phase | ID | Data | Interval |
@@ -64,11 +51,21 @@ The `UnlockService` backend is fully functional (Phase 1 + Phase 2 + verificatio
| TestUnlock states | 0x700 | `B2`, `24`, `24`, `24` (byte[3]) x2 | 500 ms each | | TestUnlock states | 0x700 | `B2`, `24`, `24`, `24` (byte[3]) x2 | 500 ms each |
| Verify | TestUnlock param | Success when value == 0xE4 | One-shot | | Verify | TestUnlock param | Success when value == 0xE4 | One-shot |
### K-Line fast unlock (timer shortcut)
| Command | `02 88 02 03 A8 01 00` |
|---------|------------------------|
| Effect | Writes pump RAM to fast-forward the internal 10 min timer |
| Prerequisite | Active K-Line session + CAN flood senders already running |
| ACK meaning | Command accepted (NOT unlock confirmed — must still verify via CAN TestUnlock) |
### Duration ### Duration
Phase 1: 600,500 ms (10 min 0.5 sec). Phase 2: ~4 sec (8 messages x 500ms). Total: ~604.5 sec. - Phase 1 (normal): 600,500 ms (10 min 0.5 sec)
- Phase 1 (fast unlock): ~2 sec after K-Line ACK (+ K-Line read time)
- Phase 2: ~4 sec (8 messages x 500 ms)
- CAN senders: persist until pump deselection
## Known Issue in Unlock Verification ### Critical: CAN sender lifecycle
The **Type 1 verification logic may be inverted** compared to the old code. Old: `Lock = (valor != 0)` meant non-zero = LOCKED. New: `Value != 0` returned as SUCCESS (unlocked). Needs hardware testing to confirm which is correct. CAN flood messages must be active **before** the fast unlock attempt (otherwise the timer resets instantly) and must **continue running after** unlock completes. Stopping them causes the pump to re-lock. `StopSenders()` is only called when the pump is deselected.
## Missing Feature: TestImmo Check ## Remaining gap: TestImmo check
Old code tracked both `TestUnlock` and `TestImmo` CAN parameters and displayed combined status. New code only checks `TestUnlock`, ignoring `TestImmo` entirely. Consider adding the immobilizer state check for completeness. Old code tracked both `TestUnlock` and `TestImmo` CAN parameters and displayed combined status ("Inmovilizada/Liberada | Bloqueada/Desbloqueada"). New code only checks `TestUnlock`. Consider adding immobilizer state display for completeness.