- HwCheck: флаг --autofind (автопоиск при старте — для скриншотов/проверки). - docs/pos-scale-2026-10-11: добавлен скриншот автопоиска на VM (COM1 в журнале, без зависания), отчёт обновлён — новый zip hardware-check и его SHA256; полная проверка с эмулятором/зависшим виртуальным портом ждёт com0com (решение владельца). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
391 lines
21 KiB
C#
391 lines
21 KiB
C#
using System.Diagnostics;
|
||
using System.Text;
|
||
using Avalonia;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Input;
|
||
using Avalonia.Interactivity;
|
||
using Avalonia.Layout;
|
||
using Avalonia.Media;
|
||
using Avalonia.Threading;
|
||
using foodmarket.Pos.Core.Hardware;
|
||
|
||
namespace foodmarket.Pos.HwCheck;
|
||
|
||
/// <summary>Окно «ALL MARKET — Проверка оборудования». Без входа, без сервера, без БД.
|
||
/// Весы (Масса-К-кадр), COM-порты, принтеры (только чтение), сканер, сохранение отчёта.</summary>
|
||
public sealed class MainWindow : Window
|
||
{
|
||
private readonly bool _allowTestPrint;
|
||
private static readonly (string label, ScaleProto proto)[] Protos =
|
||
{
|
||
("Масса-К (кадр, 57600)", ScaleProto.MassaKFrame),
|
||
("Масса-К (ASCII)", ScaleProto.MassaKAscii),
|
||
("CAS (ASCII)", ScaleProto.Cas),
|
||
};
|
||
private static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 };
|
||
|
||
private readonly ComboBox _portBox = new() { MinWidth = 220 };
|
||
private readonly ComboBox _baudBox = new() { ItemsSource = Bauds, SelectedIndex = 0, MinWidth = 100 };
|
||
private readonly ComboBox _protoBox = new() { MinWidth = 220 };
|
||
private readonly TextBlock _weight = new() { FontSize = 46, FontWeight = FontWeight.Bold, Text = "— кг" };
|
||
private readonly TextBlock _stable = new() { Foreground = Brushes.Gray };
|
||
private readonly TextBlock _hex = new() { FontFamily = new FontFamily("Consolas, monospace"), TextWrapping = TextWrapping.Wrap, Foreground = Brushes.DimGray };
|
||
private readonly TextBlock _scaleStatus = new() { TextWrapping = TextWrapping.Wrap };
|
||
private readonly TextBox _log = new() { IsReadOnly = true, AcceptsReturn = true, MinHeight = 90, MaxHeight = 160, FontFamily = new FontFamily("Consolas, monospace"), FontSize = 12, Watermark = "Журнал автопоиска по портам…" };
|
||
private readonly ItemsControl _ports = new();
|
||
private readonly ItemsControl _printers = new();
|
||
private readonly TextBox _scan = new() { Watermark = "Наведите сканер и сканируйте ШК сюда…", FontSize = 18 };
|
||
private readonly TextBlock _scanInfo = new() { TextWrapping = TextWrapping.Wrap };
|
||
private readonly Button _testPrint;
|
||
private readonly TextBlock _footer = new() { Foreground = Brushes.Gray };
|
||
|
||
private readonly DispatcherTimer _timer;
|
||
private bool _reading;
|
||
private decimal? _lastKg;
|
||
private string _lastPrinterPick = "";
|
||
private readonly List<long> _scanTimes = new();
|
||
private readonly Stopwatch _scanSw = new();
|
||
|
||
public MainWindow(bool allowTestPrint)
|
||
{
|
||
_allowTestPrint = allowTestPrint;
|
||
Title = "ALL MARKET — Проверка оборудования";
|
||
Width = 1000; Height = 720; MinWidth = 820; MinHeight = 560;
|
||
Background = Brushes.White;
|
||
FontFamily = new FontFamily("Inter, Segoe UI, sans-serif");
|
||
|
||
_protoBox.ItemsSource = Protos.Select(p => p.label).ToArray();
|
||
_protoBox.SelectedIndex = 0;
|
||
_testPrint = new Button { Content = "Тестовая печать", IsEnabled = allowTestPrint };
|
||
_testPrint.Click += (_, _) => DoTestPrint();
|
||
|
||
Content = BuildLayout();
|
||
RefreshPorts();
|
||
RefreshPrinters();
|
||
WireScanner();
|
||
|
||
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) };
|
||
_timer.Tick += (_, _) => LiveTick();
|
||
|
||
if (Program.AutoFind)
|
||
Dispatcher.UIThread.Post(() => DiscoverScale(), DispatcherPriority.Background);
|
||
}
|
||
|
||
private Control BuildLayout()
|
||
{
|
||
var header = new StackPanel { Spacing = 2, Margin = new Thickness(0, 0, 0, 10) };
|
||
header.Children.Add(new TextBlock { Text = "ALL MARKET — Проверка оборудования", FontSize = 22, FontWeight = FontWeight.Bold });
|
||
header.Children.Add(new TextBlock
|
||
{
|
||
Text = "Диагностика весов, принтера и сканера. Без входа в кассу, без сервера и без доступа к данным магазина."
|
||
+ (_allowTestPrint ? " Тестовая печать РАЗРЕШЕНА (--allow-test-print)." : " Тестовая печать выключена."),
|
||
Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap,
|
||
});
|
||
|
||
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), RowDefinitions = new RowDefinitions("*") };
|
||
var left = new StackPanel { Spacing = 10, Margin = new Thickness(0, 0, 8, 0) };
|
||
left.Children.Add(ScaleCard());
|
||
left.Children.Add(PortsCard());
|
||
var right = new StackPanel { Spacing = 10, Margin = new Thickness(8, 0, 0, 0) };
|
||
right.Children.Add(PrintersCard());
|
||
right.Children.Add(ScannerCard());
|
||
Grid.SetColumn(left, 0); Grid.SetColumn(right, 1);
|
||
grid.Children.Add(left); grid.Children.Add(right);
|
||
|
||
var footerBar = new DockPanel { Margin = new Thickness(0, 10, 0, 0) };
|
||
var btns = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
||
var save = new Button { Content = "Сохранить отчёт" }; save.Click += (_, _) => SaveReport();
|
||
var copy = new Button { Content = "Копировать отчёт" }; copy.Click += (_, _) => CopyReport();
|
||
btns.Children.Add(save); btns.Children.Add(copy);
|
||
DockPanel.SetDock(btns, Dock.Left);
|
||
footerBar.Children.Add(btns);
|
||
footerBar.Children.Add(_footer);
|
||
|
||
var root = new DockPanel { Margin = new Thickness(16) };
|
||
DockPanel.SetDock(header, Dock.Top);
|
||
DockPanel.SetDock(footerBar, Dock.Bottom);
|
||
root.Children.Add(header);
|
||
root.Children.Add(footerBar);
|
||
root.Children.Add(new ScrollViewer { Content = grid });
|
||
return root;
|
||
}
|
||
|
||
private static Border Card(string title, Control body)
|
||
{
|
||
var panel = new StackPanel { Spacing = 8 };
|
||
panel.Children.Add(new TextBlock { Text = title, FontSize = 16, FontWeight = FontWeight.SemiBold });
|
||
panel.Children.Add(body);
|
||
return new Border
|
||
{
|
||
BorderBrush = new SolidColorBrush(Color.Parse("#E2E8F0")), BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(10), Padding = new Thickness(14), Child = panel,
|
||
Background = new SolidColorBrush(Color.Parse("#FCFCFD")),
|
||
};
|
||
}
|
||
|
||
private Control ScaleCard()
|
||
{
|
||
var sel = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), RowDefinitions = new RowDefinitions("Auto,Auto,Auto") };
|
||
void Row(int r, string label, Control c) { var t = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 4, 10, 4) }; c.Margin = new Thickness(0, 4, 0, 4); Grid.SetRow(t, r); Grid.SetColumn(t, 0); Grid.SetRow(c, r); Grid.SetColumn(c, 1); sel.Children.Add(t); sel.Children.Add(c); }
|
||
Row(0, "Порт", _portBox); Row(1, "Скорость", _baudBox); Row(2, "Протокол", _protoBox);
|
||
|
||
var findBtn = new Button { Content = "Найти весы" }; findBtn.Click += (_, _) => DiscoverScale();
|
||
var readBtn = new Button { Content = "Прочитать вес сейчас" }; readBtn.Click += (_, _) => _ = ReadOnce();
|
||
var startBtn = new Button { Content = "Запустить (вес в реальном времени)" }; startBtn.Click += (_, _) => StartLive();
|
||
var stopBtn = new Button { Content = "Остановить" }; stopBtn.Click += (_, _) => StopLive();
|
||
var row1 = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; row1.Children.Add(findBtn); row1.Children.Add(readBtn);
|
||
var row2 = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; row2.Children.Add(startBtn); row2.Children.Add(stopBtn);
|
||
|
||
var body = new StackPanel { Spacing = 10 };
|
||
body.Children.Add(sel);
|
||
body.Children.Add(row1);
|
||
body.Children.Add(row2);
|
||
body.Children.Add(_weight);
|
||
body.Children.Add(_stable);
|
||
body.Children.Add(new TextBlock { Text = "Сырой кадр (hex):", Foreground = Brushes.Gray });
|
||
body.Children.Add(_hex);
|
||
body.Children.Add(_scaleStatus);
|
||
body.Children.Add(new TextBlock { Text = "Журнал автопоиска:", Foreground = Brushes.Gray });
|
||
body.Children.Add(_log);
|
||
return Card("Весы", body);
|
||
}
|
||
|
||
private Control PortsCard()
|
||
{
|
||
var refresh = new Button { Content = "Обновить список портов" }; refresh.Click += (_, _) => RefreshPorts();
|
||
var body = new StackPanel { Spacing = 8 };
|
||
body.Children.Add(refresh);
|
||
body.Children.Add(_ports);
|
||
return Card("COM-порты", body);
|
||
}
|
||
|
||
private Control PrintersCard()
|
||
{
|
||
var refresh = new Button { Content = "Обновить список принтеров" }; refresh.Click += (_, _) => RefreshPrinters();
|
||
var note = new TextBlock { Text = "Только чтение: очередь не очищается, задания не удаляются.", Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap };
|
||
var body = new StackPanel { Spacing = 8 };
|
||
body.Children.Add(refresh);
|
||
body.Children.Add(_printers);
|
||
body.Children.Add(_testPrint);
|
||
body.Children.Add(note);
|
||
return Card("Принтеры Windows", body);
|
||
}
|
||
|
||
private Control ScannerCard()
|
||
{
|
||
var body = new StackPanel { Spacing = 8 };
|
||
body.Children.Add(new TextBlock { Text = "Поставьте курсор в поле и отсканируйте штрихкод. Для EAN-13/EAN-8 проверяется контрольная цифра; по скорости ввода отличаем сканер от ручного набора.", Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap });
|
||
body.Children.Add(_scan);
|
||
body.Children.Add(_scanInfo);
|
||
return Card("Сканер штрихкодов", body);
|
||
}
|
||
|
||
// ───────────────────────── Весы ─────────────────────────
|
||
private string? SelectedPort => _portBox.SelectedItem as string;
|
||
private int SelectedBaud => _baudBox.SelectedItem is int b ? b : 57600;
|
||
private ScaleProto SelectedProto => Protos[Math.Max(0, _protoBox.SelectedIndex)].proto;
|
||
|
||
private void StartLive() { if (SelectedPort is null) { _scaleStatus.Text = "Выберите порт или нажмите «Найти весы»."; return; } _timer.Start(); _scaleStatus.Text = "Идёт опрос весов…"; }
|
||
private void StopLive() { _timer.Stop(); _scaleStatus.Text = "Опрос остановлен."; }
|
||
|
||
private void LiveTick()
|
||
{
|
||
if (_reading || SelectedPort is null) return;
|
||
_ = ReadOnce();
|
||
}
|
||
|
||
private async Task ReadOnce()
|
||
{
|
||
var port = SelectedPort; if (port is null) { _scaleStatus.Text = "Порт не выбран."; return; }
|
||
var baud = SelectedBaud; var proto = SelectedProto;
|
||
_reading = true;
|
||
try
|
||
{
|
||
var (kg, hex, status, stable) = await Task.Run(() =>
|
||
{
|
||
try
|
||
{
|
||
using var t = new SystemSerialTransport(port, baud);
|
||
t.Open();
|
||
var (r1, h1, e1) = SerialScaleReader.ReadDetailed(t, proto);
|
||
if (r1 is null) return ((decimal?)null, h1, e1 ?? "нет ответа", false);
|
||
// стабильность: второе чтение
|
||
Thread.Sleep(160);
|
||
var (r2, _, _) = SerialScaleReader.ReadDetailed(t, proto);
|
||
bool st = r2 is not null && r2.Value.Kg == r1.Value.Kg;
|
||
var rr = r2 ?? r1;
|
||
return ((decimal?)rr.Value.Kg, rr.Value.RawHex, $"флаг '{rr.Value.Flag}', ед. {rr.Value.Unit}, control {(rr.Value.Control is null ? "—" : "0x" + rr.Value.Control.Value.ToString("X2"))}", st);
|
||
}
|
||
catch (UnauthorizedAccessException) { return (null, "", "порт занят другой программой (закройте кассу МойСклад)", false); }
|
||
catch (Exception ex) { return (null, "", ex.GetType().Name + ": " + ex.Message, false); }
|
||
});
|
||
if (kg is not null)
|
||
{
|
||
_lastKg = kg;
|
||
_weight.Text = $"{kg.Value:0.000} кг";
|
||
_weight.Foreground = Brushes.Black;
|
||
_stable.Text = stable ? "стабильно" : "нестабильно (вес меняется)";
|
||
_hex.Text = hex;
|
||
_scaleStatus.Text = status;
|
||
}
|
||
else
|
||
{
|
||
_weight.Foreground = Brushes.IndianRed;
|
||
_scaleStatus.Text = status;
|
||
}
|
||
}
|
||
finally { _reading = false; }
|
||
}
|
||
|
||
private void AppendLog(string line)
|
||
=> Dispatcher.UIThread.Post(() => _log.Text = string.IsNullOrEmpty(_log.Text) ? line : _log.Text + "\n" + line);
|
||
|
||
private async void DiscoverScale()
|
||
{
|
||
StopLive();
|
||
_log.Text = "";
|
||
_scaleStatus.Text = "Ищу весы: USB-serial первыми, Bluetooth последними, с таймаутом на порт…";
|
||
var res = await ScaleAutodiscovery.DiscoverAsync(log: AppendLog);
|
||
if (res.Device is not null)
|
||
{
|
||
var d = res.Device;
|
||
_portBox.SelectedItem = d.Port;
|
||
_baudBox.SelectedItem = d.Baud;
|
||
_protoBox.SelectedIndex = Array.FindIndex(Protos, p => p.proto == d.Proto);
|
||
_weight.Text = $"{d.SampleKg:0.000} кг"; _weight.Foreground = Brushes.Black;
|
||
_hex.Text = d.RawHex;
|
||
_scaleStatus.Text = $"Найдены: {d.Port}, {d.Baud}, {d.Proto}.";
|
||
RefreshPorts();
|
||
}
|
||
else
|
||
{
|
||
_scaleStatus.Text = res.BusyPorts.Count > 0
|
||
? $"Весы не найдены. Заняты порты: {string.Join(", ", res.BusyPorts)} (закройте кассу МойСклад)."
|
||
: "Весы не найдены ни на одном порту.";
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── Порты / принтеры ─────────────────────────
|
||
private List<HardwareProbe.ComPortInfo> _portList = new();
|
||
private List<HardwareProbe.PrinterInfo> _printerList = new();
|
||
|
||
private void RefreshPorts()
|
||
{
|
||
_portList = HardwareProbe.ListComPorts(); // уже упорядочены: USB-serial → unknown → Bluetooth
|
||
string Kind(HardwareProbe.PortKind k) => k switch
|
||
{
|
||
HardwareProbe.PortKind.UsbSerial => " [USB-serial]",
|
||
HardwareProbe.PortKind.Bluetooth => " [Bluetooth]",
|
||
_ => "",
|
||
};
|
||
_ports.ItemsSource = _portList.Select(p =>
|
||
$"{p.Port}{Kind(p.Kind)}: {(p.Busy ? "ЗАНЯТ другой программой" : p.Kind == HardwareProbe.PortKind.Bluetooth ? "не опрашивается" : "свободен")}"
|
||
+ (p.Description is null ? "" : $" — {p.Description}")).ToArray();
|
||
var names = _portList.Select(p => p.Port).ToArray();
|
||
var keep = SelectedPort;
|
||
_portBox.ItemsSource = names;
|
||
if (keep is not null && names.Contains(keep)) _portBox.SelectedItem = keep;
|
||
// по умолчанию — первый НЕ-Bluetooth порт (список уже с USB-serial в начале)
|
||
else _portBox.SelectedItem = _portList.FirstOrDefault(p => p.Kind != HardwareProbe.PortKind.Bluetooth)?.Port
|
||
?? (names.Length > 0 ? names[0] : null);
|
||
}
|
||
|
||
private void RefreshPrinters()
|
||
{
|
||
_printerList = HardwareProbe.ListPrinters();
|
||
_printers.ItemsSource = _printerList.Count == 0
|
||
? new[] { "(принтеры не найдены / не Windows)" }
|
||
: _printerList.Select(p =>
|
||
$"{p.Name}{(p.IsDefault ? " [по умолчанию]" : "")} — порт {p.Port}, {(p.Offline ? "НЕ в сети" : "в сети")}, заданий в очереди: {p.QueueJobs}").ToArray();
|
||
_lastPrinterPick = _printerList.FirstOrDefault(p => p.IsDefault)?.Name ?? _printerList.FirstOrDefault()?.Name ?? "";
|
||
}
|
||
|
||
private void DoTestPrint()
|
||
{
|
||
if (!_allowTestPrint) { _footer.Text = "Тестовая печать выключена (нет --allow-test-print)."; return; }
|
||
if (!OperatingSystem.IsWindows()) { _footer.Text = "Печать доступна только на Windows."; return; }
|
||
if (string.IsNullOrEmpty(_lastPrinterPick)) { _footer.Text = "Нет принтера для теста."; return; }
|
||
try
|
||
{
|
||
var ok = WinRawPrint.Send(_lastPrinterPick, WinRawPrint.BuildTestSlip(), out var err);
|
||
_footer.Text = ok ? $"Тестовый чек отправлен на «{_lastPrinterPick}»." : $"Ошибка печати: {err}";
|
||
}
|
||
catch (Exception ex) { _footer.Text = "Ошибка печати: " + ex.Message; }
|
||
}
|
||
|
||
// ───────────────────────── Сканер ─────────────────────────
|
||
private void WireScanner()
|
||
{
|
||
_scan.AddHandler(InputElement.KeyDownEvent, (_, e) =>
|
||
{
|
||
if (!_scanSw.IsRunning) { _scanSw.Restart(); _scanTimes.Clear(); }
|
||
if (e.Key == Key.Enter)
|
||
{
|
||
AnalyzeScan();
|
||
e.Handled = true;
|
||
return;
|
||
}
|
||
_scanTimes.Add(_scanSw.ElapsedMilliseconds);
|
||
}, RoutingStrategies.Tunnel);
|
||
}
|
||
|
||
private void AnalyzeScan()
|
||
{
|
||
var code = (_scan.Text ?? "").Trim();
|
||
var a = BarcodeScan.Analyze(code, _scanTimes.ToList());
|
||
var sb = new StringBuilder();
|
||
sb.Append($"Длина: {a.Length}. ");
|
||
sb.Append(a.IsEan ? (a.EanValid ? "EAN контрольная цифра ВЕРНА. " : "EAN контрольная цифра НЕВЕРНА. ") : "Не EAN-13/EAN-8. ");
|
||
if (a.AvgIntervalMs > 0) sb.Append($"Средний интервал {a.AvgIntervalMs:0} мс/символ — {BarcodeScan.KindRu(a.Kind)}.");
|
||
_scanInfo.Text = sb.ToString();
|
||
_scanSw.Reset();
|
||
}
|
||
|
||
// ───────────────────────── Отчёт ─────────────────────────
|
||
private string BuildReportText()
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("=== ALL MARKET — отчёт проверки оборудования ===");
|
||
sb.AppendLine($"Время: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||
sb.AppendLine($"Компьютер: {Environment.MachineName}");
|
||
sb.AppendLine($"ОС: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"COM-порты ({_portList.Count}):");
|
||
foreach (var p in _portList)
|
||
sb.AppendLine($" {p.Port}: {(p.Busy ? "ЗАНЯТ" : "свободен")}{(p.Description is null ? "" : " — " + p.Description)}{(p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Весы:");
|
||
sb.AppendLine($" порт {SelectedPort}, скорость {SelectedBaud}, протокол {SelectedProto}");
|
||
sb.AppendLine($" вес: {(_lastKg is null ? "—" : _lastKg.Value.ToString("0.000") + " кг")}; {_stable.Text}");
|
||
sb.AppendLine($" последний кадр (hex): {_hex.Text}");
|
||
sb.AppendLine($" статус: {_scaleStatus.Text}");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Принтеры ({_printerList.Count}):");
|
||
foreach (var p in _printerList)
|
||
sb.AppendLine($" {p.Name}{(p.IsDefault ? " [по умолчанию]" : "")} — порт {p.Port}, {(p.Offline ? "НЕ в сети" : "в сети")}, заданий {p.QueueJobs}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Сканер:");
|
||
sb.AppendLine($" {( string.IsNullOrEmpty(_scan.Text) ? "(не сканировали)" : _scan.Text )} — {_scanInfo.Text}");
|
||
return sb.ToString();
|
||
}
|
||
|
||
private void SaveReport()
|
||
{
|
||
try
|
||
{
|
||
var dir = AppContext.BaseDirectory;
|
||
var path = Path.Combine(dir, $"hardware-check-{DateTime.Now:yyyyMMdd-HHmmss}.txt");
|
||
File.WriteAllText(path, BuildReportText(), new UTF8Encoding(true));
|
||
_footer.Text = $"Отчёт сохранён: {path}";
|
||
}
|
||
catch (Exception ex) { _footer.Text = "Не удалось сохранить отчёт: " + ex.Message; }
|
||
}
|
||
|
||
private async void CopyReport()
|
||
{
|
||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||
if (cb is not null) { await cb.SetTextAsync(BuildReportText()); _footer.Text = "Отчёт скопирован в буфер обмена."; }
|
||
else _footer.Text = "Буфер обмена недоступен.";
|
||
}
|
||
}
|