370 lines
14 KiB
C#
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();
|
|
}
|
|
}
|