- Replace LCD-style readings with a 3×2 KPI tile grid (Fluent card surfaces, 52pt values) - Add persistent top connection strip with horizontal chips + pump name badge - Add elapsed test timer (DispatcherTimer, mm:ss) to Test Summary card - Restyle Test Summary and Active Alarms with Fluent brushes/iconography - Add Devices column (CAN / K-Line / Bench tiles) between KPI grid and test/alarms - Enumerates attached PCAN USB channels via PCAN_ATTACHED_CHANNELS API - Enumerates FTDI K-Line adapters via existing FtdiInterface helpers - Click to connect/disconnect; confirmation dialog when session active or test running - Hover tint: blue = will connect, red = will disconnect; Bench row is read-only stub - Extend ICanService with SelectedChannel + EnumerateAttachedChannels() - Expose IKwpService.ConnectedPort for active session device tracking - Add DeviceRow button style with MultiDataTrigger hover colour logic - Add 30+ new localization keys (ES + EN) for KPI labels, devices, confirmations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
11 KiB
C#
288 lines
11 KiB
C#
using System;
|
|
using System.Collections.ObjectModel;
|
|
using System.Threading;
|
|
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>False for the Bench placeholder, which cannot be clicked.</summary>
|
|
public bool IsEnabled { get; init; } = true;
|
|
}
|
|
|
|
/// <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();
|
|
|
|
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,
|
|
StateLabel = GetKLineStateLabel(connected, failed),
|
|
});
|
|
}
|
|
}
|
|
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;
|
|
|
|
bool testRunning = _root.IsTestRunning;
|
|
bool sessionActive = item.IsConnected;
|
|
|
|
if (testRunning)
|
|
{
|
|
if (!Confirm(Str("Devices.Confirm.Title"), Str("Devices.Confirm.Body.TestRunning")))
|
|
return;
|
|
}
|
|
else if (sessionActive)
|
|
{
|
|
string body = string.Format(Str("Devices.Confirm.Body.Active"),
|
|
item.Kind == DeviceKind.Can ? "CAN" : "K-Line");
|
|
if (!Confirm(Str("Devices.Confirm.Title"), body))
|
|
return;
|
|
}
|
|
|
|
switch (item.Kind)
|
|
{
|
|
case DeviceKind.Can:
|
|
await ToggleCanAsync(item);
|
|
break;
|
|
|
|
case DeviceKind.KLine:
|
|
await ToggleKLineAsync(item);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task ToggleCanAsync(DeviceItem item)
|
|
{
|
|
if (item.IsConnected)
|
|
{
|
|
_root.DisconnectCanCommand.Execute(null);
|
|
}
|
|
else
|
|
{
|
|
try { _can.SelectedChannel = item.CanHandle; }
|
|
catch { return; }
|
|
_root.ConnectCanCommand.Execute(null);
|
|
}
|
|
await Task.Delay(600); // allow liveness event propagation
|
|
RefreshCanDevices();
|
|
}
|
|
|
|
private async Task ToggleKLineAsync(DeviceItem item)
|
|
{
|
|
if (item.IsConnected)
|
|
{
|
|
_kwp.Disconnect();
|
|
}
|
|
else
|
|
{
|
|
try { await _kwp.ConnectAsync(item.Id, CancellationToken.None); }
|
|
catch { /* ConnectAsync throws on init failure — leave state as-is */ }
|
|
}
|
|
RefreshKLineDevices();
|
|
}
|
|
|
|
// ── 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");
|
|
}
|
|
}
|