Files
HC_APTBS/ViewModels/Pages/DashboardDevicesViewModel.cs
LucianoDev 827b811b39 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>
2026-05-07 13:59:50 +02:00

321 lines
13 KiB
C#

using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>Kind of physical device represented by a <see cref="DeviceItem"/>.</summary>
public enum DeviceKind { Can, KLine, Bench }
/// <summary>
/// Represents a single detected device row in the Devices column.
/// </summary>
public sealed partial class DeviceItem : ObservableObject
{
/// <summary>What kind of adapter this row represents.</summary>
public DeviceKind Kind { get; init; }
/// <summary>
/// Device identity string: PCAN handle as hex for CAN, FTDI serial for K-Line,
/// empty for the single Bench placeholder row.
/// </summary>
public string Id { get; init; } = "";
/// <summary>PCAN channel handle (only meaningful when <see cref="Kind"/> is <see cref="DeviceKind.Can"/>).</summary>
public ushort CanHandle { get; init; }
/// <summary>Display name shown in the tile row.</summary>
[ObservableProperty] private string _name = "";
/// <summary>Short state label (right-aligned in the row).</summary>
[ObservableProperty] private string _stateLabel = "";
/// <summary>True when this device is the currently active connection.</summary>
[ObservableProperty] private bool _isConnected;
/// <summary>True when the device session is in a failed state (K-Line only).</summary>
[ObservableProperty] private bool _isFailed;
/// <summary>False for the Bench placeholder, which cannot be clicked.</summary>
public bool IsEnabled { get; init; } = true;
}
/// <summary>
/// Snackbar state for an in-flight CAN connect/disconnect transition.
/// </summary>
public sealed partial class DeviceTransitionViewModel : ObservableObject
{
/// <summary>Localised message shown to the user.</summary>
[ObservableProperty] private string _message = "";
/// <summary>True while the operation is running (spinner visible).</summary>
[ObservableProperty] private bool _isBusy;
/// <summary>Null while running; true on success; false on failure.</summary>
[ObservableProperty] private bool? _isSuccess;
}
/// <summary>
/// ViewModel for the Devices column on the Dashboard.
///
/// <para>Owns three observable collections of <see cref="DeviceItem"/> (one per kind)
/// and exposes toggle/refresh commands. Communicates with CAN and K-Line services
/// through <see cref="ICanService"/> and <see cref="IKwpService"/> and reads
/// cross-cutting state (test running, connection flags) via <see cref="MainViewModel"/>.</para>
/// </summary>
public sealed partial class DashboardDevicesViewModel : ObservableObject
{
private readonly MainViewModel _root;
private readonly ICanService _can;
private readonly IKwpService _kwp;
/// <summary>Detected PCAN USB channels.</summary>
public ObservableCollection<DeviceItem> CanDevices { get; } = new();
/// <summary>Detected FTDI K-Line adapters.</summary>
public ObservableCollection<DeviceItem> KLineDevices { get; } = new();
/// <summary>Single bench-controller placeholder row.</summary>
public ObservableCollection<DeviceItem> BenchDevices { get; } = new();
/// <summary>Active snackbar VM for an in-flight CAN connect/disconnect; null when no transition.</summary>
[ObservableProperty] private DeviceTransitionViewModel? _transition;
public DashboardDevicesViewModel(MainViewModel root, ICanService can, IKwpService kwp)
{
_root = root;
_can = can;
_kwp = kwp;
root.PropertyChanged += OnRootPropertyChanged;
RefreshCanDevices();
RefreshKLineDevices();
RefreshBenchDevices();
}
// ── Refresh commands ──────────────────────────────────────────────────────
/// <summary>Re-enumerates attached PCAN USB channels.</summary>
[RelayCommand]
private void RefreshCanDevices()
{
CanDevices.Clear();
try
{
var channels = _can.EnumerateAttachedChannels();
foreach (var ch in channels)
{
CanDevices.Add(new DeviceItem
{
Kind = DeviceKind.Can,
Id = ch.Handle.ToString("X"),
CanHandle = ch.Handle,
Name = ch.DisplayName,
IsConnected = _root.IsCanConnected && _can.SelectedChannel == ch.Handle,
StateLabel = GetCanStateLabel(_root.IsCanConnected && _can.SelectedChannel == ch.Handle),
});
}
}
catch { /* PCAN DLL missing — leave list empty */ }
}
/// <summary>Re-enumerates connected FTDI K-Line adapters.</summary>
[RelayCommand]
private void RefreshKLineDevices()
{
KLineDevices.Clear();
try
{
uint count = FtdiInterface.GetDevicesCount();
if (count == 0) return;
var list = new FT_DEVICE_INFO_NODE[count];
FtdiInterface.GetDeviceList(list);
foreach (var dev in list)
{
if (string.IsNullOrEmpty(dev.SerialNumber)) continue;
bool connected = _kwp.KLineState == KLineConnectionState.Connected
&& _kwp.ConnectedPort == dev.SerialNumber;
bool failed = _kwp.KLineState == KLineConnectionState.Failed
&& _kwp.ConnectedPort == dev.SerialNumber;
KLineDevices.Add(new DeviceItem
{
Kind = DeviceKind.KLine,
Id = dev.SerialNumber,
Name = string.IsNullOrEmpty(dev.Description)
? dev.SerialNumber
: $"{dev.Description} ({dev.SerialNumber})",
IsConnected = connected,
IsFailed = failed,
StateLabel = GetKLineStateLabel(connected, failed),
IsEnabled = false, // K-Line rows are display-only; session is owned by AutoTestOrchestrator
});
}
}
catch { /* FTDI DLL not loaded — leave list empty */ }
}
private void RefreshBenchDevices()
{
BenchDevices.Clear();
BenchDevices.Add(new DeviceItem
{
Kind = DeviceKind.Bench,
Id = "bench",
Name = Str("Dashboard.Devices.BenchRow"),
IsConnected = _root.IsBenchConnected,
StateLabel = GetBenchStateLabel(_root.IsBenchConnected),
IsEnabled = false,
});
}
// ── Toggle command ────────────────────────────────────────────────────────
/// <summary>
/// Connects or disconnects the device represented by <paramref name="item"/>.
/// Shows a confirmation dialog when a session is active or a test is running.
/// </summary>
[RelayCommand]
private async Task ToggleDevice(DeviceItem? item)
{
if (item is null || !item.IsEnabled) return;
// K-Line rows are non-clickable (IsEnabled=false), so they never reach this point.
// Sessions for K-Line are started/stopped exclusively by AutoTestOrchestrator.
if (item.Kind != DeviceKind.Can) return;
// A running test owns the CAN bus; never let the user yank it mid-run.
if (_root.IsTestRunning &&
!Confirm(Str("Devices.Confirm.Title"), Str("Devices.Confirm.Body.TestRunning")))
return;
await ToggleCanAsync(item);
}
private async Task ToggleCanAsync(DeviceItem item)
{
bool connecting = !item.IsConnected;
var t = new DeviceTransitionViewModel
{
Message = Str(connecting
? "Dashboard.Devices.Snackbar.Connecting"
: "Dashboard.Devices.Snackbar.Disconnecting"),
IsBusy = true,
};
Transition = t;
// ConnectCan/DisconnectCan mutate observable properties that fan out to
// CanExecuteChanged listeners on Buttons — those DPs are UI-thread-affine,
// so the commands must run on the Dispatcher even though they block briefly
// on the PCAN handle. Yield once so the snackbar paints before we block.
await Task.Yield();
bool ok;
try
{
if (connecting)
{
try { _can.SelectedChannel = item.CanHandle; }
catch { Transition = null; return; }
_root.ConnectCanCommand.Execute(null);
ok = _root.IsCanConnected;
}
else
{
_root.DisconnectCanCommand.Execute(null);
ok = !_root.IsCanConnected;
}
}
catch
{
ok = false;
}
t.IsBusy = false;
t.IsSuccess = ok;
t.Message = Str(ok
? (connecting ? "Dashboard.Devices.Snackbar.Connected"
: "Dashboard.Devices.Snackbar.Disconnected")
: "Dashboard.Devices.Snackbar.Failed");
RefreshCanDevices();
// Auto-dismiss after ~2 s; only clear if a fresh transition has not replaced this one.
_ = Task.Delay(2000).ContinueWith(_ =>
App.Current.Dispatcher.Invoke(() =>
{
if (Transition == t) Transition = null;
}));
}
// ── State change wiring ───────────────────────────────────────────────────
private void OnRootPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(MainViewModel.IsCanConnected):
App.Current.Dispatcher.Invoke(RefreshCanDevices);
break;
case nameof(MainViewModel.KLineState):
App.Current.Dispatcher.Invoke(RefreshKLineDevices);
break;
case nameof(MainViewModel.IsBenchConnected):
App.Current.Dispatcher.Invoke(SyncBenchState);
break;
}
}
private void SyncBenchState()
{
if (BenchDevices.Count == 0) return;
var row = BenchDevices[0];
row.IsConnected = _root.IsBenchConnected;
row.StateLabel = GetBenchStateLabel(_root.IsBenchConnected);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static bool Confirm(string title, string message)
{
var vm = new ConfirmDialogViewModel
{
Title = title,
Message = message,
ConfirmText = Str("Common.Yes"),
CancelText = Str("Common.Cancel"),
};
var dlg = new ConfirmDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
return vm.Accepted;
}
private static string Str(string key)
=> Application.Current.TryFindResource(key) as string ?? key;
private static string GetCanStateLabel(bool connected)
=> connected ? Str("Dashboard.Devices.State.Connected") : Str("Dashboard.Devices.State.Idle");
private static string GetKLineStateLabel(bool connected, bool failed)
{
if (connected) return Str("Dashboard.Devices.State.Active");
if (failed) return Str("Dashboard.Devices.State.Failed");
return Str("Dashboard.Devices.State.Idle");
}
private static string GetBenchStateLabel(bool connected)
=> connected ? Str("Dashboard.Devices.State.Connected") : Str("Dashboard.Devices.State.Idle");
}
}