diff --git a/CLAUDE.md b/CLAUDE.md index 1e84a7e..691efc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (P1–P6 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 P1–P6 (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) +- P1–P6 transfer function can produce Infinity/NaN (no denominator check) +- CAN parameter byteh/bytel not bounds-checked to 0–7 + ## Known debt Do not worsen before addressing deliberately. See memory file `project_known_debt.md` for details. + +Additional debt items: +- `KW1281Connection` allocates `List` 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. diff --git a/HC_APTBS.csproj b/HC_APTBS.csproj index 183ee02..ab17ffc 100644 --- a/HC_APTBS.csproj +++ b/HC_APTBS.csproj @@ -43,6 +43,11 @@ + + + + + diff --git a/Models/BenchConfiguration.cs b/Models/BenchConfiguration.cs index e884e14..f6fa136 100644 --- a/Models/BenchConfiguration.cs +++ b/Models/BenchConfiguration.cs @@ -166,14 +166,5 @@ namespace HC_APTBS.Models /// Active alarm definitions loaded from alarms.xml. public List Alarms { get; set; } = new(); - // ── Clients ─────────────────────────────────────────────────────────── - - /// Client/operator database, keyed by name (sorted). - public SortedDictionary Clients { get; set; } = new(); - - // ── Pump IDs ────────────────────────────────────────────────────────── - - /// List of known pump identifiers available in the database. - public List PumpIds { get; set; } = new(); } } diff --git a/Models/CanBusParameter.cs b/Models/CanBusParameter.cs index 903e7e4..8034542 100644 --- a/Models/CanBusParameter.cs +++ b/Models/CanBusParameter.cs @@ -225,6 +225,40 @@ namespace HC_APTBS.Models return double.Parse(value.Replace(',', '.'), CultureInfo.InvariantCulture); } + /// + /// Serialises this parameter to an XML element using the pump-param format + /// (busid, p1–p6, send, disableparams). + /// Used when persisting pump definitions to pumps.xml. + /// + 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; + } + /// Serialises this parameter to an XML element for persistence in bench.xml. public XElement ToXml() { diff --git a/Resources/Images/default_logo.png b/Resources/Images/default_logo.png new file mode 100644 index 0000000..411ce91 Binary files /dev/null and b/Resources/Images/default_logo.png differ diff --git a/Services/IConfigurationService.cs b/Services/IConfigurationService.cs index a35892a..63e4f0b 100644 --- a/Services/IConfigurationService.cs +++ b/Services/IConfigurationService.cs @@ -58,6 +58,11 @@ namespace HC_APTBS.Services /// PumpStatusDefinition? LoadPumpStatus(int statusId); + // ── Alarms ──────────────────────────────────────────────────────────────── + + /// Persists alarm definitions to alarms.xml. + void SaveAlarms(); + // ── Sensors ─────────────────────────────────────────────────────────────── /// Saves updated sensor calibration data to sensors.xml. diff --git a/Services/Impl/ConfigurationService.cs b/Services/Impl/ConfigurationService.cs index 498e20f..d37aff1 100644 --- a/Services/Impl/ConfigurationService.cs +++ b/Services/Impl/ConfigurationService.cs @@ -190,7 +190,84 @@ namespace HC_APTBS.Services.Impl /// 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 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 (pump CAN params use legacy P1–P6 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 ── + 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 ───────────────────────────────────────── + + /// + 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 ─────────────────────────────────────────────── /// @@ -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); diff --git a/Services/Impl/PdfService.cs b/Services/Impl/PdfService.cs index 6356160..3f9ff52 100644 --- a/Services/Impl/PdfService.cs +++ b/Services/Impl/PdfService.cs @@ -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 { /// - /// Generates PDF test reports using QuestPDF. + /// Generates professional PDF test reports using QuestPDF. /// /// /// Report layout: /// - /// Header: company logo, company name, date, operator, client. + /// Header (every page): company logo, company name, date, operator, client, report title. /// Pump identification table: ID, serial, model, rotation, lock angle. /// K-Line ECU data block: model reference, DFI, SW versions, fault codes. - /// Per-test results section: one table per enabled phase showing measured - /// average vs. target ± tolerance and a pass/fail indicator. - /// Footer: page numbers. + /// Overall verdict section: large PASS/FAIL badge with summary statistics. + /// Per-test results: table with pass/fail rows, followed by measurement charts + /// showing sample values against target ± tolerance bands. + /// Footer (every page): software attribution and page numbers. /// /// /// public sealed class PdfService : IPdfService { private readonly IConfigurationService _config; + private readonly byte[]? _defaultLogo; /// Provides company name, logo path, and report settings. 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 ──────────────────────────────────────────────────────────────── + /// Renders the page header: logo, company info, date/operator/client, title. 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 ──────────────────────────────────────────────────────────────── + + /// Renders the page footer: divider, attribution, and page numbers. + 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 ─────────────────────────────────────────────────────────────── + /// Composes the full report body: pump info, ECU data, verdict, test sections. 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 ─────────────────────────────────────────────────────── + /// Renders the pump identification table with alternating row stripes. 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 ────────────────────────────────────────────────────────── + /// Renders the K-Line ECU data table with alternating row stripes. 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 ─────────────────────────────────────────────────────── + + /// Renders the overall test result badge with summary statistics. + 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 ────────────────────────────────────────────────── + /// Renders a single test: results table followed by measurement charts. 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)); + }); + } + + /// Renders the pass/fail results table for one test. + 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); + } + } + }); + } + + /// Renders measurement charts for each parameter that has sample data. + 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 ─────────────────────────────────────────────────────────────── + /// Sanitises a file name by replacing invalid characters with underscores. private static string SanitiseFileName(string name) { foreach (char c in Path.GetInvalidFileNameChars()) diff --git a/Services/Impl/ReportChartRenderer.cs b/Services/Impl/ReportChartRenderer.cs new file mode 100644 index 0000000..23648d6 --- /dev/null +++ b/Services/Impl/ReportChartRenderer.cs @@ -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 +{ + /// + /// Generates SVG chart markup for embedding in QuestPDF reports via the + /// .Svg() API. All rendering is done as raw SVG strings — no + /// SkiaSharp dependency required at PDF-generation time. + /// + 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 ──────────────────────────────────────────────────────── + + /// + /// 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. + /// + /// Chart width in points. + /// Chart height in points. + /// Individual timestamped measurements. + /// Expected / setpoint value. + /// Acceptable deviation (±). + /// Computed average of all samples. + /// Whether the parameter passed the tolerance check. + /// Chart title label (e.g. "1000 RPM 40°C — QDelivery"). + /// A complete SVG document string. + public static string RenderMeasurementChart( + float width, + float height, + IReadOnlyList 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($""); + + // ── 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($""); + } + } + + // ── 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(""); + return sb.ToString(); + } + + /// + /// Produces an SVG string depicting a large PASS/FAIL verdict badge. + /// + 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($""); + + string text = passed ? "PASS" : "FAIL"; + float fontSize = height * 0.45f; + sb.AppendLine($"{text}"); + + sb.AppendLine(""); + return sb.ToString(); + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /// Maps a data value to the Y coordinate (inverted — higher values = lower Y). + private static float ValueToY(double value, float plotTop, float plotH, double yMin, double yRange) + => plotTop + (float)((1.0 - (value - yMin) / yRange) * plotH); + + /// Renders a placeholder SVG when fewer than 2 samples are available. + 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(""); + return sb.ToString(); + } + + // ── SVG primitives ──────────────────────────────────────────────────── + + /// SVG document header. + private static string SvgHeader(float w, float h) + => $""; + + /// SVG rect element with optional fill and/or stroke. + private static string Rect(float x, float y, float w, float h, + string? fill, string? stroke, float strokeWidth) + { + var sb = new StringBuilder(); + sb.Append($""); + return sb.ToString(); + } + + /// SVG line element with optional dash array. + private static string Line(float x1, float y1, float x2, float y2, + string stroke, float strokeWidth, string? dashArray) + { + var sb = new StringBuilder(); + sb.Append($""); + return sb.ToString(); + } + + /// SVG text element. + private static string Text(float x, float y, string content, + string fill, float fontSize, string anchor) + => $"{content}"; + + /// Formats a float with invariant culture (dot decimal separator). + private static string F(float v) => v.ToString("F1", CultureInfo.InvariantCulture); + + /// Converts an SKColor to an SVG hex colour string. + private static string Col(SkiaSharp.SKColor c) => $"#{c.Red:X2}{c.Green:X2}{c.Blue:X2}"; + + /// Converts an SKColor with alpha to an SVG rgba() string. + private static string ColAlpha(SkiaSharp.SKColor c) + => $"rgba({c.Red},{c.Green},{c.Blue},{(c.Alpha / 255.0).ToString("F2", CultureInfo.InvariantCulture)})"; + + /// Escapes XML special characters in text content. + private static string EscapeXml(string s) + => s.Replace("&", "&").Replace("<", "<").Replace(">", ">") + .Replace("\"", """).Replace("\u2014", "-"); + } +} diff --git a/Services/Impl/ReportTheme.cs b/Services/Impl/ReportTheme.cs new file mode 100644 index 0000000..bdcd088 --- /dev/null +++ b/Services/Impl/ReportTheme.cs @@ -0,0 +1,106 @@ +using SkiaSharp; + +namespace HC_APTBS.Services.Impl +{ + /// + /// 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. + /// + internal static class ReportTheme + { + // ── Colour palette (hex strings for QuestPDF, SKColor for charts) ───── + + /// Dark navy for section header bars. + public const string HeaderNavy = "#1B2A4A"; + + /// Secondary grey for sub-headers and muted text. + public const string HeaderGrey = "#4A4A4A"; + + /// Accent blue for highlights. + public const string AccentBlue = "#2E5090"; + + /// Pass result — dark green text. + public const string PassGreen = "#2E7D32"; + + /// Pass result — light green background. + public const string PassGreenLight = "#E8F5E9"; + + /// Fail result — dark red text. + public const string FailRed = "#C62828"; + + /// Fail result — light red background. + public const string FailRedLight = "#FFEBEE"; + + /// Alternating table row stripe. + public const string TableAltRow = "#F5F7FA"; + + /// Thin divider lines. + public const string DividerLine = "#BDBDBD"; + + /// Error/warning row background. + public const string WarningBg = "#FFF3E0"; + + /// Error/warning text. + public const string WarningText = "#E65100"; + + // ── Chart-specific SKColors ─────────────────────────────────────────── + + /// Semi-transparent blue tolerance band. + public static readonly SKColor ToleranceBand = new(46, 80, 144, 50); + + /// Amber dashed target line. + public static readonly SKColor TargetLine = new(255, 111, 0); + + /// Blue data polyline. + public static readonly SKColor SampleLine = new(21, 101, 192); + + /// Light grey chart gridlines and border. + public static readonly SKColor ChartGrid = new(224, 224, 224); + + /// Chart background. + public static readonly SKColor ChartBackground = SKColors.White; + + /// Green average line (pass). + public static readonly SKColor AvgPassLine = new(46, 125, 50); + + /// Red average line (fail). + public static readonly SKColor AvgFailLine = new(198, 40, 40); + + /// Chart axis label colour. + public static readonly SKColor AxisLabel = new(97, 97, 97); + + // ── Font sizes (points) ─────────────────────────────────────────────── + + /// Report title. + public const float TitleSize = 16f; + + /// Section header text (e.g. "PUMP IDENTIFICATION"). + public const float SectionHeaderSize = 11f; + + /// Body / table cell text. + public const float BodySize = 9f; + + /// Small captions and chart labels. + public const float CaptionSize = 7f; + + /// Footer text. + public const float FooterSize = 7f; + + // ── Spacing (points) ───────────────────────────────────────────────── + + /// Gap between major sections. + public const float SectionGap = 10f; + + /// Gap between sub-sections (e.g. chart caption). + public const float SubsectionGap = 6f; + + /// Table cell padding. + public const float CellPad = 3f; + + // ── Chart dimensions (points) ───────────────────────────────────────── + + /// Chart render height inside the PDF. + public const float ChartHeight = 160f; + } +} diff --git a/docs/gap-config-validation.md b/docs/gap-config-validation.md new file mode 100644 index 0000000..24357b5 --- /dev/null +++ b/docs/gap-config-validation.md @@ -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 `` 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` | 0–7 | Clamp or reject + log | +| `RefreshCanBusReadMs` | `LoadSettings` | >= 1 | Floor at 1 | +| `RefreshPumpParamsMs` | `LoadSettings` | >= 1 | Floor at 1 | +| `SecurityRpmLimit` | `LoadSettings` | 100–5000 | Clamp | +| `MaxPressureBar` | `LoadSettings` | 1–100 | Clamp | +| `PidP, PidI, PidD` | `LoadSettings` | >= 0 | Floor at 0 | +| `Relay.Bit` | `ParseRelayElement` | 0–63 | Reject + log | +| `SensorConfiguration.MaxVolt - MinVolt` | `GetValueFromRaw` | != 0 | Return 0 + log | +| P1–P6 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 +... +... +``` +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 diff --git a/docs/gap-ford-unlock-ui.md b/docs/gap-ford-unlock-ui.md new file mode 100644 index 0000000..8be177d --- /dev/null +++ b/docs/gap-ford-unlock-ui.md @@ -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. diff --git a/docs/gap-missing-dialogs.md b/docs/gap-missing-dialogs.md new file mode 100644 index 0000000..b63dde0 --- /dev/null +++ b/docs/gap-missing-dialogs.md @@ -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 diff --git a/docs/gap-missing-features.md b/docs/gap-missing-features.md new file mode 100644 index 0000000..47d7aca --- /dev/null +++ b/docs/gap-missing-features.md @@ -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 KlineIdMap` to `AppSettings` +2. Persist as `kline1:pump1,kline2:pump2` 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? 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. diff --git a/docs/gap-pump-data-migration.md b/docs/gap-pump-data-migration.md new file mode 100644 index 0000000..df262c3 --- /dev/null +++ b/docs/gap-pump-data-migration.md @@ -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