feat: implement SavePump/SaveAlarms, fix config round-trip bugs, redesign PDF reports

Config system fixes:
- Implement SavePump() — full XML serialization with insert/update by pump ID
- Add CanBusParameter.ToPumpXml() for legacy P1-P6 pump param format
- Fix LastRotationDirection never loaded in LoadSettings()
- Add SaveAlarms() to ConfigurationService and IConfigurationService
- Remove dead fields AppSettings.Clients and AppSettings.PumpIds

PDF report redesign:
- Professional layout with charts, verdict badges, and tolerance bands
- Add ReportChartRenderer (SVG) and ReportTheme styling constants
- Embed default_logo.png as fallback report logo

Documentation:
- Add gap analysis docs (config validation, ford unlock, missing features)
- Update CLAUDE.md architecture, known gaps, and debt tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 15:21:22 +02:00
parent 4891eb6812
commit c617854c09
15 changed files with 1495 additions and 141 deletions

162
CLAUDE.md
View File

@@ -6,6 +6,7 @@ WPF desktop application (.NET 10 / x64) that controls a VP44 diesel injection pu
- Communicates with the pump ECU via K-Line/KWP2000 (FTDI USB adapter, 9600 bps)
- Runs automated multi-phase test sequences with PID temperature control
- Generates PDF test reports (QuestPDF)
- Supports Ford VP44 immobilizer unlock via CAN (Types 1 & 2)
## Build
Windows only, .NET 10 x64. `dotnet build -r win-x64` from solution root. SDK pinned in `global.json`. No WSL/Linux — native DLLs (`PCANBasic.dll`, `ftd2xx.dll`) are x64 Windows binaries. No test project; validation is code review + manual hardware testing.
@@ -13,37 +14,115 @@ Windows only, .NET 10 x64. `dotnet build -r win-x64` from solution root. SDK pin
## Stack
- C# / .NET 10, WPF, XAML
- CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`
- Microsoft.Extensions.DependencyInjection for bootstrap
- Microsoft.Extensions.DependencyInjection / Hosting for bootstrap
- QuestPDF 2025.3.2 (requires `LicenseType.Community` in constructor)
- LiveChartsCore.SkiaSharpView.WPF 2.0.0-rc3.5 for real-time charts
- Extended.Wpf.Toolkit 4.6.1 (AvalonDock, NumericUpDown, BusyIndicator)
- Native DLLs in output: `PCANBasic.dll`, `ftd2xx.dll` (x64 only)
## Architecture
```
Infrastructure/
Pcan/PcanBasic.cs — Vendor P/Invoke file. NEVER modify.
Pcan/PcanAdapter.cs — CAN read thread, OEM legitimation, frame decode, IIR filter
Kwp/FtdiInterface.cs — FTDI D2XX wrapper (manual LoadLibrary, not NativeLibrary.Load)
Kwp/KwpCommon.cs — 5-baud slow-init, complement-ACK byte I/O
Kwp/KW1281Connection.cs — KWP/KW1281 protocol state machine
Logging/AppLogger.cs — Daily rotating log files
Pcan/PcanBasic.cs — Vendor P/Invoke file. NEVER modify.
Pcan/PcanAdapter.cs — CAN read thread, OEM legitimation, frame decode, IIR filter, liveness tracking
Kwp/FtdiInterface.cs — FTDI D2XX wrapper (manual LoadLibrary, not NativeLibrary.Load)
Kwp/KwpCommon.cs — 5-baud slow-init, complement-ACK byte I/O
Kwp/KW1281Connection.cs — KWP/KW1281 protocol state machine
Kwp/Packets/ — 10 packet types (Ack, Nak, FaultCodes, Eeprom R/W, Custom, etc.)
Logging/AppLogger.cs — Daily rotating log files (ERR/WAR/MSG/DBG)
Services/
ICanService.cs — CAN bus abstraction (parameter map, frame routing, liveness)
IBenchService.cs — Test orchestration, RPM/temp control, relay control
IKwpService.cs — K-Line ECU diagnostics (keep-alive, EEPROM, DTCs, DFI)
IUnlockService.cs — Ford VP44 immobilizer unlock state machine
IConfigurationService.cs — XML persistence for all config files
ICalibrationService.cs — Sensor calibration operations
IPdfService.cs — PDF test report generation
Services/Impl/
BenchService.cs — Test orchestration, temperature PID, relay control (background Task)
KwpService.cs — All KWP operations; one FtdiInterface open/close per call
ConfigurationService.cs — XML persistence for pump definitions, bench config, client list
PdfService.cs Report generation
BenchService.cs — Test orchestration, temperature PID, relay control (background Task)
BenchPidController.cs — Discrete-time PID for RPM ramp (derivative-on-measurement, anti-windup)
KwpService.cs — All KWP operations; persistent session with keep-alive loop
UnlockService.cs — Ford unlock: 600.5s Phase 1 + TestUnlock Phase 2 + verification
ConfigurationService.cs — XML persistence for pumps, bench, config, clients, sensors, alarms, status
CalibrationService.cs — Sensor calibration
PdfService.cs — QuestPDF report generation (multi-page, charts, verdict)
ReportChartRenderer.cs — SVG chart rendering for PDF tolerance-band visualizations
ReportTheme.cs — Report styling constants
ViewModels/ — [ObservableProperty] / [RelayCommand], no UI logic
Views/ — Pure XAML; no code-behind except DI constructors
Models/
CanBusParameter.cs — CAN parameter model + BenchParameterNames/PumpParameterNames/KlineKeys constants
BenchConfiguration.cs — Bench config + embedded AppSettings (PID, timers, safety limits)
PumpDefinition.cs — Pump model, CAN params, tests, K-Line data
TestDefinition.cs — Test type + phase list + tolerance evaluation
PhaseDefinition.cs — Phase readies/sends/receives/results
TestParameter.cs — Measurement point (name, value, tolerance)
TestResult.cs — Accumulated result with samples and pass/fail evaluation
MeasurementSample.cs — Single timestamped measurement
SensorConfiguration.cs — ADC calibration (voltage → engineering units)
Relay.cs — Relay output + RotationDirection/EncoderMode/BaudrateSelection constants
Alarm.cs — Alarm condition (bit, description, criticality, active state)
PumpStatusDefinition.cs — Status word bit definitions with color-coded states
RpmVoltageRelation.cs — RPM → voltage lookup table entry
KLineConnectionState.cs — K-Line session state enum
Converters/
BoolToPassFailBrushConverter.cs
HexColorToBrushConverter.cs
ViewModels/ — [ObservableProperty] / [RelayCommand], no UI logic
MainViewModel.cs — Central orchestrator, event marshalling, child VMs
PumpIdentificationViewModel.cs — Pump selection + K-Line ECU read
DfiManageViewModel.cs — DFI calibration read/write/auto-adjust
TestPanelViewModel.cs — Test suite management (phase enable/disable)
TestDisplayViewModel.cs — Current test execution progress
ResultDisplayViewModel.cs — Test result table (per-phase pass/fail)
PumpControlViewModel.cs — Manual pump control sliders (ME, FBKW, PreIn)
BenchControlViewModel.cs — Bench control (rotation, RPM ramp, oil pump)
FlowmeterChartViewModel.cs — Real-time flowmeter charts
AngleDisplayViewModel.cs — Encoder angle monitoring (PSG, INJ, Manual, Lock Angle)
StatusDisplayViewModel.cs — Pump status word display
Dialogs/ — KlineErrors, Progress, Report, UserCheck ViewModels
Views/
MainWindow.xaml — Root UI (multi-panel layout)
Dialogs/ — KlineErrorsDialog, ProgressDialog, ReportDialog, UserCheckDialog
UserControls/ — AngleDisplay, BenchParamConfig, DfiManage, FlowmeterChart,
PumpControl, PumpIdentification, ResultDisplay, StatusDisplay,
TestDisplay, TestPanel
```
## Configuration files
All XML files stored in `%UserProfile%\.HC_APTBS\config\`:
| File | Purpose |
|------|---------|
| `config.xml` | Global settings: temp limits, PID tuning, refresh rates, safety limits, encoder, motor, company info, K-Line port, language, users, RPM-voltage relations |
| `pumps.xml` | Pump database: per-pump identity, CAN parameters (P1P6 legacy), test definitions with phases |
| `bench.xml` | Bench CAN parameters (Factor/Offset model) + relay definitions. Embedded default fallback in code |
| `sensors.xml` | ADC sensor calibrations (channel → voltage → engineering units) |
| `clients.xml` | Client/operator directory (name → contact) |
| `alarms.xml` | Alarm definitions (bit position, description, criticality) |
| `status.xml` | Pump status word bit definitions with color-coded states |
## Constants quick reference
Defined in `Models/CanBusParameter.cs`:
- `BenchParameterNames` — RPM, Counter, BaudRate, BenchTemp, T-in, T-out, T4, QDelivery, QOver, Alarms, Pressure, AnalogSensor2, encoders (PSG/INJ/Manual)
- `PumpParameterNames` — me, FBKW, mepi, RPM, Temp, Tein, MemoryRequest, TestUnlock, TestImmo, Status, Empf3
- `KlineKeys` — pumpID, SerialNumber, ModelReference, DFI, ErrorCodes, SWV1, SWV2, PumpControl, DataRecord, result
- `RelayNames` — Electronic, OilPump, DepositCooler, DepositHeater, Counter, Direction, TinCooler, Pulse4Signal, Flasher (in `Models/Relay.cs`)
## Rules for all edits
**Always:**
- Read the file before editing it
- Keep XML doc comments on all public types and members
- Use `BenchParameterNames` / `PumpParameterNames` / `KlineKeys` constants — no magic strings
- Use `BenchParameterNames` / `PumpParameterNames` / `KlineKeys` / `RelayNames` constants — no magic strings
- Marshal to the UI thread when consuming `IBenchService` or `IKwpService` events (they fire on background threads)
- Use `CultureInfo.InvariantCulture` when parsing/formatting doubles in XML (avoid locale bugs)
- Log skipped or malformed XML elements — never silently swallow parse failures
- New bench parameters must use Factor/Offset calibration, not P1P6 (legacy model is for pump params only)
**Never:**
- Modify `Infrastructure/Pcan/PcanBasic.cs` (vendor file, intentionally untouched)
@@ -60,5 +139,60 @@ Do not change without knowing why. Use `/protocol-ref` skill for full reference
- Commit format: `feat:`, `fix:`, `refactor:` (conventional commits)
- Git remote: self-hosted Gitea at 192.168.8.130
## Known gaps (from old_source comparison)
See `docs/` guidelines for full specs. Priority: CRITICAL > HIGH > MEDIUM > LOW.
**CRITICAL:**
- No config XML validation — malformed files silently produce partial/default state
**HIGH — Missing safety features:**
- 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)
- 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
**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 KlineIDs auto-mapping (old remembered K-Line ID → pump ID associations)
**MEDIUM — Missing UI components:**
- WConfigPanel (full settings dialog)
- WUsersManage (user CRUD — only auth dialog exists)
- KlineIDSelection (COM port selection for K-Line)
- BitDisplay / SingleBit (bit-level status toggles)
- TimerDisplay (test timer/countdown)
- FilteredComboBox (searchable dropdown)
- PDF report observations/notes section missing from interface
**LOW:**
- 4ms PeriodicTimer may fire at ~15ms on Windows (old MultimediaTimer had ~1ms precision)
- Sensor calibration divide-by-zero when MaxVolt==MinVolt (no guard)
- P1P6 transfer function can produce Infinity/NaN (no denominator check)
- CAN parameter byteh/bytel not bounds-checked to 07
## Known debt
Do not worsen before addressing deliberately. See memory file `project_known_debt.md` for details.
Additional debt items:
- `KW1281Connection` allocates `List<byte>` per packet — GC pressure under long K-Line sessions
- `PcanAdapter.DrainMessageQueue` polls with `Thread.Sleep(2)` — hardcoded, was configurable in old
- No unit tests exist yet
- Users stored as plaintext `user:password` in config.xml
- Config file format has no version tracking or migration logic
- Pump ID uniqueness not enforced on load
- `ParseEcuText()` depends on exact spacing format (fragile)
- AppSettings properties have no bounds validation (timer intervals, safety limits)
- `SaveSettings()` triggers `SaveSensors()` + `SaveClients()` as side effects
## CFG/pump data migration
The old system had 32 CFG files defining pump-specific data NOT present in the new XML model:
- Status bit **reaction codes** (0=none, 1=abort, 2=warning, 3=log) — controlled test flow on errors
- **BIP-STATUS** definitions (needle-motion detection) — absent from new system entirely
- **Diagnostic RAM addresses** (solenoid timing, temperature compensation, learning procedures)
- **Flow regulator sensitivity parameters** (measurement time adjustment, tolerance, FM sensitivity)
- **EEPROM/EPROM/RAM passwords** per pump family
- **CAN-BUS-KENNUNG** protocol variant code (0/1/2/6/10) — derived implicitly in new code
See `docs/gap-pump-data-migration.md` for migration field mapping.

View File

@@ -43,6 +43,11 @@
<PackageReference Include="ToggleSwitch" Version="1.2.0" />
</ItemGroup>
<!-- Embedded images — default report logo fallback -->
<ItemGroup>
<EmbeddedResource Include="Resources\Images\default_logo.png" />
</ItemGroup>
<!-- Native DLLs — must sit next to the executable at runtime -->
<ItemGroup>
<Content Include="Resources\Native\PCANBasic.dll">

View File

@@ -166,14 +166,5 @@ namespace HC_APTBS.Models
/// <summary>Active alarm definitions loaded from alarms.xml.</summary>
public List<Alarm> Alarms { get; set; } = new();
// ── Clients ───────────────────────────────────────────────────────────
/// <summary>Client/operator database, keyed by name (sorted).</summary>
public SortedDictionary<string, string> Clients { get; set; } = new();
// ── Pump IDs ──────────────────────────────────────────────────────────
/// <summary>List of known pump identifiers available in the database.</summary>
public List<string> PumpIds { get; set; } = new();
}
}

View File

@@ -225,6 +225,40 @@ namespace HC_APTBS.Models
return double.Parse(value.Replace(',', '.'), CultureInfo.InvariantCulture);
}
/// <summary>
/// Serialises this parameter to an XML element using the pump-param format
/// (<c>busid</c>, <c>p1p6</c>, <c>send</c>, <c>disableparams</c>).
/// Used when persisting pump definitions to <c>pumps.xml</c>.
/// </summary>
public XElement ToPumpXml()
{
var elm = new XElement(Name,
new XAttribute("busid", MessageId.ToString("X")),
new XAttribute("byteh", ByteH),
new XAttribute("bytel", ByteL),
new XAttribute("type", Type));
if (!IsReceive)
elm.Add(new XAttribute("send", "true"));
if (Alpha != 1.0)
elm.Add(new XAttribute("filter", Alpha.ToString(CultureInfo.InvariantCulture)));
if (DisableCalibration)
{
elm.Add(new XAttribute("disableparams", "true"));
}
else
{
elm.Add(new XAttribute("p1", P1.ToString(CultureInfo.InvariantCulture)));
elm.Add(new XAttribute("p2", P2.ToString(CultureInfo.InvariantCulture)));
elm.Add(new XAttribute("p3", P3.ToString(CultureInfo.InvariantCulture)));
elm.Add(new XAttribute("p4", P4.ToString(CultureInfo.InvariantCulture)));
elm.Add(new XAttribute("p5", P5.ToString(CultureInfo.InvariantCulture)));
elm.Add(new XAttribute("p6", P6.ToString(CultureInfo.InvariantCulture)));
}
return elm;
}
/// <summary>Serialises this parameter to an XML element for persistence in bench.xml.</summary>
public XElement ToXml()
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -58,6 +58,11 @@ namespace HC_APTBS.Services
/// </summary>
PumpStatusDefinition? LoadPumpStatus(int statusId);
// ── Alarms ────────────────────────────────────────────────────────────────
/// <summary>Persists alarm definitions to <c>alarms.xml</c>.</summary>
void SaveAlarms();
// ── Sensors ───────────────────────────────────────────────────────────────
/// <summary>Saves updated sensor calibration data to <c>sensors.xml</c>.</summary>

View File

@@ -190,7 +190,84 @@ namespace HC_APTBS.Services.Impl
/// <inheritdoc/>
public void SavePump(PumpDefinition pump)
{
_log.Info(LogId, $"SavePump({pump.Id}) — not yet implemented.");
try
{
XDocument xdoc;
if (File.Exists(PumpsXml))
{
xdoc = XDocument.Load(PumpsXml);
}
else
{
xdoc = new XDocument(new XElement("Config", new XElement("Pumps")));
}
var root = xdoc.Root!;
var pumpsNode = root.Element("Pumps");
if (pumpsNode == null)
{
pumpsNode = new XElement("Pumps");
root.Add(pumpsNode);
}
// Build the <Pump> element mirroring ParsePumpElement's expected format.
var xpump = new XElement("Pump",
new XAttribute("id", pump.Id),
new XAttribute("model", pump.Model),
new XAttribute("text", pump.EcuText),
new XAttribute("chaveta", pump.Chaveta),
new XAttribute("rotation", pump.Rotation),
new XAttribute("info", pump.Info),
new XAttribute("preinjection", pump.HasPreInjection.ToString().ToLowerInvariant()),
new XAttribute("cilinders4", pump.Is4Cylinder.ToString().ToLowerInvariant()),
new XAttribute("unlock", pump.UnlockType),
new XAttribute("baudrate", pump.CanBaudrate == Peak.Can.Basic.TPCANBaudrate.PCAN_BAUD_250K ? "250" : "500"),
new XAttribute("lockangle", pump.LockAngle.ToString(CultureInfo.InvariantCulture)));
// PumpID child element — GetPumpIds() finds these via Descendants("PumpID").
xpump.Add(new XElement("PumpID", pump.Id));
// ── Serialise <Params> (pump CAN params use legacy P1P6 format) ──
if (pump.ParametersByName.Count > 0)
{
var xparams = new XElement("Params");
foreach (var param in pump.ParametersByName.Values)
xparams.Add(param.ToPumpXml());
xpump.Add(xparams);
}
// ── Serialise <Tests> ──
if (pump.Tests.Count > 0)
{
var xtests = new XElement("Tests");
foreach (var test in pump.Tests)
xtests.Add(test.ToXml());
xpump.Add(xtests);
}
// ── Find existing pump by ID and replace, or append ──
XElement? existing = null;
foreach (var child in pumpsNode.Elements("Pump"))
{
if (child.Attribute("id")?.Value == pump.Id)
{
existing = child;
break;
}
}
if (existing != null)
existing.ReplaceWith(xpump);
else
pumpsNode.Add(xpump);
xdoc.Save(PumpsXml);
_log.Info(LogId, $"SavePump({pump.Id}) — saved to pumps.xml.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SavePump({pump.Id}) failed: {ex.Message}");
}
}
// ── IConfigurationService: Clients ────────────────────────────────────────
@@ -241,6 +318,25 @@ namespace HC_APTBS.Services.Impl
}
}
// ── IConfigurationService: Alarms ─────────────────────────────────────────
/// <inheritdoc/>
public void SaveAlarms()
{
try
{
var root = new XElement("Alarms");
foreach (var alarm in Settings.Alarms)
root.Add(alarm.ToXml());
new XDocument(root).Save(AlarmsXml);
_log.Info(LogId, "Alarms saved.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveAlarms failed: {ex.Message}");
}
}
// ── Pump status definitions ───────────────────────────────────────────────
/// <inheritdoc/>
@@ -349,6 +445,7 @@ namespace HC_APTBS.Services.Impl
TryInt(r, "MaxRpm", v => _settings.MaxRpm = v);
TryBool(r, "RightRelayValue", v => _settings.RightRelayValue = v);
TryBool(r, "DefaultIgnoreTin", v => _settings.DefaultIgnoreTin = v);
TryInt(r, "LastRotationDir", v => _settings.LastRotationDirection = (short)v);
TryInt(r, "DaysKeepLogs", v => _settings.DaysKeepLogs = v);
TryString(r, "CompanyName", v => _settings.CompanyName = v);
TryString(r, "CompanyInfo", v => _settings.CompanyInfo = v);

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using HC_APTBS.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
@@ -8,23 +10,25 @@ using QuestPDF.Infrastructure;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Generates PDF test reports using QuestPDF.
/// Generates professional PDF test reports using QuestPDF.
///
/// <para>
/// Report layout:
/// <list type="bullet">
/// <item>Header: company logo, company name, date, operator, client.</item>
/// <item>Header (every page): company logo, company name, date, operator, client, report title.</item>
/// <item>Pump identification table: ID, serial, model, rotation, lock angle.</item>
/// <item>K-Line ECU data block: model reference, DFI, SW versions, fault codes.</item>
/// <item>Per-test results section: one table per enabled phase showing measured
/// average vs. target ± tolerance and a pass/fail indicator.</item>
/// <item>Footer: page numbers.</item>
/// <item>Overall verdict section: large PASS/FAIL badge with summary statistics.</item>
/// <item>Per-test results: table with pass/fail rows, followed by measurement charts
/// showing sample values against target ± tolerance bands.</item>
/// <item>Footer (every page): software attribution and page numbers.</item>
/// </list>
/// </para>
/// </summary>
public sealed class PdfService : IPdfService
{
private readonly IConfigurationService _config;
private readonly byte[]? _defaultLogo;
/// <param name="configService">Provides company name, logo path, and report settings.</param>
public PdfService(IConfigurationService configService)
@@ -33,6 +37,16 @@ namespace HC_APTBS.Services.Impl
// QuestPDF community licence — required for open-source use.
QuestPDF.Settings.License = LicenseType.Community;
// Load the embedded default logo as a fallback.
using var stream = typeof(PdfService).Assembly
.GetManifestResourceStream("HC_APTBS.Resources.Images.default_logo.png");
if (stream != null)
{
using var ms = new MemoryStream();
stream.CopyTo(ms);
_defaultLogo = ms.ToArray();
}
}
// ── IPdfService ───────────────────────────────────────────────────────────
@@ -50,23 +64,19 @@ namespace HC_APTBS.Services.Impl
$"{pump.Id}_{pump.SerialNumber}_{clientName}_{DateTime.Now:yyyy-MM-dd_HH-mm}.pdf");
string filePath = Path.Combine(outputFolder, fileName);
var reportDate = DateTime.Now;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(25, Unit.Millimetre);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Arial));
page.DefaultTextStyle(x => x.FontSize(ReportTheme.BodySize).FontFamily(Fonts.Arial));
page.Header().Element(c => ComposeHeader(c, pump, operatorName, clientName));
page.Header().Element(c => ComposeHeader(c, operatorName, clientName, reportDate));
page.Content().Element(c => ComposeContent(c, pump));
page.Footer().AlignCenter().Text(t =>
{
t.Span("Page ");
t.CurrentPageNumber();
t.Span(" of ");
t.TotalPages();
});
page.Footer().Element(ComposeFooter);
});
});
@@ -76,60 +86,122 @@ namespace HC_APTBS.Services.Impl
// ── Header ────────────────────────────────────────────────────────────────
/// <summary>Renders the page header: logo, company info, date/operator/client, title.</summary>
private void ComposeHeader(
IContainer container, PumpDefinition pump, string operatorName, string clientName)
IContainer container, string operatorName, string clientName, DateTime reportDate)
{
container.Row(row =>
container.Column(outer =>
{
// Company logo (optional)
if (File.Exists(_config.Settings.ReportLogoPath))
outer.Item().Row(row =>
{
row.ConstantItem(60).Height(40)
.Image(_config.Settings.ReportLogoPath)
.FitArea();
}
// Company logo — prefer configured path, fall back to embedded default.
if (File.Exists(_config.Settings.ReportLogoPath))
{
row.ConstantItem(65).Height(45)
.Image(_config.Settings.ReportLogoPath)
.FitArea();
}
else if (_defaultLogo != null)
{
row.ConstantItem(65).Height(45)
.Image(_defaultLogo)
.FitArea();
}
row.RelativeItem().PaddingLeft(10).Column(col =>
{
col.Item().Text(_config.Settings.CompanyName)
.Bold().FontSize(14);
col.Item().Text(_config.Settings.CompanyInfo)
.FontSize(8).FontColor(Colors.Grey.Darken1);
// Company name and info.
row.RelativeItem().PaddingLeft(10).Column(col =>
{
col.Item().Text(_config.Settings.CompanyName)
.Bold().FontSize(ReportTheme.TitleSize);
col.Item().Text(_config.Settings.CompanyInfo)
.FontSize(ReportTheme.CaptionSize)
.FontColor(ReportTheme.HeaderGrey);
});
// Date / operator / client block.
row.ConstantItem(140).AlignRight().Column(col =>
{
col.Item().Text($"Date: {reportDate:dd/MM/yyyy HH:mm}")
.FontSize(ReportTheme.CaptionSize + 1);
col.Item().Text($"Operator: {operatorName}")
.FontSize(ReportTheme.CaptionSize + 1);
col.Item().Text($"Client: {clientName}")
.FontSize(ReportTheme.CaptionSize + 1).Bold();
});
});
row.ConstantItem(130).Column(col =>
// Divider line.
outer.Item().PaddingTop(4).LineHorizontal(1)
.LineColor(ReportTheme.DividerLine);
// Report title.
outer.Item().PaddingTop(4).PaddingBottom(2)
.AlignCenter()
.Text("VP44 INJECTION PUMP TEST REPORT")
.Bold().FontSize(ReportTheme.SectionHeaderSize)
.FontColor(ReportTheme.HeaderNavy);
});
}
// ── Footer ────────────────────────────────────────────────────────────────
/// <summary>Renders the page footer: divider, attribution, and page numbers.</summary>
private static void ComposeFooter(IContainer container)
{
container.Column(col =>
{
col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine);
col.Item().PaddingTop(3).Row(row =>
{
col.Item().Text($"Date: {DateTime.Now:dd/MM/yyyy HH:mm}").FontSize(8);
col.Item().Text($"Operator: {operatorName}").FontSize(8);
col.Item().Text($"Client: {clientName}").FontSize(8);
row.RelativeItem().Text("Generated by HC-APTBS")
.FontSize(ReportTheme.FooterSize)
.FontColor(ReportTheme.HeaderGrey);
row.ConstantItem(100).AlignRight().Text(t =>
{
t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize));
t.Span("Page ");
t.CurrentPageNumber();
t.Span(" of ");
t.TotalPages();
});
});
});
}
// ── Content ───────────────────────────────────────────────────────────────
/// <summary>Composes the full report body: pump info, ECU data, verdict, test sections.</summary>
private static void ComposeContent(IContainer container, PumpDefinition pump)
{
container.PaddingTop(10).Column(col =>
container.PaddingTop(6).Column(col =>
{
// ── Pump identification ──────────────────────────────────────────
col.Item().PaddingBottom(8).Element(c => ComposePumpInfoTable(c, pump));
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposePumpInfoTable(c, pump));
// ── K-Line ECU data ──────────────────────────────────────────────
if (pump.KlineInfo.Count > 0)
col.Item().PaddingBottom(8).Element(c => ComposeKlineTable(c, pump));
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposeKlineTable(c, pump));
// ── Overall verdict ──────────────────────────────────────────────
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposeVerdictSection(c, pump));
// ── Test results — one section per test ──────────────────────────
foreach (var test in pump.Tests)
{
if (!test.HasResults()) continue;
col.Item().PaddingBottom(6).Element(c => ComposeTestSection(c, test));
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposeTestSection(c, test));
}
});
}
// ── Pump info table ───────────────────────────────────────────────────────
/// <summary>Renders the pump identification table with alternating row stripes.</summary>
private static void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
{
container.Table(table =>
@@ -145,32 +217,41 @@ namespace HC_APTBS.Services.Impl
table.Header(header =>
{
header.Cell().ColumnSpan(4)
.Background(Colors.Blue.Darken3)
.Padding(4)
.Background(ReportTheme.HeaderNavy)
.Padding(5)
.Text("PUMP IDENTIFICATION")
.FontColor(Colors.White).Bold().FontSize(10);
.FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize);
});
int rowIndex = 0;
void AddRow(string label1, string value1, string label2, string value2)
{
table.Cell().Padding(3).Text(label1).Bold();
table.Cell().Padding(3).Text(value1);
table.Cell().Padding(3).Text(label2).Bold();
table.Cell().Padding(3).Text(value2);
string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF";
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(label1).Bold().FontSize(ReportTheme.BodySize);
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(value1).FontSize(ReportTheme.BodySize);
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(label2).Bold().FontSize(ReportTheme.BodySize);
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(value2).FontSize(ReportTheme.BodySize);
}
AddRow("Pump ID:", pump.Id, "Model:", pump.Model);
AddRow("Serial No.:", pump.SerialNumber, "Injector:", pump.Injector);
AddRow("Tube:", pump.Tube, "Valve:", pump.Valve);
AddRow("Tension:", pump.Tension, "Rotation:", pump.Rotation);
AddRow("Lock Angle:", $"{pump.LockAngle:F2}°",
"Measured:", $"{pump.LockAngleResult:F2}°");
AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No");
AddRow("Pump ID:", pump.Id, "Model:", pump.Model);
AddRow("Serial No.:", pump.SerialNumber, "Injector:", pump.Injector);
AddRow("Tube:", pump.Tube, "Valve:", pump.Valve);
AddRow("Tension:", pump.Tension, "Rotation:", pump.Rotation);
AddRow("Lock Angle:", $"{pump.LockAngle:F2}\u00B0",
"Measured:", $"{pump.LockAngleResult:F2}\u00B0");
AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No");
});
}
// ── K-Line table ──────────────────────────────────────────────────────────
/// <summary>Renders the K-Line ECU data table with alternating row stripes.</summary>
private static void ComposeKlineTable(IContainer container, PumpDefinition pump)
{
container.Table(table =>
@@ -186,19 +267,24 @@ namespace HC_APTBS.Services.Impl
table.Header(header =>
{
header.Cell().ColumnSpan(4)
.Background(Colors.Blue.Darken2)
.Padding(4)
.Background(ReportTheme.HeaderNavy)
.Padding(5)
.Text("ECU DATA (K-Line)")
.FontColor(Colors.White).Bold().FontSize(10);
.FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize);
});
int rowIndex = 0;
void AddKv(string key)
{
if (pump.KlineInfo.TryGetValue(key, out var val))
{
table.Cell().Padding(3).Text(key + ":").Bold();
table.Cell().ColumnSpan(3).Padding(3).Text(val);
}
if (!pump.KlineInfo.TryGetValue(key, out var val)) return;
string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF";
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(key + ":").Bold().FontSize(ReportTheme.BodySize);
table.Cell().ColumnSpan(3).Background(bg).Padding(ReportTheme.CellPad)
.Text(val).FontSize(ReportTheme.BodySize);
}
AddKv(KlineKeys.ModelReference);
@@ -212,81 +298,243 @@ namespace HC_APTBS.Services.Impl
});
}
// ── Verdict section ───────────────────────────────────────────────────────
/// <summary>Renders the overall test result badge with summary statistics.</summary>
private static void ComposeVerdictSection(IContainer container, PumpDefinition pump)
{
// Compute summary statistics.
var testsWithResults = pump.Tests.Where(t => t.HasResults()).ToList();
int totalTests = pump.Tests.Count;
int testedCount = testsWithResults.Count;
int totalPhases = 0;
int passedPhases = 0;
foreach (var test in testsWithResults)
{
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null) continue;
totalPhases++;
if (tp.Result.Passed) passedPhases++;
}
}
}
bool allPassed = totalPhases > 0 && passedPhases == totalPhases;
container.Border(1).BorderColor(ReportTheme.DividerLine)
.Background(ReportTheme.TableAltRow)
.Padding(8)
.Row(row =>
{
// PASS/FAIL verdict badge (SVG).
row.ConstantItem(100).Height(50)
.Svg(ReportChartRenderer.RenderVerdictBadge(100, 50, allPassed))
.FitArea();
// Summary statistics.
row.RelativeItem().PaddingLeft(12).Column(col =>
{
col.Item().Text("OVERALL TEST RESULT")
.Bold().FontSize(ReportTheme.SectionHeaderSize)
.FontColor(ReportTheme.HeaderNavy);
col.Item().PaddingTop(4).Text(
$"Tests executed: {testedCount} of {totalTests}")
.FontSize(ReportTheme.BodySize);
col.Item().Text(
$"Parameters evaluated: {passedPhases} / {totalPhases} passed")
.FontSize(ReportTheme.BodySize);
// Per-test mini indicators.
col.Item().PaddingTop(4).Row(indicators =>
{
foreach (var test in pump.Tests)
{
bool hasResults = test.HasResults();
bool testPassed = hasResults && test.Phases
.Where(p => p.Enabled && p.Receives != null)
.SelectMany(p => p.Receives)
.Where(tp => tp.Result != null)
.All(tp => tp.Result!.Passed);
string indicator = !hasResults ? "--"
: testPassed ? "\u2713" : "\u2717";
string color = !hasResults ? ReportTheme.HeaderGrey
: testPassed ? ReportTheme.PassGreen : ReportTheme.FailRed;
indicators.AutoItem().PaddingRight(8).Text(t =>
{
t.Span($"{test.Name}: ").FontSize(ReportTheme.BodySize);
t.Span(indicator).Bold().FontSize(ReportTheme.BodySize)
.FontColor(color);
});
}
});
});
});
}
// ── Test results section ──────────────────────────────────────────────────
/// <summary>Renders a single test: results table followed by measurement charts.</summary>
private static void ComposeTestSection(IContainer container, TestDefinition test)
{
container.Column(col =>
{
// Section header bar.
col.Item()
.Background(Colors.Grey.Lighten2)
.Padding(4)
.Background(ReportTheme.HeaderNavy)
.Padding(5)
.Text($"TEST: {test.Name}")
.Bold().FontSize(10);
.FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize);
col.Item().Table(table =>
// Results table.
col.Item().Element(c => ComposeResultsTable(c, test));
// Charts — one per parameter that has sample data.
col.Item().PaddingTop(ReportTheme.SubsectionGap)
.Element(c => ComposeTestCharts(c, test));
});
}
/// <summary>Renders the pass/fail results table for one test.</summary>
private static void ComposeResultsTable(IContainer container, TestDefinition test)
{
container.Table(table =>
{
table.ColumnsDefinition(cols =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2); // Phase
cols.RelativeColumn(1); // Parameter
cols.RelativeColumn(1); // Target
cols.RelativeColumn(1); // Tolerance
cols.RelativeColumn(1); // Average
cols.RelativeColumn(1); // Result
});
table.Header(header =>
{
foreach (var h in new[] { "Phase", "Parameter", "Target", "Tolerance ±", "Average", "Result" })
header.Cell()
.Background(Colors.Grey.Darken1)
.Padding(3)
.Text(h).FontColor(Colors.White).Bold().FontSize(8);
});
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null) continue;
bool passed = tp.Result.Passed;
string resultText = passed ? "PASS" : "FAIL";
string bgColor = passed ? Colors.Green.Lighten4 : Colors.Red.Lighten4;
table.Cell().Background(bgColor).Padding(3).Text(phase.Name).FontSize(8);
table.Cell().Background(bgColor).Padding(3).Text(tp.Name).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(tp.Value.ToString("F2")).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(tp.Tolerance.ToString("F2")).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(tp.Result.Average.ToString("F2")).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(resultText).Bold()
.FontColor(passed ? Colors.Green.Darken2 : Colors.Red.Darken2)
.FontSize(8);
}
// Show any alarm bits that fired during this phase.
if (phase.ErrorBits?.Count > 0)
{
table.Cell().ColumnSpan(6)
.Background(Colors.Orange.Lighten4)
.Padding(3)
.Text($" ⚠ Error bits: {string.Join(", ", phase.ErrorBits)}")
.FontSize(8).FontColor(Colors.Orange.Darken3);
}
}
cols.RelativeColumn(2); // Phase
cols.RelativeColumn(1); // Parameter
cols.RelativeColumn(1); // Target
cols.RelativeColumn(1); // Tolerance
cols.RelativeColumn(1); // Average
cols.RelativeColumn(1); // Result
});
table.Header(header =>
{
foreach (var h in new[] { "Phase", "Parameter", "Target", "Tolerance \u00B1", "Average", "Result" })
header.Cell()
.Background(ReportTheme.AccentBlue)
.Padding(ReportTheme.CellPad)
.Text(h).FontColor(Colors.White).Bold()
.FontSize(ReportTheme.CaptionSize + 1);
});
int rowIndex = 0;
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null) continue;
bool passed = tp.Result.Passed;
string resultText = passed ? "PASS" : "FAIL";
// Alternating base row colour, tinted by pass/fail.
string bgColor = passed
? (rowIndex % 2 == 0 ? ReportTheme.PassGreenLight : "#D7ECD9")
: (rowIndex % 2 == 0 ? ReportTheme.FailRedLight : "#FFDBDB");
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(phase.Name).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Name).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Value.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Tolerance.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Result.Average.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(resultText).Bold()
.FontColor(passed ? ReportTheme.PassGreen : ReportTheme.FailRed)
.FontSize(ReportTheme.CaptionSize + 1);
rowIndex++;
}
// Show any alarm bits that fired during this phase.
if (phase.ErrorBits?.Count > 0)
{
table.Cell().ColumnSpan(6)
.Background(ReportTheme.WarningBg)
.Padding(ReportTheme.CellPad)
.Text($" \u26A0 Error bits: {string.Join(", ", phase.ErrorBits)}")
.FontSize(ReportTheme.CaptionSize + 1)
.FontColor(ReportTheme.WarningText);
}
}
});
}
/// <summary>Renders measurement charts for each parameter that has sample data.</summary>
private static void ComposeTestCharts(IContainer container, TestDefinition test)
{
container.Column(col =>
{
bool anyChart = false;
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null || tp.Result.Samples.Count < 2) continue;
anyChart = true;
string label = $"{phase.Name} \u2014 {tp.Name}";
// The chart itself (SVG rendered inline).
string chartSvg = ReportChartRenderer.RenderMeasurementChart(
480, ReportTheme.ChartHeight,
tp.Result.Samples,
tp.Value,
tp.Tolerance,
tp.Result.Average,
tp.Result.Passed,
label);
col.Item().PaddingBottom(2)
.Svg(chartSvg)
.FitWidth();
// Chart caption.
col.Item().PaddingBottom(ReportTheme.SubsectionGap)
.Text($"Samples: {tp.Result.Samples.Count} | " +
$"Target: {tp.Value:F2} \u00B1 {tp.Tolerance:F2} | " +
$"Average: {tp.Result.Average:F2} | " +
$"Result: {(tp.Result.Passed ? "PASS" : "FAIL")}")
.FontSize(ReportTheme.CaptionSize)
.FontColor(ReportTheme.HeaderGrey);
}
}
if (!anyChart)
{
col.Item().PaddingTop(2).PaddingBottom(4)
.Text("No sample data available for graphical display.")
.FontSize(ReportTheme.CaptionSize).Italic()
.FontColor(ReportTheme.HeaderGrey);
}
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>Sanitises a file name by replacing invalid characters with underscores.</summary>
private static string SanitiseFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())

View File

@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using HC_APTBS.Models;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Generates SVG chart markup for embedding in QuestPDF reports via the
/// <c>.Svg()</c> API. All rendering is done as raw SVG strings — no
/// SkiaSharp dependency required at PDF-generation time.
/// </summary>
internal static class ReportChartRenderer
{
// ── Layout constants (SVG user-units, mapped to points in the PDF) ────
private const float LeftMargin = 48f;
private const float RightMargin = 12f;
private const float TopMargin = 22f;
private const float BottomMargin = 24f;
private const float LegendHeight = 14f;
// ── Public API ────────────────────────────────────────────────────────
/// <summary>
/// Produces an SVG string depicting a line chart of individual
/// measurement samples with a horizontal target line, shaded tolerance
/// band, average line, and axis labels.
/// </summary>
/// <param name="width">Chart width in points.</param>
/// <param name="height">Chart height in points.</param>
/// <param name="samples">Individual timestamped measurements.</param>
/// <param name="targetValue">Expected / setpoint value.</param>
/// <param name="tolerance">Acceptable deviation (±).</param>
/// <param name="average">Computed average of all samples.</param>
/// <param name="passed">Whether the parameter passed the tolerance check.</param>
/// <param name="label">Chart title label (e.g. "1000 RPM 40°C — QDelivery").</param>
/// <returns>A complete SVG document string.</returns>
public static string RenderMeasurementChart(
float width,
float height,
IReadOnlyList<MeasurementSample> samples,
double targetValue,
double tolerance,
double average,
bool passed,
string label)
{
if (samples.Count < 2)
return RenderInsufficientData(width, height, label);
var sb = new StringBuilder();
sb.AppendLine(SvgHeader(width, height));
// Plot area bounds.
float plotLeft = LeftMargin;
float plotRight = width - RightMargin;
float plotTop = TopMargin + LegendHeight;
float plotBottom = height - BottomMargin;
float plotW = plotRight - plotLeft;
float plotH = plotBottom - plotTop;
// Y-axis range: encompass all samples and the tolerance band, with padding.
double minVal = samples.Min(s => s.Value);
double maxVal = samples.Max(s => s.Value);
double bandLo = targetValue - tolerance;
double bandHi = targetValue + tolerance;
minVal = Math.Min(minVal, bandLo);
maxVal = Math.Max(maxVal, bandHi);
double range = maxVal - minVal;
if (range < 0.001) range = 1.0;
double padding = range * 0.12;
double yMin = minVal - padding;
double yMax = maxVal + padding;
double yRange = yMax - yMin;
// ── Background & border ──────────────────────────────────────────
sb.AppendLine(Rect(0, 0, width, height, "#FFFFFF", null, 0));
sb.AppendLine(Rect(plotLeft, plotTop, plotW, plotH, null, Col(ReportTheme.ChartGrid), 1));
// ── Grid lines ───────────────────────────────────────────────────
const int gridCount = 5;
double gridStep = yRange / gridCount;
for (int i = 0; i <= gridCount; i++)
{
double val = yMin + gridStep * i;
float y = ValueToY(val, plotTop, plotH, yMin, yRange);
sb.AppendLine(Line(plotLeft, y, plotRight, y, Col(ReportTheme.ChartGrid), 0.5f, null));
string text = val.ToString("F1", CultureInfo.InvariantCulture);
sb.AppendLine(Text(plotLeft - 4f, y + 3f, text, Col(ReportTheme.AxisLabel), 7f, "end"));
}
// ── Tolerance band ───────────────────────────────────────────────
float bandTopY = Math.Max(ValueToY(bandHi, plotTop, plotH, yMin, yRange), plotTop);
float bandBotY = Math.Min(ValueToY(bandLo, plotTop, plotH, yMin, yRange), plotBottom);
sb.AppendLine(Rect(plotLeft, bandTopY, plotW, bandBotY - bandTopY,
ColAlpha(ReportTheme.ToleranceBand), null, 0));
// ── Target line (dashed amber) ───────────────────────────────────
float targetY = ValueToY(targetValue, plotTop, plotH, yMin, yRange);
sb.AppendLine(Line(plotLeft, targetY, plotRight, targetY,
Col(ReportTheme.TargetLine), 1.5f, "6,3"));
// ── Average line (dashed green/red) ──────────────────────────────
float avgY = ValueToY(average, plotTop, plotH, yMin, yRange);
string avgCol = passed ? Col(ReportTheme.AvgPassLine) : Col(ReportTheme.AvgFailLine);
sb.AppendLine(Line(plotLeft, avgY, plotRight, avgY, avgCol, 1.2f, "4,3"));
// ── Data polyline ────────────────────────────────────────────────
var points = new StringBuilder();
for (int i = 0; i < samples.Count; i++)
{
float x = plotLeft + (plotW * i / (samples.Count - 1));
float y = ValueToY(samples[i].Value, plotTop, plotH, yMin, yRange);
if (i > 0) points.Append(' ');
points.Append(F(x)).Append(',').Append(F(y));
}
sb.Append($"<polyline points=\"{points}\" fill=\"none\" ")
.Append($"stroke=\"{Col(ReportTheme.SampleLine)}\" stroke-width=\"1.5\" ")
.AppendLine($"stroke-linejoin=\"round\"/>");
// ── Sample dots (only if ≤ 60 samples) ──────────────────────────
if (samples.Count <= 60)
{
for (int i = 0; i < samples.Count; i++)
{
float x = plotLeft + (plotW * i / (samples.Count - 1));
float y = ValueToY(samples[i].Value, plotTop, plotH, yMin, yRange);
sb.AppendLine($"<circle cx=\"{F(x)}\" cy=\"{F(y)}\" r=\"2\" " +
$"fill=\"{Col(ReportTheme.SampleLine)}\"/>");
}
}
// ── X-axis labels ────────────────────────────────────────────────
int xLabelStep = Math.Max(1, samples.Count / 8);
for (int i = 0; i < samples.Count; i += xLabelStep)
{
float x = plotLeft + (plotW * i / (samples.Count - 1));
sb.AppendLine(Text(x, plotBottom + 12f, (i + 1).ToString(),
Col(ReportTheme.AxisLabel), 7f, "middle"));
}
// ── Title label (top-left) ───────────────────────────────────────
sb.AppendLine(Text(plotLeft, TopMargin, EscapeXml(label), "#1B2A4A", 9f, "start"));
// ── Legend (top-right) ────────────────────────────────────────────
string targetText = $"Target: {targetValue.ToString("F2", CultureInfo.InvariantCulture)}";
string avgText = $"Avg: {average.ToString("F2", CultureInfo.InvariantCulture)}";
sb.AppendLine(Text(plotRight - 80f, TopMargin, targetText,
Col(ReportTheme.TargetLine), 7f, "start"));
sb.AppendLine(Text(plotRight, TopMargin, avgText, avgCol, 7f, "end"));
sb.AppendLine("</svg>");
return sb.ToString();
}
/// <summary>
/// Produces an SVG string depicting a large PASS/FAIL verdict badge.
/// </summary>
public static string RenderVerdictBadge(float width, float height, bool passed)
{
var sb = new StringBuilder();
sb.AppendLine(SvgHeader(width, height));
string bgColor = passed ? "#2E7D32" : "#C62828";
sb.AppendLine($"<rect x=\"2\" y=\"2\" width=\"{F(width - 4)}\" height=\"{F(height - 4)}\" " +
$"rx=\"8\" ry=\"8\" fill=\"{bgColor}\"/>");
string text = passed ? "PASS" : "FAIL";
float fontSize = height * 0.45f;
sb.AppendLine($"<text x=\"{F(width / 2)}\" y=\"{F(height / 2 + fontSize * 0.35f)}\" " +
$"font-family=\"Arial\" font-weight=\"bold\" font-size=\"{F(fontSize)}\" " +
$"fill=\"white\" text-anchor=\"middle\">{text}</text>");
sb.AppendLine("</svg>");
return sb.ToString();
}
// ── Private helpers ───────────────────────────────────────────────────
/// <summary>Maps a data value to the Y coordinate (inverted — higher values = lower Y).</summary>
private static float ValueToY(double value, float plotTop, float plotH, double yMin, double yRange)
=> plotTop + (float)((1.0 - (value - yMin) / yRange) * plotH);
/// <summary>Renders a placeholder SVG when fewer than 2 samples are available.</summary>
private static string RenderInsufficientData(float width, float height, string label)
{
var sb = new StringBuilder();
sb.AppendLine(SvgHeader(width, height));
sb.AppendLine(Rect(0, 0, width, height, "#F5F7FA", Col(ReportTheme.ChartGrid), 1));
sb.AppendLine(Text(12f, 18f, EscapeXml(label), "#1B2A4A", 9f, "start"));
sb.AppendLine(Text(width / 2, height / 2 + 4f,
"Insufficient sample data for chart", Col(ReportTheme.AxisLabel), 10f, "middle"));
sb.AppendLine("</svg>");
return sb.ToString();
}
// ── SVG primitives ────────────────────────────────────────────────────
/// <summary>SVG document header.</summary>
private static string SvgHeader(float w, float h)
=> $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{F(w)}\" height=\"{F(h)}\" " +
$"viewBox=\"0 0 {F(w)} {F(h)}\">";
/// <summary>SVG rect element with optional fill and/or stroke.</summary>
private static string Rect(float x, float y, float w, float h,
string? fill, string? stroke, float strokeWidth)
{
var sb = new StringBuilder();
sb.Append($"<rect x=\"{F(x)}\" y=\"{F(y)}\" width=\"{F(w)}\" height=\"{F(h)}\"");
sb.Append(fill != null ? $" fill=\"{fill}\"" : " fill=\"none\"");
if (stroke != null) sb.Append($" stroke=\"{stroke}\" stroke-width=\"{F(strokeWidth)}\"");
sb.Append("/>");
return sb.ToString();
}
/// <summary>SVG line element with optional dash array.</summary>
private static string Line(float x1, float y1, float x2, float y2,
string stroke, float strokeWidth, string? dashArray)
{
var sb = new StringBuilder();
sb.Append($"<line x1=\"{F(x1)}\" y1=\"{F(y1)}\" x2=\"{F(x2)}\" y2=\"{F(y2)}\" ");
sb.Append($"stroke=\"{stroke}\" stroke-width=\"{F(strokeWidth)}\"");
if (dashArray != null) sb.Append($" stroke-dasharray=\"{dashArray}\"");
sb.Append("/>");
return sb.ToString();
}
/// <summary>SVG text element.</summary>
private static string Text(float x, float y, string content,
string fill, float fontSize, string anchor)
=> $"<text x=\"{F(x)}\" y=\"{F(y)}\" font-family=\"Arial\" font-size=\"{F(fontSize)}\" " +
$"fill=\"{fill}\" text-anchor=\"{anchor}\">{content}</text>";
/// <summary>Formats a float with invariant culture (dot decimal separator).</summary>
private static string F(float v) => v.ToString("F1", CultureInfo.InvariantCulture);
/// <summary>Converts an SKColor to an SVG hex colour string.</summary>
private static string Col(SkiaSharp.SKColor c) => $"#{c.Red:X2}{c.Green:X2}{c.Blue:X2}";
/// <summary>Converts an SKColor with alpha to an SVG rgba() string.</summary>
private static string ColAlpha(SkiaSharp.SKColor c)
=> $"rgba({c.Red},{c.Green},{c.Blue},{(c.Alpha / 255.0).ToString("F2", CultureInfo.InvariantCulture)})";
/// <summary>Escapes XML special characters in text content.</summary>
private static string EscapeXml(string s)
=> s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
.Replace("\"", "&quot;").Replace("\u2014", "-");
}
}

View File

@@ -0,0 +1,106 @@
using SkiaSharp;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Centralised visual constants for the PDF test report.
/// All colours, font sizes, spacing, and chart dimensions live here
/// so the report look-and-feel can be adjusted in one place.
/// </summary>
internal static class ReportTheme
{
// ── Colour palette (hex strings for QuestPDF, SKColor for charts) ─────
/// <summary>Dark navy for section header bars.</summary>
public const string HeaderNavy = "#1B2A4A";
/// <summary>Secondary grey for sub-headers and muted text.</summary>
public const string HeaderGrey = "#4A4A4A";
/// <summary>Accent blue for highlights.</summary>
public const string AccentBlue = "#2E5090";
/// <summary>Pass result — dark green text.</summary>
public const string PassGreen = "#2E7D32";
/// <summary>Pass result — light green background.</summary>
public const string PassGreenLight = "#E8F5E9";
/// <summary>Fail result — dark red text.</summary>
public const string FailRed = "#C62828";
/// <summary>Fail result — light red background.</summary>
public const string FailRedLight = "#FFEBEE";
/// <summary>Alternating table row stripe.</summary>
public const string TableAltRow = "#F5F7FA";
/// <summary>Thin divider lines.</summary>
public const string DividerLine = "#BDBDBD";
/// <summary>Error/warning row background.</summary>
public const string WarningBg = "#FFF3E0";
/// <summary>Error/warning text.</summary>
public const string WarningText = "#E65100";
// ── Chart-specific SKColors ───────────────────────────────────────────
/// <summary>Semi-transparent blue tolerance band.</summary>
public static readonly SKColor ToleranceBand = new(46, 80, 144, 50);
/// <summary>Amber dashed target line.</summary>
public static readonly SKColor TargetLine = new(255, 111, 0);
/// <summary>Blue data polyline.</summary>
public static readonly SKColor SampleLine = new(21, 101, 192);
/// <summary>Light grey chart gridlines and border.</summary>
public static readonly SKColor ChartGrid = new(224, 224, 224);
/// <summary>Chart background.</summary>
public static readonly SKColor ChartBackground = SKColors.White;
/// <summary>Green average line (pass).</summary>
public static readonly SKColor AvgPassLine = new(46, 125, 50);
/// <summary>Red average line (fail).</summary>
public static readonly SKColor AvgFailLine = new(198, 40, 40);
/// <summary>Chart axis label colour.</summary>
public static readonly SKColor AxisLabel = new(97, 97, 97);
// ── Font sizes (points) ───────────────────────────────────────────────
/// <summary>Report title.</summary>
public const float TitleSize = 16f;
/// <summary>Section header text (e.g. "PUMP IDENTIFICATION").</summary>
public const float SectionHeaderSize = 11f;
/// <summary>Body / table cell text.</summary>
public const float BodySize = 9f;
/// <summary>Small captions and chart labels.</summary>
public const float CaptionSize = 7f;
/// <summary>Footer text.</summary>
public const float FooterSize = 7f;
// ── Spacing (points) ─────────────────────────────────────────────────
/// <summary>Gap between major sections.</summary>
public const float SectionGap = 10f;
/// <summary>Gap between sub-sections (e.g. chart caption).</summary>
public const float SubsectionGap = 6f;
/// <summary>Table cell padding.</summary>
public const float CellPad = 3f;
// ── Chart dimensions (points) ─────────────────────────────────────────
/// <summary>Chart render height inside the PDF.</summary>
public const float ChartHeight = 160f;
}
}

View File

@@ -0,0 +1,76 @@
# Gap: Configuration Validation & Migration
## Problem
The configuration system loads 7 XML files with no schema validation, no bounds checking, and silent error swallowing. Malformed files produce partial/default state with no user indication. There is no migration path from the old 32-CFG-file format.
## Critical Issues
### 1. SavePump() Stub
**File:** `Services/Impl/ConfigurationService.cs:191-194`
**Impact:** Pump definition edits (test phase enable/disable, parameter changes, new pumps) are lost on restart.
**Fix:** Implement full XML serialization mirroring the `LoadPump`/`ParsePumpElement` logic. Use `PumpDefinition.ToXml()`, `TestDefinition.ToXml()`, `PhaseDefinition.ToXml()`. Write to `pumps.xml` at the correct XPath location. Handle both insert (new pump) and update (existing pump) cases.
### 2. LastRotationDirection Never Loaded
**File:** `Services/Impl/ConfigurationService.cs``SaveSettings()` writes `<LastRotationDir>` but `LoadSettings()` never reads it.
**Fix:** Add `TryInt(r, "LastRotationDir", v => settings.LastRotationDirection = (short)v);` in `LoadSettings()`.
### 3. No SaveAlarms()
**File:** `Services/Impl/ConfigurationService.cs`
**Impact:** Alarm configuration changes cannot be persisted.
**Fix:** Add `SaveAlarms()` using `Alarm.ToXml()` for each alarm, write to `alarms.xml`.
### 4. SaveSettings() Side Effects
**File:** `Services/Impl/ConfigurationService.cs:101-102`
**Impact:** Calling `SaveSettings()` always also writes sensors.xml and clients.xml.
**Fix:** Split into `SaveSettings()`, `SaveSensors()`, `SaveClients()` called independently. Or document the coupling clearly.
## Bounds Validation Rules
Add these checks in the respective `FromXml`/`Load*` methods:
| Field | Location | Valid Range | Guard |
|-------|----------|-------------|-------|
| `byteh` / `bytel` | `CanBusParameter.FromXml`, `ParseParamElement` | 07 | Clamp or reject + log |
| `RefreshCanBusReadMs` | `LoadSettings` | >= 1 | Floor at 1 |
| `RefreshPumpParamsMs` | `LoadSettings` | >= 1 | Floor at 1 |
| `SecurityRpmLimit` | `LoadSettings` | 1005000 | Clamp |
| `MaxPressureBar` | `LoadSettings` | 1100 | Clamp |
| `PidP, PidI, PidD` | `LoadSettings` | >= 0 | Floor at 0 |
| `Relay.Bit` | `ParseRelayElement` | 063 | Reject + log |
| `SensorConfiguration.MaxVolt - MinVolt` | `GetValueFromRaw` | != 0 | Return 0 + log |
| P1P6 denominator | `GetTransformResult` | != 0 | Return 0 + log |
## Locale-Safe Parsing
All `double.Parse` / `int.Parse` calls in XML loading must use `CultureInfo.InvariantCulture`. Known violations:
- `SensorConfiguration.FromXml``double.Parse(v)` with no culture (line ~78-83)
- `CanBusParameter.ParseDecimal` — already uses `InvariantCulture` (correct)
- `ConfigurationService.TryDouble` — uses `double.TryParse` with `InvariantCulture` (correct)
## Config Versioning Approach
Add a `version` attribute to each XML root element:
```xml
<Config version="2">...</Config>
<Pumps version="1">...</Pumps>
```
On load, check version. If missing or old, run migration logic. Bump version after migration. This enables safe format evolution.
## Error Reporting Strategy
Replace all `catch { }` and `catch (Exception) { }` blocks with:
```csharp
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{elementName}': {ex.Message}");
}
```
Files affected:
- `ConfigurationService.cs``TryInt`, `TryDouble`, `TryBool`, `TryString` helpers (lines 621-646)
- `SensorConfiguration.cs``TryParse` wrapper (line 100-103)
## Dead Fields
`AppSettings.Clients` and `AppSettings.PumpIds` are never populated by `LoadSettings()` — they are dead fields. Either remove them or wire up the loading. Current live data paths: `ConfigurationService._clients` and `GetPumpIds()`.
## Old CFG → XML Migration Strategy
See `gap-pump-data-migration.md` for the full field mapping. In summary:
1. Parse each CFG file using the pipe-delimited format documented in `old_source/HerlicScripts/Program.cs`
2. Map CAN IDs, P1-P6 formulas, status bit definitions, and EEPROM passwords to the pumps.xml and status.xml schemas
3. Run once as a migration tool, not as runtime logic

View File

@@ -0,0 +1,74 @@
# Gap: Ford Unlock Progress UI
## Problem
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
- `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`)
- 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
### UnlockDialog.xaml (View)
- Modal dialog (MVVM, no code-behind logic)
- Progress bar (0-100%) + percentage text
- Elapsed time display (MM:SS)
- Phase indicator: "Phase 1: Sending unlock signals" / "Phase 2: Testing" / "Verifying..."
- Current unlock type indicator (Type 1 / Type 2)
- Cancel button (disabled during Phase 2 — it cannot be cancelled once started)
- Close button (enabled only after completion)
- Result indicator: green checkmark (success) / red X (failed)
### UnlockViewModel.cs (ViewModel)
- `[ObservableProperty] double Progress`
- `[ObservableProperty] string ElapsedTime`
- `[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
- Trigger: button in MainViewModel (currently exists but needs to open the dialog)
- The dialog should be shown via a dialog service or `Window.ShowDialog()` from MainViewModel
- Marshal all event handlers to UI thread
## Protocol Reference
### Type 1 (CAN IDs 0x700 + 0x300)
| Phase | ID | Data | Interval |
|-------|----|------|----------|
| Msg1 | 0x700 | `B2 00 00 00 00 00 00 00` | 500 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 |
| Verify | TestUnlock param | Success when value != 0 | One-shot |
### Type 2 (CAN IDs 0x700 + 0x500)
| Phase | ID | Data | Interval |
|-------|----|------|----------|
| Msg1 | 0x700 | `00 00 00 B2 00 00 00 00` | 500 ms |
| Msg2 | 0x500 | `00 00 00 00 78 00 00 00` | 50 ms |
| TestUnlock states | 0x700 | `B2`, `24`, `24`, `24` (byte[3]) x2 | 500 ms each |
| Verify | TestUnlock param | Success when value == 0xE4 | One-shot |
### Duration
Phase 1: 600,500 ms (10 min 0.5 sec). Phase 2: ~4 sec (8 messages x 500ms). Total: ~604.5 sec.
## Known Issue in Unlock Verification
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.
## Missing Feature: 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.

View File

@@ -0,0 +1,85 @@
# Gap: Missing Dialogs and UI Components
## Priority-Ranked List
### CRITICAL — Safety Dialogs (prevent hardware damage)
#### 1. RPM Safety Warning (`WCareOnRpmOn`)
**Old behavior:** Shown when operator sets RPM > 0 but oil pump is not running. Three options: turn on oil pump + proceed, proceed without oil, cancel.
**Trigger:** RPM setpoint change in BenchControlViewModel
**MVVM:** Dialog returns enum (ProceedWithOil / ProceedWithout / Cancel)
**Why critical:** Running the pump motor without oil circulation can cause bearing damage.
#### 2. Oil Pump Confirmation (`WAcceptOilTurnOn`)
**Old behavior:** Confirmation dialog before activating oil pump. Ensures operator confirms oil level and connections are ready.
**Trigger:** Oil pump relay toggle in BenchControlViewModel
**MVVM:** Simple confirm/cancel dialog
#### 3. 27V Warning (`WAlert27v`)
**Old behavior:** Warning when pump requires 27V power supply (some Ford variants). Alerts operator to switch power supply before proceeding.
**Trigger:** Pump selection when pump definition indicates 27V requirement
**MVVM:** Informational dialog with acknowledge button
### HIGH — Functional Dialogs
#### 4. Ford Unlock Progress (`WUnlocker`)
See `gap-ford-unlock-ui.md` for full spec.
#### 5. COM Port / K-Line Selection (`KlineIDSelection`)
**Old behavior:** Dropdown listing available FTDI devices by serial number. User selects which adapter to use for K-Line communication.
**Current gap:** `KwpService.DetectKLinePort()` auto-detects the first FTDI device. No UI for manual selection when multiple adapters are connected.
**Trigger:** K-Line connection attempt, or settings panel
**MVVM:** ComboBox of detected FTDI serial numbers + Connect button
#### 6. User Management (`WUsersManage`)
**Old behavior:** Add/remove users, change passwords. User list displayed in a grid.
**Current state:** `UserCheckDialog` handles auth only. `ConfigurationService.UpdateUsers()` exists but no UI invokes it for CRUD.
**Trigger:** Admin menu or settings panel
**MVVM:** DataGrid with add/edit/remove buttons. Hash or encrypt passwords before storage (see gap-missing-features.md).
#### 7. Full Settings Panel (`WConfigPanel`)
**Old behavior:** Comprehensive dialog for all settings: PID tuning, refresh rates, safety limits, encoder resolution, motor calibration, company info, log retention, language selection.
**Current state:** `BenchParamConfigView` covers bench CAN parameter editing only. All other settings require hand-editing config.xml.
**Trigger:** Settings button in toolbar
**MVVM:** Tabbed dialog (General, Safety, PID, Company, Sensors, Language)
### MEDIUM — Utility Dialogs
#### 8. Generic Confirmation (`WAskQuestion`)
**Old behavior:** Reusable yes/no dialog with custom message.
**Alternative:** WPF's built-in `MessageBox.Show` with `MessageBoxButton.YesNo`. Can be used directly in ViewModels via a dialog service abstraction. No custom dialog needed unless the app's visual theme demands it.
#### 9. Generic Alert (`WGenericAlert`)
**Old behavior:** Reusable info/warning dialog with custom message + icon.
**Alternative:** Same as above — `MessageBox.Show` covers this. Implement a custom one only if visual consistency matters.
### MEDIUM — Missing Controls
#### 10. Test Timer/Countdown (`TimerDisplay`)
**Old behavior:** Visual countdown during conditioning and measurement phases. Shows remaining seconds in large digital font.
**Current gap:** Phase progress is communicated via `VerboseMessage` text events but no dedicated countdown UI.
**Implementation:** Either a UserControl with large text binding, or integrate into `TestDisplayView` with a progress ring.
#### 11. Bit-Level Status Display (`BitDisplay` / `SingleBit`)
**Old behavior:** Grid of toggleable bits showing pump status word state. Each bit shows label + green/red indicator based on PumpStatusDefinition.
**Current state:** `StatusDisplayView` exists but may not render individual bits as toggle indicators.
**Implementation:** ItemsControl with BitDisplay item template (colored circle + label per bit).
#### 12. Searchable ComboBox (`FilteredComboBox`)
**Old behavior:** Dropdown with text filtering for pump selection (100+ pumps).
**Alternative:** Extended.Wpf.Toolkit's `AutoFilteredComboBox` may cover this. Or implement as a standard ComboBox with `IsTextSearchEnabled="True"` and `IsEditable="True"`.
### LOW — Display Controls
#### 13. Temperature Display Widget (`TempDisplayer`)
**Current state:** Temperature values are shown inline in the main UI. A dedicated widget is nice-to-have but not functionally missing.
#### 14. Graphical Result Display (`GraphicResultDisplay` / `CustomChart`)
**Current state:** `FlowmeterChartView` + `ReportChartRenderer` partially cover this. LiveChartsCore handles real-time visualization. The old custom chart control is superseded.
## Implementation Pattern
All dialogs should follow the existing MVVM pattern:
- `Views/Dialogs/XxxDialog.xaml` — pure XAML, no code-behind logic
- `ViewModels/Dialogs/XxxViewModel.cs``[ObservableProperty]`, `[RelayCommand]`
- Opened from parent ViewModel via a dialog service or `Window.ShowDialog()`
- Results returned via ViewModel property, not code-behind event args

View File

@@ -0,0 +1,117 @@
# Gap: Missing Features
## 1. Localization System (HIGH)
### Problem
The old app supported runtime Spanish/English switching via `R.LoadLanguage()` which swapped WPF `ResourceDictionary` files (`StringResources.xaml` for Spanish, `StringResourcesEN.xaml` for English). The new app has `AppSettings.Language` ("ESP"/"ENG") persisted but NOT used — all UI strings are hardcoded in English.
### Implementation approach
1. Create `Resources/StringResources.xaml` (Spanish) and `Resources/StringResources.en.xaml` (English) with keyed `sys:String` entries
2. Reference strings in XAML via `{DynamicResource key}` (not `StaticResource` — dynamic allows runtime swap)
3. Add `LanguageService` or extension method that merges the correct dictionary into `Application.Current.Resources`
4. Wire language toggle to `AppSettings.Language` change
5. Default resource dictionary should be Spanish (the primary user base)
### Scope
All user-facing text: labels, button text, dialog messages, status messages, report text, error messages. Roughly 200-300 strings.
## 2. User Credential Encryption (HIGH)
### Problem
Users stored as plaintext `user:password` pairs in `config.xml`. Default is `admin:admin`. Old system used AES-256 (Rijndael CBC, PBKDF2 1000 iterations) via `Encrypter.cs`.
### Recommended approach
Don't re-implement the old Rijndael scheme (it used obsolete primitives and weak iteration count). Instead:
1. **Hash passwords** with `Rfc2898DeriveBytes` using HMAC-SHA256, 600,000 iterations, random 16-byte salt
2. Store as `user:salt:hash` in config.xml
3. `ValidateUser()` computes hash of input password with stored salt and compares
4. Migration: on first load of old-format `user:password` entries, hash them and rewrite
This is more secure than the old encrypted-but-reversible approach. Passwords become irreversible.
### Files to modify
- `Models/BenchConfiguration.cs` — change `Users` property format
- `Services/Impl/ConfigurationService.cs``ValidateUser()`, `GetUsers()`, `UpdateUsers()`, `SaveSettings()`
## 3. KlineIDs Auto-Mapping (MEDIUM)
### Problem
Old system remembered K-Line ID → pump ID associations via `Settings.Default.KlineIDs` (comma-separated `klineID:pumpID` string). When a pump was connected and identified via K-Line, the system could auto-select it next time. New system has no equivalent.
### Implementation
1. Add `Dictionary<string, string> KlineIdMap` to `AppSettings`
2. Persist as `<KlineIDs>kline1:pump1,kline2:pump2</KlineIDs>` in config.xml
3. In `KwpService` or `PumpIdentificationViewModel`: after successful K-Line read, save the mapping
4. On pump connection: look up K-Line ID in map, auto-select pump if found
## 4. Test Phase Timer/Countdown Display (MEDIUM)
### Problem
During test execution, conditioning and measurement phases have timed durations (e.g., 10 sec conditioning, 30 sec measurement). The old system showed a visual countdown. The new system fires `VerboseMessage` events with text like "Conditioning: 8s remaining" but there's no dedicated countdown UI.
### Implementation
- Add countdown properties to `TestDisplayViewModel`: `RemainingSeconds`, `PhaseProgress` (0-1), `PhaseName`
- Fire `PhaseTimerTick` event from `BenchService` with remaining seconds
- Display as a circular progress indicator or large countdown text in `TestDisplayView`
## 5. QOver Zero-Flow Safety Check (HIGH — Safety)
### Problem
The old system had a safety check: if `QOver == 0` while RPM > 300 and oil pump is on, trigger emergency stop (leak/blockage detection). This is completely absent from the new codebase.
### Implementation
Add to `BenchService` or the ViewModel's bench monitoring loop:
```csharp
if (qOverValue == 0 && benchRpm > 300 && IsRelayOn(RelayNames.OilPump))
{
EmergencyStop();
_log.Error(LogId, "QOver zero-flow safety triggered: oil flow blocked while motor running");
}
```
Debounce for 2-3 seconds to avoid false positives during startup transients.
## 6. Alarm Bit Recording During Tests (HIGH)
### Problem
`PhaseDefinition` has `ErrorBits` list and `RecordErrorBit(int bit)` method, but no code in `BenchService` ever calls it. The old system tracked which alarm bits fired during each test phase for inclusion in the report.
### Implementation
In `BenchService.MeasurePhaseAsync()`, subscribe to alarm parameter changes. When an alarm bit transitions to active during a measurement phase, call `phase.RecordErrorBit(bit)`. The error bits are already rendered in the PDF report (PdfService checks `phase.ErrorBits`).
## 7. Per-Sample Real-Time UI Callback (MEDIUM)
### Problem
Old system fired `ITestParameterListener.OnValueUpdate` for every measurement sample, enabling live chart updates during test execution. New system silently collects samples with no per-sample event.
### Implementation
Add an event to `IBenchService`:
```csharp
event Action<string, double, double>? MeasurementSampled; // paramName, value, timestamp
```
Fire in `MeasurePhaseAsync` after each sample. ViewModel subscribes to update live charts.
## 8. Pump Parameter Zeroing Between Phases (MEDIUM)
### Problem
Old system called `StopPump()` between phases, which zeroed ME/FBKW/PreIn values. New system calls `SetRpm(0)` but does NOT zero pump injection parameters. This means the pump may continue injecting fuel between phases at the previous phase's setpoint.
### Implementation
Add a `StopPumpParameters()` method to `BenchService` that sets ME, FBKW, and PreIn to 0 and sends the CAN frame. Call it at the end of each phase (currently line 776 in BenchService.cs — after `SetRpm(0)`).
## 9. PDF Report Observations Section (LOW)
### Problem
Old report had an "Observaciones" free-text section at the bottom. New `IPdfService.GenerateReport()` has no observations parameter and no observations rendering.
### Implementation
1. Add `string? observations` parameter to `IPdfService.GenerateReport()`
2. Add a text section at the bottom of the PDF body in `PdfService`
3. Add an observations text box to `ReportDialog`/`ReportViewModel`
## 10. PDF Auto-Open After Generation (LOW)
### Problem
Old code called `Process.Start(filePath)` to open the generated PDF. New code returns the path but doesn't open it.
### Implementation
After `GenerateReport()` returns the path, call `Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })` in the ViewModel.

View File

@@ -0,0 +1,118 @@
# Gap: Pump Data Migration (Old CFG/TXT → New XML)
## Overview
The old system had 32 CFG files (Bosch PSG format), 117 test plan TXT files, and reference data files that were processed by `HerlicScripts/Program.cs` into an encrypted `data.herlic` pump database. The new system uses `pumps.xml` (plain XML). This document maps every old data field to its new equivalent and identifies what cannot be migrated.
## Source Files
- `old_source/HerlicScripts/CFGs/*.CFG` — 32 pump config files
- `old_source/HerlicScripts/TestPlansLargos/*.txt` — 117 test plan files
- `old_source/HerlicScripts/AngulosDeBloqueo.txt` — Lock angle reference (114 entries)
- `old_source/HerlicScripts/AvanceInyeccion.txt` — Injection advance CAN data
- `old_source/HerlicScripts/ListadoBombas.txt` — Master pump database (108 pumps)
- `old_source/HerlicScripts/PreDatos.txt` / `PosDatos.txt` — XML header/footer templates
- `old_source/HerlicScripts/Program.cs` — Migration tool source
## CFG Families
| Family | CFGs | Description | KENNUNG |
|--------|------|-------------|---------|
| Type 1 | T06102, T062xx (21 files) | PSG2/5-Opel | 0 |
| Type 2 | T15xxx, T31804 (7 files) | PSG5-PI with pre-injection | 2 or 10 |
| Type 3 | T18xxx (3 files) | PSG5-PI-Ford | 6 |
| Type 4 | T06301 (1 file) | PSG5-FIEONA | 1 |
## Field Mapping: CFG → pumps.xml
### Fully Migrated (present in new PumpDefinition model)
| CFG Field | New Model Property | Notes |
|-----------|--------------------|-------|
| `ID-SEND1` | `ParametersByName[me/FBKW/mepi].MessageId` | TX message ID |
| `ID-EMPF1` | `ParametersByName[Status].MessageId` | RX status ID |
| `ID-EMPF2` | `ParametersByName[RPM/Temp].MessageId` | RX diagnostics ID |
| `ID-EMPF3` | `ParametersByName[Empf3].MessageId` | RX self-test ID |
| `CAN-MESOLL` P1-P6 | `CanBusParameter.P1-P6` | Fuel quantity formula |
| `CAN-FB-KW` P1-P6 | `CanBusParameter.P1-P6` | FBKW injection angle |
| `CAN-KW-N` P1-P6 | `CanBusParameter.P1-P6` | RPM formula |
| `CAN-THYBR` P1-P6 | `CanBusParameter.P1-P6` | Temperature formula |
| `CAN-BAUDRATE` | `PumpDefinition.CanBaudrate` | 500 or 250 kBaud |
| Lock angles | `PumpDefinition.LockAngle` | From AngulosDeBloqueo.txt |
| Rotation direction | `PumpDefinition.Rotation` | left/right |
| Pre-injection flag | `PumpDefinition.HasPreInjection` | Type 2/3 pumps |
| Unlock type | `PumpDefinition.UnlockType` | 0/1/2 |
| Test plans | `PumpDefinition.Tests` | From test plan TXT files |
### Partially Migrated (structure exists but data incomplete)
| CFG Field | New Model | Gap |
|-----------|-----------|-----|
| `CAN-STATUS*` bit definitions | `PumpStatusDefinition` in status.xml | Only display info (bit, state, color, description) migrated. **Missing:** reaction codes (0=none, 1=abort, 2=warning, 3=log), special function codes, special parameters |
| `CAN-PSGTEST*` self-test bits | `PumpStatusDefinition` | Same gap as above — no reaction/function codes |
| `CAN-MESOLL-PI` / `CAN-PHIAD-PI` | `CanBusParameter` for mepi | Formula exists but only if pump HasPreInjection |
### NOT Migrated (no equivalent in new model)
| CFG Field | Purpose | Impact |
|-----------|---------|--------|
| `PAS-EPROM-LES` | EPROM read password (4 bytes + scope) | KWP passwords now handled in KwpService code, not per-pump config. Currently only 3 hardcoded password versions (V1/V2/V3-V4). If a pump needs a different password, it can't be configured. |
| `PAS-EEPROM-LES/SCHREIB` | EEPROM read/write passwords | Same as above |
| `PAS-RAM-SCHREIB` | RAM write password | Same |
| `CAN-PASSWORT-EWS` | EWS immobilizer password | Always 0xFFFF in all CFGs (unused in practice) |
| `BIP-STATUS*` | Needle-motion detection (8 definitions with HEX pattern matching) | Entirely absent. Pre-injection pumps use BIP to verify injection timing. |
| `EMPFINDLICHKEIT` | Flow regulator sensitivity (dAD, measurement time, tolerance, max sensitivity, regulator current) | Not stored per-pump. Bench-level config only. |
| `EMPFINDLICHKEIT-2` | Flow regulator part 2 (dFM-Empf, Fehl-zul, Messzeit) | Same |
| `ADR-MV-SCHALTZEIT` | Solenoid valve switching time RAM address | Diagnostic addresses not per-pump |
| `ADR-F_TK_TE_W` | Temperature compensation switch address | Same |
| `ADR-TK_AT_W` | Dynamic temp compensation switch | Same |
| `ADR-SV-SOLL` / `ADR-SV-IST` | SV actuator target/actual addresses | Same |
| `ADR-TO_MV_600_HE` | MV pre-control value address | Same |
| `ADR-MV-T-ABGLEICH*` | MV time adjustment addresses | Same |
| `ADR-S_BIP_HW_UW` | BIP status address | Same |
| `ADR-ID-EMPF2-SENDEN` | EMPF2 auto-send control address | Same |
| `ADR-REL-CAN-SRC` | CAN source relative address | Same |
| `ADR-LERN-VERF` | Learning procedure switch | Same |
| `ADR-RESET` | PSG auto-reset address | Same |
| `BASISADR` | EEPROM base address table | Same |
| `CAN-BUS-KENNUNG` | Protocol variant code (0/1/2/6/10) | Derived implicitly from CAN IDs and UnlockType |
| `PHI-LUECKE-SYNCH` | Sync pulse angle offset | Not stored |
| `ATS-SERVICE` | Ford service CAN ID | Not stored separately |
| `KW_SIM_UMSCHALTUNG` | SV control/regulation switching debounce | Not stored |
| `MSG-KONFIGURATION` | Message config byte | Not stored |
| WTSS formulas (`N-NW`, `MESOLL`, `PHIAD`, `FB-NW`, `FB-KW`, `PHI1`, `FB-MASS`, `THYBR-OFFSET`) | Secondary scaling for WTSS transfer | Not stored |
## Status Bit Reaction Codes — Impact Assessment
The old CFGs defined per-bit reaction behavior:
- **Reaction 0:** No action (display only)
- **Reaction 1:** Abort test immediately
- **Reaction 2:** Show warning, continue
- **Reaction 3:** Log only
The new `PumpStatusDefinition` stores only display info (description + color). There is no mechanism to:
- Abort a test when a specific status bit asserts
- Show a warning for specific bits during test execution
- Differentiate between critical and informational bits per pump model
**Recommendation:** Add a `Reaction` property (int, 0-3) to `StatusBitValue` in `PumpStatusDefinition.cs`. Wire it into `BenchService` test execution to check active status bits against their reaction codes per phase.
## BIP-STATUS — Impact Assessment
BIP (Brennraumindikator-Prüfung / combustion indicator test) is a per-cylinder needle-motion detection system used on pre-injection VP44 pumps. The old system defined 6-7 BIP status conditions with HEX pattern matching (e.g., "BIP detected OK", "No BIP in capture window", "BIP deviation too large").
**Impact:** Without BIP status monitoring, pre-injection pump testing lacks validation that the injection nozzle is actually opening correctly. This is a diagnostic gap for Type 2 (T15xxx) and Type 3 (T18xxx) pumps.
**Recommendation:** Add a `BipStatusDefinition` model class and integrate BIP pattern matching into the test execution flow for pre-injection pumps.
## Test Plan TXT Format
Each test plan file follows this structure:
```
[TestName] [TestType] [nPhases] [ConditioningTime] [MeasurementTime] [MeasurementsPerSec]
[PhaseName] [RPM] [me] [FBKW] [ME_PI] [T-In] [QOver] [Tolerance_me] [Tolerance_FBKW] ...
```
108 pump IDs have corresponding test plan files. These should already be in `pumps.xml` if the old `Program.cs` migration was run. Verify coverage by comparing pump IDs in `pumps.xml` against `ListadoBombas.txt`.
## Migration Tool Approach
If re-migration is needed:
1. Port the parsing logic from `old_source/HerlicScripts/Program.cs` into a standalone console app
2. Read each CFG file using the pipe-delimited parser (`GetParamCFG`)
3. Read test plans from TXT files
4. Read lock angles from `AngulosDeBloqueo.txt`
5. Generate `pumps.xml` and `status.xml` in the new schema
6. Run once, commit the generated files, do not ship the migration tool