feat: developer tools page, auto-test orchestrator, BIP display, tests redesign
Bundles several feature streams that have been iterating on the working tree: - Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the identification card, manual KWP write + transaction log, ROM/EEPROM dump card with progress banner and completion message, persisted custom-commands library, persisted EEPROM passwords library. New service primitives: IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync. Persistence mirrors the Clients XML pattern in two new files (custom_commands.xml, eeprom_passwords.xml). - Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and progress dialog VM, gated on dashboard alarms. - BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at 0x0106 via IKwpService.ReadBipStatusAsync; status definitions in BipStatusDefinition. - Tests page redesign: TestSectionCard + PhaseTileView replacing the old TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/ TestSectionView controls and their VMs. - Pump command sliders: Fluent thick-track style with overhang thumb, click-anywhere-and-drag, mouse-wheel adjustment. - Window startup: app.manifest declares PerMonitorV2 DPI awareness, MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and maximizes there (after the hook is in place) so the app fits the work area exactly on any display configuration. - Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and dump-functions reference docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
297
docs/dump functions.txt
Normal file
297
docs/dump functions.txt
Normal file
@@ -0,0 +1,297 @@
|
||||
public bool DumpRom(int firstAddress, int lastAddress)
|
||||
{
|
||||
const ushort BLOCK_SIZE = 0x0100; // 256 bytes per block
|
||||
const byte MAX_CHUNK = 13; // protocol limit
|
||||
const bool DEBUG = true;
|
||||
|
||||
if (firstAddress < 0 || lastAddress < 0 || lastAddress < firstAddress)
|
||||
{
|
||||
Logger.Write("ReadRomEepromBlock: invalid address range.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Number of 0x0100 blocks touched by the requested range
|
||||
int numBlocks = ((lastAddress - firstAddress) / BLOCK_SIZE) + 1;
|
||||
|
||||
// Fresh buffer for this dump
|
||||
var packets2 = new List<byte>(numBlocks * BLOCK_SIZE);
|
||||
|
||||
Logger.Write($"ROM EEPROM BIN dump start: 0x{firstAddress:X4} .. 0x{lastAddress:X4}");
|
||||
|
||||
try
|
||||
{
|
||||
int addr = firstAddress;
|
||||
|
||||
while (addr <= lastAddress)
|
||||
{
|
||||
ushort blockBase = (ushort)(addr & 0xFF00);
|
||||
|
||||
if (DEBUG)
|
||||
Logger.Write($"===== ROM EEPROM block base 0x{blockBase:X4} =====");
|
||||
|
||||
// Last address we may read inside this current 0x0100 block
|
||||
int blockEnd = blockBase + BLOCK_SIZE - 1;
|
||||
|
||||
// Do not read past:
|
||||
// 1) requested lastAddress
|
||||
// 2) current block end
|
||||
int maxReadableAbs = Math.Min(lastAddress, blockEnd);
|
||||
|
||||
while (addr <= maxReadableAbs)
|
||||
{
|
||||
int remaining = maxReadableAbs - addr + 1;
|
||||
byte len = (byte)Math.Min(MAX_CHUNK, remaining);
|
||||
|
||||
var chunk = ReadRomEeprom((ushort)addr, len);
|
||||
|
||||
// Safety: if the ECU returns fewer bytes than requested, don't infinite-loop
|
||||
if (chunk == null || chunk.Count == 0)
|
||||
{
|
||||
Logger.Write($"ReadRomEeprom returned 0 bytes at 0x{addr:X4}");
|
||||
return false;
|
||||
}
|
||||
|
||||
packets2.AddRange(chunk);
|
||||
|
||||
if (DEBUG)
|
||||
//Logger.Write($"{addr:X4}: " + string.Join(" ", chunk.Select(b => b.ToString("X2"))));
|
||||
|
||||
// Advance by what was actually received
|
||||
addr += chunk.Count;
|
||||
}
|
||||
|
||||
// Move to next block if still not finished
|
||||
if (addr <= lastAddress)
|
||||
{
|
||||
addr = blockBase + BLOCK_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
KeepAlive();
|
||||
|
||||
string fileName = $"rom_eeprom_dump_{firstAddress:X4}-{lastAddress:X4}.bin";
|
||||
File.WriteAllBytes(fileName, packets2.ToArray());
|
||||
|
||||
Logger.Write($"ROM EEPROM BIN dump done: {packets2.Count} bytes -> {fileName}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Write($"ReadRomEepromBlock failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public bool DumpEeprom(int startAddress, int endAddress)
|
||||
{
|
||||
const ushort BLOCK_SIZE = 0x0100; // logical block stride
|
||||
const ushort VALID_BYTES_PER_BLOCK = 0x00C0; // EEPROM valid region: 0x0000..0x00BF
|
||||
const byte MAX_CHUNK = 13; // protocol limit / safe chunk size
|
||||
|
||||
if (startAddress < 0 || endAddress < 0 || endAddress < startAddress)
|
||||
{
|
||||
Logger.Write("ReadEepromBlock: invalid address range.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fresh buffer for this dump
|
||||
var packets2 = new List<byte>();
|
||||
|
||||
Logger.Write($"EEPROM BIN dump start: 0x{startAddress:X4} .. 0x{endAddress:X4}");
|
||||
|
||||
try
|
||||
{
|
||||
// Walk absolute address range, but only read valid EEPROM bytes inside each 0x0100 block
|
||||
int addr = startAddress;
|
||||
|
||||
while (addr <= endAddress)
|
||||
{
|
||||
ushort blockBase = (ushort)(addr & 0xFF00); // start of current 0x0100 block
|
||||
ushort offsetInBlock = (ushort)(addr & 0x00FF);
|
||||
|
||||
// If current address is outside valid EEPROM region of this block, skip to next block
|
||||
if (offsetInBlock >= VALID_BYTES_PER_BLOCK)
|
||||
{
|
||||
addr = blockBase + BLOCK_SIZE;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Last valid absolute address inside this block
|
||||
int blockValidEndAbs = blockBase + VALID_BYTES_PER_BLOCK - 1;
|
||||
|
||||
// Do not read past:
|
||||
// 1) requested endAddress
|
||||
// 2) valid EEPROM end within this block
|
||||
int maxReadableAbs = Math.Min(endAddress, blockValidEndAbs);
|
||||
|
||||
while (addr <= maxReadableAbs)
|
||||
{
|
||||
int remaining = maxReadableAbs - addr + 1;
|
||||
byte len = (byte)Math.Min(MAX_CHUNK, remaining);
|
||||
|
||||
var chunk = ReadEeprom((ushort)addr, len);
|
||||
|
||||
if (chunk == null || chunk.Count == 0)
|
||||
{
|
||||
Logger.Write($"ReadEeprom returned 0 bytes at 0x{addr:X4}");
|
||||
return false;
|
||||
}
|
||||
|
||||
packets2.AddRange(chunk);
|
||||
|
||||
float progress = 1.0f * (addr - startAddress) / (endAddress - startAddress);
|
||||
Logger.Write($"Progress{progress:P1}");
|
||||
//Logger.Write(string.Join(" ", chunk.Select(b => $"0x{b:X2}")));
|
||||
|
||||
// Safety: advance by what was actually received
|
||||
addr += chunk.Count;
|
||||
}
|
||||
|
||||
// Move to next block if needed
|
||||
if (addr <= endAddress)
|
||||
{
|
||||
addr = (blockBase + BLOCK_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
KeepAlive();
|
||||
|
||||
string fileName = $"eeprom_dump_{startAddress:X4}-{endAddress:X4}.bin";
|
||||
File.WriteAllBytes(fileName, packets2.ToArray());
|
||||
|
||||
Logger.Write($"EEPROM BIN dump done: {packets2.Count} bytes -> {fileName}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Write($"ReadEepromBlock failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public bool DumpAllEeprom()
|
||||
{
|
||||
int startAddress = 0x0000;
|
||||
int endAddress = 0x0000; //0x00BF
|
||||
const ushort VALID_BYTES_PER_BLOCK = 0x00C0; // EEPROM valid region: 0x0000..0x00BF
|
||||
const byte MAX_CHUNK = 13; // protocol limit / safe chunk size
|
||||
|
||||
if (startAddress < 0 || endAddress < 0 || endAddress < startAddress)
|
||||
{
|
||||
Logger.Write("ReadEepromBlock: invalid address range.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fresh buffer for this dump
|
||||
var packets2 = new List<byte>();
|
||||
|
||||
Logger.Write($"EEPROM BIN dump start: 0x{startAddress:X4} .. 0x{endAddress:X4}");
|
||||
|
||||
try
|
||||
{
|
||||
// Walk absolute address range, but only read valid EEPROM bytes inside each 0x0100 block
|
||||
int addr = startAddress;
|
||||
|
||||
while (addr <= endAddress)
|
||||
{
|
||||
ushort blockBase = (ushort)(addr & 0xFF00); // start of current 0x0100 block
|
||||
ushort offsetInBlock = (ushort)(addr & 0x00FF);
|
||||
|
||||
// Last valid absolute address inside this block
|
||||
int blockValidEndAbs = blockBase + VALID_BYTES_PER_BLOCK - 1;
|
||||
|
||||
// Do not read past:
|
||||
// 1) requested endAddress
|
||||
// 2) valid EEPROM end within this block
|
||||
int maxReadableAbs = Math.Min(endAddress, blockValidEndAbs);
|
||||
|
||||
while (addr <= maxReadableAbs)
|
||||
{
|
||||
int remaining = maxReadableAbs - addr + 1;
|
||||
byte len = (byte)Math.Min(MAX_CHUNK, remaining);
|
||||
|
||||
var chunk = ReadEeprom((ushort)addr, len);
|
||||
|
||||
if (chunk == null || chunk.Count == 0)
|
||||
{
|
||||
Logger.Write($"ReadEeprom returned 0 bytes at 0x{addr:X4}");
|
||||
return false;
|
||||
}
|
||||
|
||||
packets2.AddRange(chunk);
|
||||
|
||||
float progress = 1.0f * (addr - startAddress) / (endAddress - startAddress);
|
||||
Logger.Write($"Progress{progress:P1}");
|
||||
//Logger.Write(string.Join(" ", chunk.Select(b => $"0x{b:X2}")));
|
||||
|
||||
// Safety: advance by what was actually received
|
||||
addr += chunk.Count;
|
||||
}
|
||||
|
||||
}
|
||||
packets2.AddRange(new byte[16]);
|
||||
packets2.AddRange(Enumerable.Repeat((byte)0xFF, 7));
|
||||
|
||||
var oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x00, 0x82, 0x33 });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
var newbytes = oemzone[0].Body;
|
||||
newbytes.RemoveRange(0,4);
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
/*oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x00, 0x00, 0x00 });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x00, 0x9F, 0xFF });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x00, 0xD7, 0x01 });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x00, 0xFF, 0xFC });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
packets2.AddRange(newbytes);*/
|
||||
|
||||
oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x6A, 0x89 });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
newbytes.RemoveRange(0, 4);
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x02, 0x2E, 0x10 });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
newbytes.RemoveRange(0, 4);
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
newbytes.RemoveRange(0, 4);
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
oemzone = SendCustom(new List<byte> { 0x18, 0x00, 0x04, 0xC7, 0xAE });
|
||||
oemzone = oemzone.Where(b => !b.IsAckNak).ToList();
|
||||
newbytes = oemzone[0].Body;
|
||||
newbytes.RemoveRange(0, 4);
|
||||
|
||||
packets2.AddRange(newbytes);
|
||||
|
||||
KeepAlive();
|
||||
|
||||
string fileName = $"eeprom_dump_{startAddress:X4}-{0x00FF:X4}.bin";
|
||||
File.WriteAllBytes(fileName, packets2.ToArray());
|
||||
|
||||
Logger.Write($"EEPROM BIN dump done: {packets2.Count} bytes -> {fileName}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Write($"ReadEepromBlock failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
58
docs/gotcha-oil-pump-dialog-race.md
Normal file
58
docs/gotcha-oil-pump-dialog-race.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Gotcha: Oil-pump confirmation dialog vs. `RefreshFromTick` race
|
||||
|
||||
## Symptom
|
||||
On the Tests page, pressing **Start Test** shows the oil-pump leak-check dialog. After the operator clicks **Accept**, the tests **do not start** — the operator has to press **Start Test** a second time. The second press works.
|
||||
|
||||
## Why it happens
|
||||
`BenchControlViewModel.OnIsOilPumpOnChanged` uses `dlg.ShowDialog()`, which runs a **nested dispatcher message pump** on the UI thread. While that pump is draining, the `MainViewModel` refresh timer keeps ticking and calls `BenchControlViewModel.RefreshFromTick()`, which reads the relay state from config and writes it back into the `_isOilPumpOn` backing field:
|
||||
|
||||
```csharp
|
||||
bool relayOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
|
||||
if (_isOilPumpOn != relayOn)
|
||||
{
|
||||
_isOilPumpOn = relayOn;
|
||||
OnPropertyChanged(nameof(IsOilPumpOn));
|
||||
}
|
||||
```
|
||||
|
||||
The ordering on the first press is:
|
||||
|
||||
1. `IsOilPumpOn = true` — setter writes backing field to `true`, then calls `OnIsOilPumpOnChanged`.
|
||||
2. Inside the partial: `dlg.ShowDialog()` blocks. **`SetRelay` has not been called yet, so `relay.State` is still `false`.**
|
||||
3. A refresh tick fires during the nested pump. `RefreshFromTick` sees `_isOilPumpOn (true) != relayOn (false)` and **clobbers `_isOilPumpOn` back to `false`**.
|
||||
4. Operator clicks Accept. `ShowDialog` returns. `_bench.SetRelay(OilPump, true)` finally runs and commits `relay.State = true`.
|
||||
5. `OnIsOilPumpOnChanged` returns — but `_isOilPumpOn` is still `false` from step 3.
|
||||
6. The caller (`TestsPageViewModel.StartTestAsync`) checks `if (!Root.BenchControl.IsOilPumpOn) return;` — guard trips, **test never starts**.
|
||||
|
||||
On the second press, `relay.State` is already `true`, so `RefreshFromTick` is a no-op during the second dialog and the flow completes.
|
||||
|
||||
## The fix
|
||||
After `SetRelay` commits the real state at the bottom of `OnIsOilPumpOnChanged`, re-assert the backing field:
|
||||
|
||||
```csharp
|
||||
_bench.SetRelay(RelayNames.OilPump, value);
|
||||
|
||||
if (_isOilPumpOn != value)
|
||||
{
|
||||
_isOilPumpOn = value;
|
||||
OnPropertyChanged(nameof(IsOilPumpOn));
|
||||
}
|
||||
```
|
||||
|
||||
Writing through the backing field (not the setter) avoids re-triggering the confirmation dialog.
|
||||
|
||||
## General lesson — nested message pumps
|
||||
**Any `ShowDialog()` call is a nested dispatcher pump.** While it blocks, timers, CAN callbacks marshalled to the UI thread, and property-change handlers keep running. Mutable state that other handlers may "correct" based on transient external readings can be rewritten under you before your synchronous code resumes. When mixing a modal dialog with a periodic state-sync task:
|
||||
|
||||
- Either **suspend the sync task** while the dialog is open, or
|
||||
- **Re-assert local state after the dialog returns** once the ground truth (relay, register, etc.) has actually been committed.
|
||||
|
||||
Symptoms of this class of bug:
|
||||
- An operation "works the second time but not the first"
|
||||
- A property setter appears to silently revert
|
||||
- A guard on a property right after a dialog accept evaluates the opposite of what the user chose
|
||||
|
||||
## Files involved
|
||||
- [ViewModels/BenchControlViewModel.cs](../ViewModels/BenchControlViewModel.cs) — `OnIsOilPumpOnChanged`, `RefreshFromTick`
|
||||
- [ViewModels/Pages/TestsPageViewModel.cs](../ViewModels/Pages/TestsPageViewModel.cs) — `StartTestAsync` guard that exposed the race
|
||||
- [Services/Impl/BenchService.cs](../Services/Impl/BenchService.cs) — `SetRelay` (synchronous update of `relay.State` + async CAN transmit)
|
||||
58
docs/gotcha-pump-change-ui-jank.md
Normal file
58
docs/gotcha-pump-change-ui-jank.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Gotcha: `OnPumpChanged` UI-thread jank — deferred fixes
|
||||
|
||||
## Status
|
||||
**Deferred.** These two issues cause a small but measurable hitch on the UI thread during manual pump selection. For pumps without an immobilizer unlocker the jank is not perceptible enough to justify the work. Document kept so the cost is understood if we ever need to address it (e.g. if disk I/O gets slower, pump catalogs grow, or the jank stacks with a future UI change).
|
||||
|
||||
The separate unlocker-pump delay issue was fixed independently by making the unlock state observer event-driven via `CanBusParameter.ValueChanged`.
|
||||
|
||||
## Issue 1 — `ConfigurationService.LoadPumpStatus` does disk I/O on the UI thread
|
||||
|
||||
### Symptom
|
||||
On pump change, up to ~20–200 ms of UI thread blocking (worst case on cold cache or spinning disk) before `OnPumpChanged` returns. Two XML parses per pump change.
|
||||
|
||||
### Why it happens
|
||||
[`MainViewModel.OnPumpChanged`](../ViewModels/MainViewModel.cs) calls `_config.LoadPumpStatus(...)` twice (once for the `Status` word, once for `Empf3`) while building the status displays. [`LoadPumpStatus`](../Services/Impl/ConfigurationService.cs) caches by `statusId`, but on pump switch the new pump usually carries a different `Type`, so both lookups miss and trigger `XDocument.Load(StatusXml)` — a full file read plus XML parse. Runs synchronously on the UI thread.
|
||||
|
||||
### Fix (when needed)
|
||||
Preload every `<PumpStatus>` entry into `_statusCache` during `ConfigurationService` construction (or app bootstrap). The file is small and status definitions never change at runtime. After that, `LoadPumpStatus` degenerates to a pure dictionary lookup and disk I/O is gone from the pump-change path.
|
||||
|
||||
Alternative (smaller change, slower fix): wrap both calls in `Task.Run` and apply results via `Dispatcher.InvokeAsync`. `PumpStatusDefinition` is a plain POCO, so it's safe to construct off-thread.
|
||||
|
||||
## Issue 2 — `TestPanelViewModel.LoadAllTests` allocation burst on the UI thread
|
||||
|
||||
### Symptom
|
||||
~5–30 ms synchronous burst on pump change while the test panel is rebuilt. On slower machines or pumps with many tests the operator sees a visible hitch between clicking the pump and the page settling.
|
||||
|
||||
### Why it happens
|
||||
[`LoadAllTests`](../ViewModels/TestPanelViewModel.cs) clears the `Tests` collection and synchronously creates one `TestSectionViewModel` per test definition, each spawning child `PhaseCardViewModel` and `GraphicIndicatorViewModel` instances. For a typical pump that's ~100+ view models constructed in a single tight loop, each raising `INotifyPropertyChanged` setters. The `ObservableCollection.Add` calls also dispatch `CollectionChanged` synchronously through any `ItemsControl` already bound to `Tests`.
|
||||
|
||||
### Fix (when needed)
|
||||
In `OnPumpChanged`, yield before `LoadAllTests` so the already-queued render frame commits first:
|
||||
|
||||
```csharp
|
||||
// ...all the lightweight synchronous bookkeeping (senders, CAN param swap,
|
||||
// slider gate, unlock startup)...
|
||||
|
||||
// Let the frame with the slider-enable + pump-name update paint before
|
||||
// we do the heavy test-panel rebuild.
|
||||
await Dispatcher.Yield(DispatcherPriority.Background);
|
||||
TestPanel.LoadAllTests(pump);
|
||||
```
|
||||
|
||||
This turns `OnPumpChanged` into an `async void` handler — acceptable here because the caller is the `SelectedPump` partial-method hook which does not observe the returned Task. Operator sees the slider gate open and the unlock dialog appear instantly; the test panel fills in on the next dispatcher tick.
|
||||
|
||||
Alternative: keep `OnPumpChanged` synchronous and wrap only the rebuild in `Dispatcher.BeginInvoke(..., DispatcherPriority.Background)`. Same effect; easier to keep the void signature.
|
||||
|
||||
## Why we're not fixing these now
|
||||
- Non-unlocker pumps: the combined ~25–230 ms worst case is absorbed by the operator's own reaction time after clicking the pump. Not flagged as a UX problem in field use.
|
||||
- Unlocker pumps: the original 1 s unlock-dialog delay was the dominant visible symptom. That was fixed by the event-driven observer — the remaining jank from issues 1 and 2 sits under the noise floor of the unlock dialog appearing.
|
||||
|
||||
Revisit if:
|
||||
- Pump catalogs grow to the point that `LoadAllTests` crosses the ~50 ms mark
|
||||
- `status.xml` grows (new status types) or storage latency regresses
|
||||
- Any future UI change on `DashboardPage` / `PumpPage` makes the pump-change transition visually tighter and exposes the hitch
|
||||
|
||||
## Files involved
|
||||
- [ViewModels/MainViewModel.cs](../ViewModels/MainViewModel.cs) — `OnPumpChanged` (lines 391–470)
|
||||
- [Services/Impl/ConfigurationService.cs](../Services/Impl/ConfigurationService.cs) — `LoadPumpStatus` (lines 364–435)
|
||||
- [ViewModels/TestPanelViewModel.cs](../ViewModels/TestPanelViewModel.cs) — `LoadAllTests` (lines 110–125)
|
||||
207
docs/kline_eeprom_spec.md
Normal file
207
docs/kline_eeprom_spec.md
Normal file
@@ -0,0 +1,207 @@
|
||||
## 5. EEPROM memory map (8-bit address, 0x00–0xFF)
|
||||
|
||||
The on-board serial EEPROM has 256 bytes addressed `0x00–0xFF`. The ECU's internal mirror after boot covers a sub-range only (`FUN_7568` loads EEPROM 0x40–0x8B into RAM 0x400–0x44B); the upper area is read on demand.
|
||||
|
||||
Observed structure (from cross-dump comparison + disassembly):
|
||||
|
||||
| EEPROM range | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| 0x00–0x3F | Pump cal record A — operating parameters | Read at boot via `FUN_4A79` (record-based, 8-byte chunks). Content is largely zero in current dumps. Erasable via cmd 0x05. |
|
||||
| 0x40–0x8B | Pump cal record B — primary | Block-loaded into RAM 0x400–0x44B at boot by `FUN_7568`. Contains: temp offset (0x42), dphi seed (0x44) + checksum (0x45), accel-comp seed (0x48), angle-table seed (0x4C), CKP loop seeds (0x50–0x56), redundant copies (0x79–0x7A), serial number ASCII (0x80–0x88) |
|
||||
| 0x8C–0xBF | Pump cal record C — extension | Not loaded into RAM at boot. Readable only via K-line cmd 0x19 after Zone 3 unlock. **Per user observation 2026-05-07: present in physical EEPROM, never consumed by running ECU code.** Likely OEM/factory metadata. |
|
||||
| 0xC0–0xCF | Reserved / unused | Outside any zone bounds in observed configs |
|
||||
| 0xD0 | Lockout backoff counter | Written by `FUN_29D4` failure path, read at boot by `FUN_3AD1`. Persists failed-auth state across power cycles. |
|
||||
| 0xD7–0xDF | Zone 0 seed (9 bytes) | Read into RAM 0xB7+ as part of cmd 0x18 success response |
|
||||
| 0xE1–0xE9 | Zone 1 seed (9 bytes) | Same |
|
||||
| 0xEA–0xF2 | Zone 2 seed / Zone 8 magic seed (9 bytes) | Same. Shared between Zone 2 and Zone 8. |
|
||||
| 0xF3–0xFB | Zone 3 seed (9 bytes) | Same |
|
||||
| 0xFC | Zone 0 use counter | Incremented on each cmd 0x18 + RB0=0x10 success |
|
||||
| 0xFD | Zone 1 use counter | Same |
|
||||
| 0xFE | Zone 2 / Zone 8 use counter | **Magic-zone access leaves this trace** |
|
||||
| 0xFF | Zone 3 use counter | Same |
|
||||
|
||||
The "cal record" boundaries (A/B/C) are inferred from access patterns; the EEPROM does not have explicit headers separating them.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security architecture
|
||||
|
||||
### 6.1 Zone descriptor table
|
||||
|
||||
A 4-entry × 12-byte table at **ROM 0x5FA0** drives zone authentication. Each entry has:
|
||||
|
||||
| Offset | Field | Type | Purpose |
|
||||
|---|---|---|---|
|
||||
| +0 | `alt_key` | u16-LE | Alternative key (used by cmd 0x18 with RB0 ≠ 0x10) |
|
||||
| +2 | `exp_key` | u16-LE | Primary expected key |
|
||||
| +4 | `zone_start` | u16-LE | First EEPROM address the zone covers |
|
||||
| +6 | `zone_end` | u16-LE | Last EEPROM address (inclusive) |
|
||||
| +8 | `flag_byte` | u8 | RE6/RE7 bit pattern set on success |
|
||||
| +9 | `seed_off` | u8 | EEPROM offset of the 9-byte seed read on success |
|
||||
| +10 | `cnt_off` | u8 | EEPROM offset of the per-zone use counter |
|
||||
| +11 | reserved | u8 | usually 0xFF (unused) |
|
||||
|
||||
A **fifth implicit "magic zone 8"** is hard-coded in `FUN_29D4`:
|
||||
- Key: **0x4453** (ASCII "DS")
|
||||
- Range: 0x0000–0xFFFE (effectively the whole 64KB MCU address space)
|
||||
- On success: `RE7 = 0xFF` (every gate-bit set, including the cmd 0x1A write gate)
|
||||
- Seed/counter: shares Zone 2's offsets (0xEA / 0xFE)
|
||||
|
||||
|
||||
### 6.3 Authentication flow (`FUN_29D4`)
|
||||
|
||||
Tester side:
|
||||
|
||||
1. **Build cmd 0x18 block** with:
|
||||
- `RB3` = 0x03 (request length / sub-count)
|
||||
- `RB4` = zone selector (0, 1, 2, 3, or 8)
|
||||
- `RB5` = key high byte
|
||||
- `RB6` = key low byte
|
||||
- `RB7` = any non-zero byte (acts as continuation flag — without it, the ECU's `RWC4 = 0` reset short-circuits the success path)
|
||||
- `RB0` = 0x10 if you also want write access enabled (sets the upper-nibble flag bits AND increments the use counter)
|
||||
- Otherwise `RB0` = block length (4 + data bytes)
|
||||
|
||||
2. **TX block, observe response.** ECU returns success block (`RB2 = 0xF0`, payload = the 9-byte seed + zone bounds) or error (`RB2` = 0xE5/error pattern).
|
||||
|
||||
3. **On success**, internal flags are set:
|
||||
- Zone 0 → `RE6 |= 0x01` (read) or `|= 0x11` if RB0=0x10 (read+write)
|
||||
- Zone 1 → `RE6 |= 0x02` or `|= 0x22`
|
||||
- Zone 2 → `RE6 |= 0x04` or `|= 0x44`
|
||||
- Zone 3 → `RE7 |= 0x08`
|
||||
- Zone 8 → `RE7 = 0xFF` (and cmd 0x1A becomes available)
|
||||
|
||||
4. The flags persist for the rest of the K-line session; they reset on session end / power cycle.
|
||||
|
||||
ECU-side details an implementer should be aware of:
|
||||
|
||||
- The 9-byte seed read at `EEPROM[seed_off..seed_off+8]` is **echoed back to the tester in the response**. It does not gate access — pure transport, presumably so the tool can fingerprint the unit.
|
||||
- `RE8` (lockout state) must be 0 to attempt auth. If non-zero (set by previous failed attempts), the ECU silently fails until the lockout timer (`FUN_38BE`) decrements RE8 to 0.
|
||||
- Wildcard match: if `exp_key == 0xFFFF` in any zone descriptor, ANY tester key is accepted. **None of the observed dumps use this**, but a future ROM variant might.
|
||||
- Sentinel: if `exp_key == 0x5555`, the zone is permanently disabled (cannot be unlocked). None observed yet either.
|
||||
|
||||
### 6.4 Lockout mechanism
|
||||
|
||||
Implemented in `FUN_29D4` failure path + `FUN_38BE` timer.
|
||||
|
||||
```
|
||||
fail #1 -> RE9 = 1, RE8 = 1, stored to EEPROM 0xD0
|
||||
fail #2 -> RE9 = 2, RE8 = 2
|
||||
fail #3 -> RE9 = 4
|
||||
...
|
||||
fail #8 -> RE9 = 128
|
||||
fail #9+ -> RE9 = 240 (saturated)
|
||||
```
|
||||
|
||||
`RE8` decrements once per ~30000 ticks (active session) or ~120000 ticks (idle). Each decrement re-writes EEPROM 0xD0 — the lockout therefore **persists across power cycles**. A wedged unit can require minutes to hours of waiting to retry.
|
||||
|
||||
**Recommendation for the reader software:** read EEPROM 0xD0 before any auth attempt (via cmd 0x19 after Zone 3 unlock — the only safe path that doesn't risk further lockout).
|
||||
|
||||
## 7. Read recipes
|
||||
|
||||
### 7.1 Recipe A — public read of 0x00–0xBF (Zone 3)
|
||||
|
||||
This is the safest operation: it requires a static key (0x00FF) that is identical across every variant we have dumps for, has no destructive side-effects, does not increment the magic-zone counter, and covers the full "general data" portion of the EEPROM.
|
||||
|
||||
```
|
||||
# Step 1 — unlock Zone 3
|
||||
TX: [ 06 NN 18 03 03 00 FF FF 03 ]
|
||||
len=06, seq=NN, cmd=0x18, RB3=03, RB4=03 (Zone 3),
|
||||
RB5=00, RB6=FF (key 0x00FF), RB7=FF (continuation flag),
|
||||
end=03
|
||||
|
||||
ECU response (success): RB2 = 0xF0, plus 9-byte seed @ 0xF3..0xFB.
|
||||
ECU now has RE7 |= 0x08.
|
||||
|
||||
# Step 2 — read EEPROM in 13-byte chunks
|
||||
for offset in 0x00, 0x0D, 0x1A, 0x27, 0x34, 0x41, 0x4E, 0x5B,
|
||||
0x68, 0x75, 0x82, 0x8F, 0x9C, 0xA9, 0xB6:
|
||||
TX: [ 04 NN 19 0D 00 offset 03 ]
|
||||
cmd=0x19 (read EEPROM), RB3=0x0D (13 bytes), RB5=offset
|
||||
RX: 13 bytes from EEPROM[offset..offset+12] in the response payload
|
||||
|
||||
# 15 chunks * 13 bytes = 195 bytes, covering 0x00..0xC2
|
||||
# Trim or adjust the last chunk's RB3 to 0x0A so it stops at 0xBF inclusive.
|
||||
```
|
||||
|
||||
### 7.2 Recipe B — full read of 0x00–0xFF (Zone 0)
|
||||
|
||||
Reads bytes that Zone 3 cannot see (0xC0–0xFF — counters, seeds, lockout). Requires the OEM key 0x00A6.
|
||||
|
||||
```
|
||||
# Step 1 — unlock Zone 0
|
||||
TX: [ 06 NN 18 03 00 00 A6 01 03 ]
|
||||
RB4=00 (Zone 0), RB5=00, RB6=A6, RB7=01
|
||||
|
||||
# Step 2 — read EEPROM via cmd 0x03 (per-zone path)
|
||||
for offset in 0x00, 0x0A, 0x14, ..., 0xF6:
|
||||
TX: [ 06 NN 03 0A 00 offset 03 ]
|
||||
cmd=0x03, RB3=0x0A (10 bytes), RB4:RB5 = 0x00:offset (16-bit address)
|
||||
|
||||
# 26 chunks * 10 bytes = 260 bytes, slightly over-reads;
|
||||
# adjust the last chunk's RB3 to stop at 0xFF.
|
||||
```
|
||||
|
||||
### 7.3 Recipe C — magic full access (Zone 8) — read AND write
|
||||
|
||||
```
|
||||
# Step 1 — unlock Zone 8 with the master key
|
||||
TX: [ 06 NN 18 03 08 44 53 01 03 ]
|
||||
RB4=08 (magic zone), RB5=44, RB6=53 (key "DS" = 0x4453), RB7=01
|
||||
|
||||
# After: RE7 = 0xFF (every gate-bit set).
|
||||
# All read commands work; cmd 0x1A is also available for writing.
|
||||
|
||||
# WARNING: this access path increments EEPROM[0xFE] (the use counter).
|
||||
# The increment is non-destructive but forensically observable — service
|
||||
# tools at the OEM can read 0xFE to see how many times Zone 8 was accessed.
|
||||
```
|
||||
|
||||
### 7.4 Recipe D — fast-path RAM-mirror dump (no auth)
|
||||
|
||||
For EEPROM 0x40–0x8B specifically, you don't need to talk to the EEPROM driver at all — the data is mirrored into RAM 0x400–0x44B at boot and accessible via cmd 0x01 (read RAM, no auth required).
|
||||
|
||||
```
|
||||
for offset in 0x400, 0x40D, 0x41A, ..., 0x444:
|
||||
TX: [ 04 NN 01 0D high_addr low_addr 03 ]
|
||||
cmd=0x01 (read RAM/ROM), RB3=0x0D, RB4=high, RB5=low
|
||||
|
||||
# 6 chunks covering 0x400..0x44B = exact mirror of EEPROM 0x40..0x8B.
|
||||
```
|
||||
|
||||
This recipe is ideal for monitoring tools that want to observe the live EEPROM cache without authentication overhead.
|
||||
|
||||
---
|
||||
|
||||
## 8. Write recipes — DANGER ZONE
|
||||
|
||||
Writes require either Zone 0/1/2 unlock with `RB0 = 0x10` (cmd 0x0C path) or Zone 8 (cmd 0x1A path).
|
||||
|
||||
### 8.1 Cmd 0x0C — write via OEM zone (with RB0 = 0x10 unlock)
|
||||
|
||||
```
|
||||
# Step 1 — unlock Zone 0 with write enabled
|
||||
TX: [ 06 NN 18 03 00 00 A6 01 03 ] # NOTE: RB0 must be 0x10, not 0x06,
|
||||
# to set the read+write flag pattern
|
||||
# (RC8 << 4 | RC8) and increment 0xFC.
|
||||
```
|
||||
|
||||
(Implementer task: verify the RB0=0x10 vs RB0=0x06 distinction by experiment — it's set in `FUN_29D4 @ 0x29D4` based on `RB0 == 0x10` test, but the call path that actually populates `RB0` differs between the dispatcher's path-A and path-B.)
|
||||
|
||||
```
|
||||
# Step 2 — cmd 0x0C write with verify
|
||||
TX: [ 0E NN 0C NN 00 offset b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 03 ]
|
||||
cmd=0x0C, RB3=count<11, RB4:RB5 = address, RB6+ = bytes to write
|
||||
```
|
||||
|
||||
### 8.2 Cmd 0x1A — write via Zone 8 (after master unlock)
|
||||
|
||||
After Zone 8 is unlocked (RE7 = 0xFF), cmd 0x1A becomes available:
|
||||
|
||||
```
|
||||
TX: [ ... NN 1A count rb4 offset b1..bN 03 ]
|
||||
cmd=0x1A, RB3=count<11, RB5=EEPROM offset (8-bit), data bytes from RAM 0xB6+
|
||||
```
|
||||
|
||||
Each byte is written and immediately read-back-verified internally by `FUN_22EA`. On verify failure, the response indicates failure and the partial write is left in place.
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user