food-market/src/food-market.pos.hwcheck/MainWindow.cs
nns 8371ef6683
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
docs(pos): фикс автопоиска весов (Bluetooth) проверен на Win11 + --autofind
- 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>
2026-10-11 09:09:37 +05:00

391 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = "Буфер обмена недоступен.";
}
}