using System; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using HC_APTBS.Models; using HC_APTBS.Services; namespace HC_APTBS.ViewModels.Pages { /// /// ViewModel for the Developer Tools navigation page — exposes a raw KWP/K-Line /// custom-command console for hardware development and debugging. /// /// /// Compiled into Debug builds only. The project's /// DEVELOPER_TOOLS compile-time symbol gates every reference to this /// type, and the page files are Compile Remove'd from Release builds /// in HC_APTBS.csproj, so this page does not appear at all in /// consumer builds. /// /// public sealed partial class DeveloperPageViewModel : ObservableObject { private const string LogId = nameof(DeveloperPageViewModel); private readonly IKwpService _kwp; private readonly IAppLogger _log; /// Root coordinator — exposes K-Line state for status binding. public MainViewModel Root { get; } // ── Input / output state ────────────────────────────────────────────────── /// /// Hex bytes typed by the developer. Whitespace, commas and dashes are /// accepted as separators; e.g. "18 00 03 FF FF" or "18-00-03-FF-FF". /// [ObservableProperty] private string _hexInput = string.Empty; /// Single-line status message (parse error, send result, …). [ObservableProperty] private string _statusText = string.Empty; /// True while a send is in flight — disables the Send button. [ObservableProperty] private bool _isBusy; /// True when the underlying K-Line session is open. [ObservableProperty] private bool _isSessionOpen; /// Time-stamped record of every TX/RX packet exchanged on this page. public ObservableCollection Log { get; } = new(); // ── Child VMs ───────────────────────────────────────────────────────────── /// Pump identification card VM, reused from the singleton on . public ViewModels.PumpIdentificationViewModel Identification => Root.PumpIdentification; /// ROM / EEPROM dump card. public DeveloperToolsDumpViewModel Dump { get; } /// Saved KWP custom commands library. public DeveloperToolsCommandsViewModel Commands { get; } /// EEPROM unlock password library. public DeveloperToolsPasswordsViewModel Passwords { get; } public DeveloperPageViewModel( MainViewModel root, IKwpService kwp, IConfigurationService config, IAppLogger log) { Root = root; _kwp = kwp; _log = log; IsSessionOpen = root.KLineState == KLineConnectionState.Connected; root.PropertyChanged += OnRootPropertyChanged; Dump = new DeveloperToolsDumpViewModel(this, kwp, log); Commands = new DeveloperToolsCommandsViewModel(this, kwp, config, log); Passwords = new DeveloperToolsPasswordsViewModel(this, kwp, config, log); } private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(MainViewModel.KLineState)) { IsSessionOpen = Root.KLineState == KLineConnectionState.Connected; SendCommand.NotifyCanExecuteChanged(); } } // ── Commands ────────────────────────────────────────────────────────────── /// Sends the hex payload as a raw KWP custom packet over the persistent session. [RelayCommand(CanExecute = nameof(CanSend))] private async Task SendAsync() { if (!TryParseHex(HexInput, out var bytes, out var error)) { StatusText = $"Parse error: {error}"; return; } IsBusy = true; try { var hex = FormatHex(bytes); StatusText = $"Sending {bytes.Length} byte(s)…"; AppendLog(DeveloperLogDirection.Tx, hex); _log.Info(LogId, $"TX {hex}"); var responses = await _kwp.SendRawCustomAsync(bytes, CancellationToken.None); if (responses.Count == 0) { AppendLog(DeveloperLogDirection.Info, "(no response)"); StatusText = "No response packets."; return; } foreach (var pkt in responses) { var rxHex = FormatHex(pkt); AppendLog(DeveloperLogDirection.Rx, rxHex); _log.Info(LogId, $"RX {rxHex}"); } StatusText = $"Received {responses.Count} packet(s)."; } catch (Exception ex) { StatusText = $"Send failed: {ex.Message}"; AppendLog(DeveloperLogDirection.Info, $"ERROR: {ex.Message}"); _log.Warning(LogId, $"SendAsync failed: {ex.Message}"); } finally { IsBusy = false; } } private bool CanSend() => !IsBusy && IsSessionOpen; /// Clears the on-screen log (does not affect AppLogger output). [RelayCommand] private void ClearLog() { Log.Clear(); StatusText = string.Empty; } // ── Helpers ─────────────────────────────────────────────────────────────── /// /// Appends a row to the shared transaction log. Exposed as internal so /// the page's child VMs (dump, command library, password library) can stream /// their own TX/RX rows into the same scrolling log. /// internal void AppendLog(DeveloperLogDirection dir, string text) { // Keep memory bounded — drop oldest entries when the list grows large. const int max = 500; if (Log.Count >= max) Log.RemoveAt(0); Log.Add(new DeveloperLogEntry(DateTime.Now, dir, text)); } /// Internal accessor so child VMs can push status messages. internal void SetStatus(string text) => StatusText = text; /// /// Formats a byte array as a space-separated uppercase hex string. Internal so /// child VMs can produce log rows that match the parent's formatting. /// internal static string FormatHex(byte[] bytes) => string.Join(" ", bytes.Select(b => b.ToString("X2"))); /// /// Same formatter for any read-only sequence (e.g. /// returned by KWP read primitives). /// internal static string FormatHex(System.Collections.Generic.IReadOnlyList bytes) { var sb = new System.Text.StringBuilder(bytes.Count * 3); for (int i = 0; i < bytes.Count; i++) { if (i > 0) sb.Append(' '); sb.Append(bytes[i].ToString("X2")); } return sb.ToString(); } /// /// Parses a hex byte sequence. Accepts spaces, commas, dashes, and any /// combination as separators. Each token must be 1–2 hex digits. /// internal static bool TryParseHex(string input, out byte[] bytes, out string error) { bytes = Array.Empty(); error = string.Empty; if (string.IsNullOrWhiteSpace(input)) { error = "input is empty"; return false; } var separators = new[] { ' ', ',', '-', '\t', '\r', '\n' }; var tokens = input.Split(separators, StringSplitOptions.RemoveEmptyEntries); if (tokens.Length == 0) { error = "no hex bytes found"; return false; } var result = new byte[tokens.Length]; for (int i = 0; i < tokens.Length; i++) { var t = tokens[i]; if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2); if (t.Length is < 1 or > 2) { error = $"token #{i + 1} '{tokens[i]}' must be 1–2 hex digits"; return false; } if (!byte.TryParse(t, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out byte b)) { error = $"token #{i + 1} '{tokens[i]}' is not valid hex"; return false; } result[i] = b; } bytes = result; return true; } } /// Log entry direction — TX (sent), RX (received), or Info (status). public enum DeveloperLogDirection { Tx, Rx, Info } /// One row in the Developer Tools transaction log. public sealed record DeveloperLogEntry(DateTime Timestamp, DeveloperLogDirection Direction, string Hex) { /// Pre-formatted line text for binding into a single TextBlock. public string Display { get { var sb = new StringBuilder(); sb.Append('[').Append(Timestamp.ToString("HH:mm:ss.fff")).Append(']').Append(' '); sb.Append(Direction switch { DeveloperLogDirection.Tx => "TX", DeveloperLogDirection.Rx => "RX", _ => " " }); sb.Append(' ').Append(Hex); return sb.ToString(); } } } }