Files
YMhut-box-C-/src/box-winUI/Views/Tools/SerialTerminalToolPage.cs
T

370 lines
14 KiB
C#

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<SerialParityMode>())
{
_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<SerialStopBitsMode>())
{
_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();
}
}