using System.Text; using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Windows.Storage; using Windows.Storage.Pickers; using WinRT.Interop; using YMhut.Box.Core.Tools; using YMhut.Box.WinUI.Services; using YMhut.Box.WinUI.ViewModels.Tools; namespace YMhut.Box.WinUI.Views.Tools; public sealed class SerialTerminalToolViewModel(IToolModule module) : AdaptiveToolViewModel(module); public sealed class SerialTerminalToolPage : ToolPageBase { private readonly ISerialPortTransport _transport; private readonly Action? _goBack; private readonly ComboBox _portBox = new() { MinWidth = 130, PlaceholderText = "COM" }; private readonly ComboBox _baudBox = new() { MinWidth = 120 }; private readonly ComboBox _parityBox = new() { MinWidth = 110 }; private readonly ComboBox _dataBitsBox = new() { MinWidth = 90 }; private readonly ComboBox _stopBitsBox = new() { MinWidth = 100 }; private readonly ToggleSwitch _hexSend = new(); private readonly ToggleSwitch _hexReceive = new(); private readonly ToggleSwitch _timedSend = new(); private readonly NumberBox _interval = new() { Minimum = 100, Maximum = 60000, Value = 1000, SmallChange = 100, Width = 120 }; private readonly TextBox _sendBox = new() { AcceptsReturn = true, MinHeight = 92, TextWrapping = TextWrapping.Wrap }; private readonly TextBox _receiveBox = new() { AcceptsReturn = true, IsReadOnly = true, MinHeight = 260, TextWrapping = TextWrapping.NoWrap, FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Cascadia Mono") }; private readonly TextBlock _status = ModernUi.Text(string.Empty, 13, FontWeights.SemiBold, ModernUi.TextSecondary); private readonly DispatcherTimer _sendTimer = new(); private Button? _connectButton; private Button? _sendButton; private bool _sending; private bool _disposed; public SerialTerminalToolPage( IToolModule module, Action? goBack = null, ISerialPortTransport? transport = null) { _goBack = goBack; _transport = transport ?? new SerialPortTransport(); BindModule(module); Background = ModernUi.AppBackground; ConfigureControls(); Content = BuildContent(module); _transport.DataReceived += Transport_DataReceived; _sendTimer.Tick += SendTimer_Tick; Loaded += (_, _) => RefreshPorts(); Unloaded += SerialTerminalToolPage_Unloaded; } private void ConfigureControls() { foreach (var baud in new[] { 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400 }) { _baudBox.Items.Add(baud); } _baudBox.SelectedItem = 115200; foreach (var parity in Enum.GetValues()) { _parityBox.Items.Add(parity); } _parityBox.SelectedItem = SerialParityMode.None; foreach (var dataBits in new[] { 5, 6, 7, 8 }) { _dataBitsBox.Items.Add(dataBits); } _dataBitsBox.SelectedItem = 8; foreach (var stopBits in Enum.GetValues()) { _stopBitsBox.Items.Add(stopBits); } _stopBitsBox.SelectedItem = SerialStopBitsMode.One; _hexSend.Header = AppLocalizer.T("HEX 发送", "HEX send"); _hexReceive.Header = AppLocalizer.T("HEX 显示", "HEX display"); _timedSend.Header = AppLocalizer.T("定时发送", "Timed send"); _timedSend.Toggled += (_, _) => UpdateTimer(); _interval.ValueChanged += (_, _) => UpdateTimer(); _sendBox.PlaceholderText = AppLocalizer.T("输入要发送的文本或十六进制字节", "Enter text or hexadecimal bytes to send"); _status.Text = AppLocalizer.T("未连接", "Disconnected"); } private UIElement BuildContent(IToolModule module) { var refresh = ModernUi.IconButton("\uE72C", AppLocalizer.T("刷新串口", "Refresh ports"), RefreshPorts); _connectButton = ModernUi.PillButton(AppLocalizer.T("连接", "Connect"), "\uE703", async () => await ToggleConnectionAsync(), primary: true); var connectionActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _connectButton, refresh } }; var connectionGrid = new Grid { ColumnSpacing = 10, RowSpacing = 10 }; for (var index = 0; index < 5; index++) { connectionGrid.ColumnDefinitions.Add(new ColumnDefinition()); } AddLabeledControl(connectionGrid, 0, AppLocalizer.T("端口", "Port"), _portBox); AddLabeledControl(connectionGrid, 1, AppLocalizer.T("波特率", "Baud"), _baudBox); AddLabeledControl(connectionGrid, 2, AppLocalizer.T("校验", "Parity"), _parityBox); AddLabeledControl(connectionGrid, 3, AppLocalizer.T("数据位", "Data bits"), _dataBitsBox); AddLabeledControl(connectionGrid, 4, AppLocalizer.T("停止位", "Stop bits"), _stopBitsBox); _sendButton = ModernUi.PillButton(AppLocalizer.T("发送", "Send"), "\uE724", async () => await SendAsync(), primary: true); _sendButton.IsEnabled = false; var sendActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, Children = { _hexSend, _timedSend, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center, Children = { ModernUi.Text(AppLocalizer.T("间隔 ms", "Interval ms"), 12, foreground: ModernUi.TextSecondary), _interval } }, _sendButton } }; var clear = ModernUi.IconButton("\uE74D", AppLocalizer.T("清空接收日志", "Clear receive log"), () => _receiveBox.Text = string.Empty); var export = ModernUi.IconButton("\uE159", AppLocalizer.T("导出接收日志", "Export receive log"), async () => await ExportLogAsync()); var receiveHeader = new Grid(); receiveHeader.ColumnDefinitions.Add(new ColumnDefinition()); receiveHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); receiveHeader.Children.Add(_hexReceive); var receiveActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, Children = { clear, export } }; Grid.SetColumn(receiveActions, 1); receiveHeader.Children.Add(receiveActions); var root = new StackPanel { Padding = new Thickness(24, 20, 24, 28), Spacing = 16 }; root.Children.Add(ModernUi.PageHeader( ToolText.Name(module), ToolText.Description(module), module.Metadata.IconGlyph, actions: connectionActions, back: _goBack, backTooltip: AppLocalizer.T("返回上一级", "Back"), meta: _status)); root.Children.Add(ModernUi.Card(connectionGrid, new Thickness(14), radius: 8)); root.Children.Add(ModernUi.Card(new StackPanel { Spacing = 10, Children = { ModernUi.Text(AppLocalizer.T("发送", "Send"), 16, FontWeights.SemiBold), _sendBox, sendActions } }, new Thickness(14), radius: 8)); root.Children.Add(ModernUi.Card(new StackPanel { Spacing = 10, Children = { ModernUi.Text(AppLocalizer.T("接收日志", "Receive log"), 16, FontWeights.SemiBold), receiveHeader, _receiveBox } }, new Thickness(14), radius: 8)); return new ScrollViewer { HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = root }; } private static void AddLabeledControl(Grid grid, int column, string label, Control control) { var panel = new StackPanel { Spacing = 5, Children = { ModernUi.Text(label, 12, FontWeights.SemiBold, ModernUi.TextSecondary), control } }; Grid.SetColumn(panel, column); grid.Children.Add(panel); } private void RefreshPorts() { var selected = _portBox.SelectedItem?.ToString(); _portBox.Items.Clear(); foreach (var name in _transport.GetPortNames()) { _portBox.Items.Add(name); } _portBox.SelectedItem = !string.IsNullOrWhiteSpace(selected) && _portBox.Items.Contains(selected) ? selected : _portBox.Items.FirstOrDefault(); if (_portBox.Items.Count == 0) { _status.Text = AppLocalizer.T("未发现串口设备", "No serial ports found"); } } private async Task ToggleConnectionAsync() { if (_transport.IsOpen) { await DisconnectAsync(); return; } if (_portBox.SelectedItem is not string portName) { ToastService.Show(AppLocalizer.T("请选择串口。", "Select a serial port."), ToastKind.Warning); return; } try { var options = new SerialConnectionOptions( portName, _baudBox.SelectedItem is int baud ? baud : 115200, _dataBitsBox.SelectedItem is int dataBits ? dataBits : 8, _parityBox.SelectedItem is SerialParityMode parity ? parity : SerialParityMode.None, _stopBitsBox.SelectedItem is SerialStopBitsMode stopBits ? stopBits : SerialStopBitsMode.One); await _transport.OpenAsync(options); _status.Text = AppLocalizer.T($"已连接 {portName} · {options.BaudRate}", $"Connected to {portName} · {options.BaudRate}"); _connectButton!.Content = AppLocalizer.T("断开", "Disconnect"); _sendButton!.IsEnabled = true; SetConnectionControlsEnabled(false); UpdateTimer(); } catch (Exception exception) { _status.Text = AppLocalizer.T("连接失败", "Connection failed"); ToastService.Show(AppLocalizer.SanitizeSensitiveText(exception.Message, 160), ToastKind.Warning); } } private async Task DisconnectAsync() { _sendTimer.Stop(); await _transport.CloseAsync(); _status.Text = AppLocalizer.T("未连接", "Disconnected"); if (_connectButton is not null) { _connectButton.Content = AppLocalizer.T("连接", "Connect"); } if (_sendButton is not null) { _sendButton.IsEnabled = false; } SetConnectionControlsEnabled(true); } private async Task SendAsync() { if (_sending || !_transport.IsOpen) { return; } _sending = true; try { var data = SerialPayloadCodec.Parse(_sendBox.Text, _hexSend.IsOn); if (data.Length == 0) { return; } await _transport.WriteAsync(data); AppendLog("TX", SerialPayloadCodec.Format(data, _hexSend.IsOn)); } catch (Exception exception) { ToastService.Show(AppLocalizer.SanitizeSensitiveText(exception.Message, 160), ToastKind.Warning); } finally { _sending = false; } } private async void SendTimer_Tick(object? sender, object e) => await SendAsync(); private void UpdateTimer() { _sendTimer.Stop(); if (_timedSend.IsOn && _transport.IsOpen) { _sendTimer.Interval = TimeSpan.FromMilliseconds(Math.Clamp(_interval.Value, 100, 60000)); _sendTimer.Start(); } } private void Transport_DataReceived(object? sender, YMhut.Box.Core.Tools.SerialDataReceivedEventArgs e) { var data = e.Data.ToArray(); DispatcherQueue.TryEnqueue(() => AppendLog("RX", SerialPayloadCodec.Format(data, _hexReceive.IsOn))); } private void AppendLog(string direction, string value) { var next = $"[{DateTime.Now:HH:mm:ss.fff}] {direction} {value}"; _receiveBox.Text = string.IsNullOrEmpty(_receiveBox.Text) ? next : _receiveBox.Text + Environment.NewLine + next; if (_receiveBox.Text.Length > 250_000) { _receiveBox.Text = _receiveBox.Text[^200_000..]; } _receiveBox.SelectionStart = _receiveBox.Text.Length; } private async Task ExportLogAsync() { if (App.CurrentWindow is null) { return; } var picker = new FileSavePicker { SuggestedFileName = $"serial-{DateTime.Now:yyyyMMdd-HHmmss}" }; picker.FileTypeChoices.Add(AppLocalizer.T("文本日志", "Text log"), [".txt"]); InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow)); var file = await picker.PickSaveFileAsync(); if (file is not null) { await FileIO.WriteTextAsync(file, _receiveBox.Text); ToastService.Show(AppLocalizer.T("串口日志已导出。", "Serial log exported."), ToastKind.Success); } } private void SetConnectionControlsEnabled(bool enabled) { _portBox.IsEnabled = enabled; _baudBox.IsEnabled = enabled; _parityBox.IsEnabled = enabled; _dataBitsBox.IsEnabled = enabled; _stopBitsBox.IsEnabled = enabled; } private async void SerialTerminalToolPage_Unloaded(object sender, RoutedEventArgs e) { if (_disposed) { return; } _disposed = true; _sendTimer.Stop(); _sendTimer.Tick -= SendTimer_Tick; _transport.DataReceived -= Transport_DataReceived; try { await _transport.CloseAsync(); } catch { } _transport.Dispose(); } }