From f15a12695c34b761aa07fcbf51b070f8336d7337 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 11 Oct 2026 09:03:45 +0500 Subject: [PATCH] =?UTF-8?q?fix(pos):=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B2=D0=B8=D1=81=D0=BD=D0=B5=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=20Bluetooth-=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D1=85=20(?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B3=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=81=D1=81=D0=B5=2011.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На ноутбуке магазина автопоиск спотыкался о Bluetooth-порты (COM7/COM8 — Open/Write висят), выбирал по умолчанию BT и не доходил до COM22 (USB-SERIAL CH340, где весы). Парсер/протокол исправны (ручной выбор COM22 57600 работает) — баг только в поиске. - ScaleAutodiscovery.DiscoverAsync: тирами по типу порта — USB-serial первыми, неопознанные в середине, Bluetooth последними; на каждый порт ЖЁСТКИЙ таймаут (Open обёрнут в задачу с гонкой против Task.Delay — SerialPort.Open на BT виснет); порты перебираются параллельно (до 4), общий бюджет ~15 с; любая ошибка/таймаут → следующий порт; по каждому порту строка в лог («COM22 CH340: ENQ→ACK, вес 0.000 кг», «COM7 Bluetooth: пропущен», «COM15: нет ответа»). - HardwareProbe: Classify (CH340/Prolific/FTDI/CP210x → USB-serial; BTHENUM/Bluetooth → Bluetooth; ttyUSB/ttyACM → USB-serial), ClassifyPorts (без открытия портов), OpenCloseBounded (жёсткий таймаут — листинг не виснет на BT). ListComPorts не открывает Bluetooth-порты. - HwCheck: по умолчанию выбирается НЕ Bluetooth-порт; окно показывает журнал автопоиска по портам; список портов помечает [USB-serial]/[Bluetooth]. - Основная касса (экран «Оборудование») — та же DiscoverScaleAsync; --probe-hw не открывает BT-порты. - Из zip hardware-check убран ярлык «(с тестовой печатью).bat» (печать — отдельно по слову владельца). - Тесты: зависающий порт (Open 30 с) не задерживает поиск > таймаута, живой порт вторым — найден; USB-serial проверяется раньше Bluetooth (BT не трогаем). 53/53. Co-Authored-By: Claude Opus 4.8 --- deploy/hwcheck-publish.sh | 11 +- .../Hardware/HardwareProbe.cs | 86 ++++++++- .../Hardware/ScaleDiscovery.cs | 164 ++++++++++++------ .../Hardware/SerialScale.cs | 4 +- src/food-market.pos.hwcheck/MainWindow.cs | 28 ++- src/food-market.pos/Services/AppServices.cs | 4 +- src/food-market.pos/Tooling/ProbeRunner.cs | 30 ++-- .../food-market.UnitTests/MassaKScaleTests.cs | 8 +- .../ScaleAutodiscoveryTests.cs | 79 +++++++++ 9 files changed, 327 insertions(+), 87 deletions(-) create mode 100644 tests/food-market.UnitTests/ScaleAutodiscoveryTests.cs diff --git a/deploy/hwcheck-publish.sh b/deploy/hwcheck-publish.sh index 3b095f5..4ecf20e 100644 --- a/deploy/hwcheck-publish.sh +++ b/deploy/hwcheck-publish.sh @@ -18,16 +18,12 @@ dotnet publish "$PROJ" -c Release -r "$RID" --self-contained true \ -p:PublishReadyToRun=true -p:Version="$VER" -p:DebugType=none \ -o "$DIR" >/tmp/hwcheck-publish.log 2>&1 || { tail -30 /tmp/hwcheck-publish.log; exit 1; } -# Ярлык-bat (без ключей — тестовая печать выключена) и README. +# Ярлык-bat (без ключей — тестовая печать выключена). Ярлык печати НЕ включаем: +# тестовую печать владелец включит отдельной сборкой/ключом по своему слову. cat > "$DIR/Проверка оборудования.bat" <<'BAT' @echo off start "" "%~dp0foodmarket.Pos.HwCheck.exe" BAT -# Отдельный bat для владельца, когда НУЖНА тестовая печать (по его слову). -cat > "$DIR/Проверка оборудования (с тестовой печатью).bat" <<'BAT' -@echo off -start "" "%~dp0foodmarket.Pos.HwCheck.exe" --allow-test-print -BAT cat > "$DIR/README.txt" <<'TXT' ALL MARKET — Проверка оборудования @@ -42,8 +38,7 @@ ALL MARKET — Проверка оборудования 6. «Сохранить отчёт» — текстовый файл рядом с программой; «Копировать отчёт» — в буфер обмена. Пришлите отчёт — по нему настроим кассу. -Тестовая печать включается отдельным ярлыком «(с тестовой печатью)» только по -согласованию с владельцем. +На принтер ничего не печатается. Тестовую печать включает владелец отдельно. TXT ( cd "$OUT" && zip -qr "allmarket-hardware-check-$RID-$VER.zip" "allmarket-hardware-check-$RID" ) diff --git a/src/food-market.pos.core/Hardware/HardwareProbe.cs b/src/food-market.pos.core/Hardware/HardwareProbe.cs index bc5e6f3..b624a72 100644 --- a/src/food-market.pos.core/Hardware/HardwareProbe.cs +++ b/src/food-market.pos.core/Hardware/HardwareProbe.cs @@ -11,24 +11,96 @@ namespace foodmarket.Pos.Core.Hardware; public static class HardwareProbe { public sealed record ComPortInfo(string Port, bool Busy, string? Description, - string? Vid, string? Pid, string? Manufacturer, string? OpenError); + string? Vid, string? Pid, string? Manufacturer, string? OpenError, PortKind Kind = PortKind.Unknown); public sealed record PrinterInfo(string Name, string? Port, string? Driver, bool IsDefault, bool Offline, int QueueJobs); - public static List ListComPorts() + /// Тип порта для приоритета автопоиска весов. Bluetooth-порты (BTHENUM, + /// «Стандартный последовательный порт по соединению Bluetooth») открываются/пишутся + /// с зависаниями — проверяем их последними. + public enum PortKind { UsbSerial, Unknown, Bluetooth } + + public sealed record PortDesc(string Port, PortKind Kind, string? Description); + + private static readonly string[] UsbSerialMarkers = + { "CH340", "CH341", "USB-SERIAL", "USB SERIAL", "PROLIFIC", "FTDI", "FT232", "CP210", "SILICON LABS", "USB2.0-SER", "USBSER" }; + private static readonly string[] BluetoothMarkers = + { "BLUETOOTH", "BTHENUM", "ПО СОЕДИНЕНИЮ BLUETOOTH", "RFCOMM", "STANDARD SERIAL OVER BLUETOOTH" }; + + public static PortKind Classify(string port, string? description) + { + var d = (description ?? "").ToUpperInvariant(); + var p = port.ToUpperInvariant(); + if (BluetoothMarkers.Any(m => d.Contains(m))) return PortKind.Bluetooth; + if (UsbSerialMarkers.Any(m => d.Contains(m))) return PortKind.UsbSerial; + // Linux: /dev/ttyUSB*, /dev/ttyACM* — обычно USB-serial. + if (p.Contains("TTYUSB") || p.Contains("TTYACM")) return PortKind.UsbSerial; + return PortKind.Unknown; + } + + /// Список портов с типом и описанием БЕЗ открытия портов (чтобы не висеть + /// на Bluetooth). Для приоритета автопоиска: USB-serial → unknown → Bluetooth. + public static List ClassifyPorts() { var wmi = OperatingSystem.IsWindows() ? QueryComWmi() : new(); var names = SafePortNames(); foreach (var w in wmi) if (!names.Contains(w.port, StringComparer.OrdinalIgnoreCase)) names.Add(w.port); names = names.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(NaturalOrder).ToList(); - - var result = new List(); + var list = new List(); foreach (var p in names) { - var w = wmi.FirstOrDefault(x => string.Equals(x.port, p, StringComparison.OrdinalIgnoreCase)); - var (busy, err) = TryOpenClose(p); - result.Add(new ComPortInfo(p, busy, w.desc, w.vid, w.pid, w.manuf, err)); + var desc = wmi.FirstOrDefault(x => string.Equals(x.port, p, StringComparison.OrdinalIgnoreCase)).desc; + list.Add(new PortDesc(p, Classify(p, desc), desc)); + } + // порядок: USB-serial, unknown, Bluetooth + return list.OrderBy(x => x.Kind switch { PortKind.UsbSerial => 0, PortKind.Unknown => 1, _ => 2 }) + .ThenBy(x => NaturalOrder(x.Port)).ToList(); + } + + /// Открыть→закрыть с ЖЁСТКИМ таймаутом (SerialPort.Open на Bluetooth-порту + /// может висеть). true busy при Access denied; timedout — порт не ответил на открытие. + public static (bool busy, bool timedout, string? err) OpenCloseBounded(string port, int timeoutMs = 1000) + { + SerialPort? spRef = null; + var task = Task.Run(() => + { + var sp = new SerialPort(port) { ReadTimeout = 300, WriteTimeout = 300 }; + spRef = sp; + sp.Open(); sp.Close(); + }); + if (!task.Wait(timeoutMs)) + { + try { spRef?.Dispose(); } catch { } // разблокировать зависший Open + return (false, true, $"таймаут открытия (> {timeoutMs} мс)"); + } + if (task.IsFaulted) + { + var ex = task.Exception?.GetBaseException(); + if (ex is UnauthorizedAccessException) return (true, false, "занят другой программой (Access denied)"); + return (false, false, ex?.GetType().Name + ": " + ex?.Message); + } + return (false, false, null); + } + + public static List ListComPorts() + { + var wmi = OperatingSystem.IsWindows() ? QueryComWmi() : new(); + // Порядок/тип — через ClassifyPorts (без открытия). Занятость — с жёстким + // таймаутом (Bluetooth-порт может висеть на Open). + var result = new List(); + foreach (var pc in ClassifyPorts()) + { + var w = wmi.FirstOrDefault(x => string.Equals(x.port, pc.Port, StringComparison.OrdinalIgnoreCase)); + // Bluetooth-порты не открываем при листинге — только помечаем. + if (pc.Kind == PortKind.Bluetooth) + { + result.Add(new ComPortInfo(pc.Port, false, pc.Description ?? w.desc, w.vid, w.pid, w.manuf, + "Bluetooth-порт (при листинге не открывается)", pc.Kind)); + continue; + } + var (busy, _, err) = OpenCloseBounded(pc.Port, 1000); + result.Add(new ComPortInfo(pc.Port, busy, pc.Description ?? w.desc, w.vid, w.pid, w.manuf, err, pc.Kind)); } return result; } diff --git a/src/food-market.pos.core/Hardware/ScaleDiscovery.cs b/src/food-market.pos.core/Hardware/ScaleDiscovery.cs index a518b41..e9b8105 100644 --- a/src/food-market.pos.core/Hardware/ScaleDiscovery.cs +++ b/src/food-market.pos.core/Hardware/ScaleDiscovery.cs @@ -11,12 +11,12 @@ public static class SerialScaleReader { /// Подробное чтение (для диагностики): кадр, hex, флаг, control-байт. public static (MassaK.Reading? reading, string rawHex, string? error) ReadDetailed( - ISerialTransport t, ScaleProto proto) + ISerialTransport t, ScaleProto proto, int ackTimeoutMs = 400, int frameTimeoutMs = 700, int handshakeAttempts = 2) { switch (proto) { case ScaleProto.MassaKFrame: - return MassaK.ReadWeight(t); + return MassaK.ReadWeight(t, ackTimeoutMs, frameTimeoutMs, handshakeAttempts); case ScaleProto.MassaKAscii: case ScaleProto.Cas: { @@ -80,71 +80,133 @@ private static string ReadAsciiLine(ISerialTransport t, int totalTimeoutMs) } } -/// Автопоиск весов по COM-портам. 57600 и Масса-К-кадр — ПЕРВЫМИ (реальный -/// случай владельца). Общий бюджет ограничен; занятый порт отмечается отдельно, -/// чтобы сказать кассиру «порт занят», а не «весы не найдены». +/// Автопоиск весов по COM-портам. Исправление по реальному запуску (11.10): +/// автопоиск НЕ должен спотыкаться о Bluetooth-порты (Open/Write на них висят). Поэтому: +/// • USB-serial порты (CH340/Prolific/FTDI/CP210x) проверяются ПЕРВЫМИ, Bluetooth — +/// последними; • на каждый порт — жёсткий таймаут (Open обёрнут в задачу с гонкой); +/// • порты перебираются параллельно (ограниченно), общий бюджет ограничен; +/// • любая ошибка/таймаут на порту → следующий порт; по каждому порту — строка в лог. public static class ScaleAutodiscovery { public sealed record Found(string Port, int Baud, ScaleProto Proto, decimal SampleKg, string RawHex); public sealed record Result(Found? Device, IReadOnlyList BusyPorts, IReadOnlyList ScannedPorts); - // 57600 первой (реальные весы владельца); дальше — типовые скорости розницы. - public static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 }; - public static readonly ScaleProto[] Protos = { ScaleProto.MassaKFrame, ScaleProto.MassaKAscii, ScaleProto.Cas }; + // Комбинации на порт (быстро): Масса-К-кадр 57600 (реальные весы владельца), затем 9600. + private static readonly (ScaleProto proto, int baud)[] Attempts = + { (ScaleProto.MassaKFrame, 57600), (ScaleProto.MassaKFrame, 9600) }; - public static string[] ListPorts() - { - try { return SerialPort.GetPortNames().Distinct().OrderBy(NaturalOrder).ToArray(); } - catch { return Array.Empty(); } - } - private static int NaturalOrder(string p) - => int.TryParse(new string(p.Where(char.IsDigit).ToArray()), out var n) ? n : 9999; + /// Список портов с типом (для теста можно подменить). + public static List ListPorts() => HardwareProbe.ClassifyPorts(); - /// Перебрать порты × протоколы × скорости. Для Масса-К-кадра достаточно - /// быстрого ENQ→ACK: нет ACK → сразу следующая комбинация (быстро). Бюджет общий. + private sealed class Holder { public ISerialTransport? T; public void KillQuietly() { try { T?.Dispose(); } catch { } } } + + private static string DescSuffix(HardwareProbe.PortDesc pc) + => pc.Kind == HardwareProbe.PortKind.UsbSerial ? " (USB-serial)" + : pc.Kind == HardwareProbe.PortKind.Bluetooth ? " (Bluetooth)" + : pc.Description is null ? "" : $" ({pc.Description})"; + + /// Синхронная обёртка (для существующих вызовов). public static Result Discover(Action? log = null, Func? transportFactory = null, - int budgetMs = 12000, CancellationToken ct = default) + int budgetMs = 15000, CancellationToken ct = default) + => DiscoverAsync(log, transportFactory, null, 1300, 4, budgetMs, false, ct).GetAwaiter().GetResult(); + + /// true — Bluetooth-порты не опрашивать совсем (только лог). + public static async Task DiscoverAsync( + Action? log = null, + Func? transportFactory = null, + IReadOnlyList? portsOverride = null, + int perPortMs = 1300, int maxParallel = 4, int budgetMs = 15000, + bool skipBluetooth = false, CancellationToken ct = default) { var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b)); - var sw = Stopwatch.StartNew(); - var busy = new List(); - var scanned = new List(); - foreach (var port in ListPorts()) - { - if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break; - scanned.Add(port); - // быстрая проверка занятости - try { using var probe = make(port, Bauds[0]); probe.Open(); } - catch (UnauthorizedAccessException) { busy.Add(port); log?.Invoke($"{port}: занят другой программой"); continue; } - catch { /* не открылся по иной причине — всё равно попробуем протоколы ниже */ } + var ports = portsOverride ?? ListPorts(); + var busy = new System.Collections.Concurrent.ConcurrentBag(); + var scanned = new System.Collections.Concurrent.ConcurrentBag(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(budgetMs); + Found? found = null; + var gate = new object(); - foreach (var proto in Protos) + // Тирами по приоритету: USB-serial → unknown → Bluetooth. Нашли в тире — дальше не идём. + foreach (var tier in new[] { HardwareProbe.PortKind.UsbSerial, HardwareProbe.PortKind.Unknown, HardwareProbe.PortKind.Bluetooth }) + { + if (found is not null || cts.IsCancellationRequested) break; + var tierPorts = ports.Where(p => p.Kind == tier).ToList(); + if (tierPorts.Count == 0) continue; + if (tier == HardwareProbe.PortKind.Bluetooth && skipBluetooth) { - foreach (var baud in Bauds) + foreach (var pc in tierPorts) log?.Invoke($"{pc.Port}: пропущен Bluetooth-порт"); + continue; + } + using var sem = new SemaphoreSlim(Math.Max(1, maxParallel)); + var tasks = tierPorts.Select(async pc => + { + await sem.WaitAsync(cts.Token).ConfigureAwait(false); + try { - if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break; - // ASCII-режимы на экзотических скоростях не перебираем — только типовые - if (proto != ScaleProto.MassaKFrame && baud is 57600 or 38400) continue; - try - { - using var t = make(port, baud); - t.Open(); - var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto); - if (reading is not null && reading.Value.Kg >= 0m) - { - log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {reading.Value.Kg:0.###} кг; кадр {hex})"); - return new Result(new Found(port, baud, proto, reading.Value.Kg, hex), busy, scanned); - } - } - catch (UnauthorizedAccessException) { if (!busy.Contains(port)) busy.Add(port); break; } - catch { /* нет ответа — следующая комбинация */ } + if (found is not null || cts.IsCancellationRequested) return; + scanned.Add(pc.Port); + var f = await ProbePortAsync(pc, make, perPortMs, log, busy, cts.Token).ConfigureAwait(false); + if (f is not null) { lock (gate) { if (found is null) { found = f; } } cts.Cancel(); } + } + catch (OperationCanceledException) { } + finally { sem.Release(); } + }).ToList(); + try { await Task.WhenAll(tasks).ConfigureAwait(false); } catch (OperationCanceledException) { } + } + + if (found is not null) + log?.Invoke($"Весы найдены: {found.Port}, {found.Baud}, {found.Proto} (вес {found.SampleKg:0.###} кг)."); + else + log?.Invoke(busy.Any() + ? $"Весы не найдены; заняты порты: {string.Join(", ", busy.Distinct())}." + : "Весы не найдены ни на одном порту."); + return new Result(found, busy.Distinct().ToList(), scanned.Distinct().ToList()); + } + + /// Проба одного порта с ЖЁСТКИМ таймаутом: Open может висеть (Bluetooth), + /// поэтому работа — в задаче, которую гоняем против Task.Delay(perPortMs); при + /// таймауте транспорт принудительно закрываем, чтобы разблокировать. + private static async Task ProbePortAsync(HardwareProbe.PortDesc pc, + Func make, int perPortMs, Action? log, + System.Collections.Concurrent.ConcurrentBag busy, CancellationToken ct) + { + var holder = new Holder(); + var work = Task.Run(() => ProbePortSync(pc, make, holder, log, busy), ct); + var done = await Task.WhenAny(work, Task.Delay(perPortMs, ct)).ConfigureAwait(false); + if (done != work) + { + holder.KillQuietly(); // разблокировать зависший Open/Write + log?.Invoke($"{pc.Port}{DescSuffix(pc)}: таймаут (> {perPortMs} мс) — пропущен"); + return null; + } + try { return await work.ConfigureAwait(false); } catch { return null; } + } + + private static Found? ProbePortSync(HardwareProbe.PortDesc pc, + Func make, Holder holder, Action? log, + System.Collections.Concurrent.ConcurrentBag busy) + { + foreach (var (proto, baud) in Attempts) + { + ISerialTransport? t = null; + try + { + t = make(pc.Port, baud); holder.T = t; + t.Open(); + var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto, ackTimeoutMs: 300, frameTimeoutMs: 500, handshakeAttempts: 1); + if (reading is not null && reading.Value.Kg >= 0m) + { + log?.Invoke($"{pc.Port}{DescSuffix(pc)}: ENQ→ACK, вес {reading.Value.Kg:0.###} {reading.Value.Unit} ({proto} {baud})"); + return new Found(pc.Port, baud, proto, reading.Value.Kg, hex); } } + catch (UnauthorizedAccessException) { busy.Add(pc.Port); log?.Invoke($"{pc.Port}{DescSuffix(pc)}: занят другой программой"); return null; } + catch { /* нет ответа на этой комбинации — следующая */ } + finally { try { t?.Dispose(); } catch { } holder.T = null; } } - log?.Invoke(busy.Count > 0 - ? $"Весы не найдены; заняты порты: {string.Join(", ", busy)}" - : "Весы: автопоиск не нашёл весов"); - return new Result(null, busy, scanned); + log?.Invoke($"{pc.Port}{DescSuffix(pc)}: нет ответа"); + return null; } } diff --git a/src/food-market.pos.core/Hardware/SerialScale.cs b/src/food-market.pos.core/Hardware/SerialScale.cs index 9dcf97f..81a91c2 100644 --- a/src/food-market.pos.core/Hardware/SerialScale.cs +++ b/src/food-market.pos.core/Hardware/SerialScale.cs @@ -163,9 +163,9 @@ public static byte[] ReadFrame(ISerialTransport t, int totalTimeoutMs = 700, int /// Полный цикл: handshake → DC1 → кадр → разбор. Возвращает (кадр, hex, /// ошибка). Транспорт должен быть уже открыт. public static (Reading? reading, string rawHex, string? error) ReadWeight(ISerialTransport t, - int ackTimeoutMs = 400, int frameTimeoutMs = 700) + int ackTimeoutMs = 400, int frameTimeoutMs = 700, int handshakeAttempts = 2) { - if (!Handshake(t, ackTimeoutMs)) return (null, "", "весы не ответили ACK на ENQ"); + if (!Handshake(t, ackTimeoutMs, handshakeAttempts)) return (null, "", "весы не ответили ACK на ENQ"); t.Write(new[] { DC1 }, 0, 1); var frame = ReadFrame(t, frameTimeoutMs); if (frame.Length == 0) return (null, "", "нет ответа на DC1 (пустой кадр)"); diff --git a/src/food-market.pos.hwcheck/MainWindow.cs b/src/food-market.pos.hwcheck/MainWindow.cs index 2e8119a..83f0b4d 100644 --- a/src/food-market.pos.hwcheck/MainWindow.cs +++ b/src/food-market.pos.hwcheck/MainWindow.cs @@ -31,6 +31,7 @@ public sealed class MainWindow : Window 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 }; @@ -141,6 +142,8 @@ private Control ScaleCard() 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); } @@ -231,11 +234,15 @@ private async Task ReadOnce() 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(); - _scaleStatus.Text = "Ищу весы (57600 и Масса-К-кадр — первыми)…"; - var res = await Task.Run(() => ScaleAutodiscovery.Discover()); + _log.Text = ""; + _scaleStatus.Text = "Ищу весы: USB-serial первыми, Bluetooth последними, с таймаутом на порт…"; + var res = await ScaleAutodiscovery.DiscoverAsync(log: AppendLog); if (res.Device is not null) { var d = res.Device; @@ -261,16 +268,23 @@ private async void DiscoverScale() private void RefreshPorts() { - _portList = HardwareProbe.ListComPorts(); + _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}: {(p.Busy ? "ЗАНЯТ другой программой" : "свободен")}" - + (p.Description is null ? "" : $" — {p.Description}") - + (p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")).ToArray(); + $"{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; - else if (names.Length > 0) _portBox.SelectedIndex = 0; + // по умолчанию — первый НЕ-Bluetooth порт (список уже с USB-serial в начале) + else _portBox.SelectedItem = _portList.FirstOrDefault(p => p.Kind != HardwareProbe.PortKind.Bluetooth)?.Port + ?? (names.Length > 0 ? names[0] : null); } private void RefreshPrinters() diff --git a/src/food-market.pos/Services/AppServices.cs b/src/food-market.pos/Services/AppServices.cs index f08cac2..bb66575 100644 --- a/src/food-market.pos/Services/AppServices.cs +++ b/src/food-market.pos/Services/AppServices.cs @@ -100,7 +100,9 @@ public void SetScale(string port, int baud, ScaleProto proto) /// успехе — запоминаем. Если сохранённые весы не отвечают — автопоиск молча. public async Task DiscoverScaleAsync(CancellationToken ct = default) { - var res = await Task.Run(() => ScaleAutodiscovery.Discover(AppendScaleLog, ct: ct), ct); + // USB-serial первыми, Bluetooth последними, жёсткий таймаут на порт, параллельно — + // чтобы не висеть на Bluetooth-портах и дойти до CH340 (реальный баг 11.10). + var res = await ScaleAutodiscovery.DiscoverAsync(AppendScaleLog, ct: ct); if (res.Device is null) return false; SetScale(res.Device.Port, res.Device.Baud, res.Device.Proto); return true; diff --git a/src/food-market.pos/Tooling/ProbeRunner.cs b/src/food-market.pos/Tooling/ProbeRunner.cs index 144dfc3..27da839 100644 --- a/src/food-market.pos/Tooling/ProbeRunner.cs +++ b/src/food-market.pos/Tooling/ProbeRunner.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using foodmarket.Pos.Hardware; using foodmarket.Pos.Core.Hardware; +using ScaleHw = foodmarket.Pos.Core.Hardware.HardwareProbe; namespace foodmarket.Pos.Tooling; @@ -58,17 +59,26 @@ public static int Run(string[] args) Pid = wi?.Pid, Manufacturer = wi?.Manufacturer, }; - // открыть→закрыть, без удержания - var (busy, openErr) = TryOpenClose(port); - p.Busy = busy; - p.OpenError = openErr; - // опрос весов только на свободном порту и пока есть бюджет времени - if (!busy && sw.Elapsed < Budget - TimeSpan.FromSeconds(3)) - p.Scale = ProbeScale(port, sw); - else if (busy) - p.Scale = "порт занят другим приложением — не опрашивался"; + var kind = ScaleHw.Classify(port, p.Description); + // Bluetooth-порты не открываем: Open/Write на них висят (реальный баг 11.10). + if (kind == ScaleHw.PortKind.Bluetooth) + { + p.Busy = false; + p.Scale = "Bluetooth-порт — не опрашивался"; + } else - p.Scale = "пропущен (лимит времени)"; + { + // открыть→закрыть с жёстким таймаутом, без удержания + var (busy, _, openErr) = ScaleHw.OpenCloseBounded(port, 1000); + p.Busy = busy; + p.OpenError = openErr; + if (!busy && sw.Elapsed < Budget - TimeSpan.FromSeconds(3)) + p.Scale = ProbeScale(port, sw); + else if (busy) + p.Scale = "порт занят другим приложением — не опрашивался"; + else + p.Scale = "пропущен (лимит времени)"; + } report.Ports.Add(p); } diff --git a/tests/food-market.UnitTests/MassaKScaleTests.cs b/tests/food-market.UnitTests/MassaKScaleTests.cs index 5371bb1..a15cb9d 100644 --- a/tests/food-market.UnitTests/MassaKScaleTests.cs +++ b/tests/food-market.UnitTests/MassaKScaleTests.cs @@ -18,10 +18,16 @@ public sealed class FakeScaleTransport : ISerialTransport public bool AnswerAck { get; set; } = true; // отвечает ли ACK на ENQ public byte[]? Frame { get; set; } // кадр, выдаваемый на DC1 public bool Opening { get; set; } // бросить при Open (порт занят и т.п.) + public int OpenDelayMs { get; set; } // «зависание» на Open (Bluetooth-порт) public void PreloadRx(params byte[] bytes) { foreach (var b in bytes) _rx.Enqueue(b); } - public void Open() { if (Opening) throw new System.UnauthorizedAccessException("busy"); Opened = true; } + public void Open() + { + if (OpenDelayMs > 0) System.Threading.Thread.Sleep(OpenDelayMs); + if (Opening) throw new System.UnauthorizedAccessException("busy"); + Opened = true; + } public void DiscardBuffers() { } public void Write(byte[] data, int offset, int count) { diff --git a/tests/food-market.UnitTests/ScaleAutodiscoveryTests.cs b/tests/food-market.UnitTests/ScaleAutodiscoveryTests.cs new file mode 100644 index 0000000..4719975 --- /dev/null +++ b/tests/food-market.UnitTests/ScaleAutodiscoveryTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using foodmarket.Pos.Core.Hardware; +using Xunit; +using PortKind = foodmarket.Pos.Core.Hardware.HardwareProbe.PortKind; +using PortDesc = foodmarket.Pos.Core.Hardware.HardwareProbe.PortDesc; + +public class ScaleAutodiscoveryTests +{ + private static byte[] Frame(string payload, byte control = 0x71) + { + var body = new List { MassaK.SOH, MassaK.STX }; + body.AddRange(Encoding.ASCII.GetBytes(payload)); + body.Add(control); body.Add(MassaK.ETX); body.Add(MassaK.EOT); + return body.ToArray(); + } + + // Требование 5: «зависающий» порт не должен задерживать поиск дольше таймаута; + // живой порт вторым — должен быть найден. + [Fact] + public async Task Hanging_port_does_not_block_discovery_of_live_port() + { + var ports = new List + { + new("COMHANG", PortKind.Unknown, "зависает на Open"), + new("COMLIVE", PortKind.Unknown, "живой"), + }; + var live = Frame("S 1.235kg"); + foodmarket.Pos.Core.Hardware.ISerialTransport Make(string p, int b) => + p == "COMHANG" ? new foodmarket.UnitTests.FakeScaleTransport { OpenDelayMs = 30000 } + : new foodmarket.UnitTests.FakeScaleTransport { AnswerAck = true, Frame = live }; + + var sw = Stopwatch.StartNew(); + var res = await ScaleAutodiscovery.DiscoverAsync( + log: null, transportFactory: Make, portsOverride: ports, + perPortMs: 1500, maxParallel: 4, budgetMs: 15000); + sw.Stop(); + + res.Device.Should().NotBeNull(); + res.Device!.Port.Should().Be("COMLIVE"); + res.Device.SampleKg.Should().Be(1.235m); + sw.ElapsedMilliseconds.Should().BeLessThan(5000); // не ждём 30 с зависшего порта + } + + // Требование 2/3: USB-serial проверяется раньше Bluetooth — находим весы на CH340, + // не трогая Bluetooth-порт первым. + [Fact] + public async Task UsbSerial_tier_is_probed_before_bluetooth() + { + var ports = new List + { + new("COM7", PortKind.Bluetooth, "Bluetooth"), + new("COM22", PortKind.UsbSerial, "USB-SERIAL CH340"), + }; + var btTouched = false; + var live = Frame("S 0.000kg"); + foodmarket.Pos.Core.Hardware.ISerialTransport Make(string p, int b) + { + if (p == "COM7") { btTouched = true; return new foodmarket.UnitTests.FakeScaleTransport { OpenDelayMs = 30000 }; } + return new foodmarket.UnitTests.FakeScaleTransport { AnswerAck = true, Frame = live }; + } + var res = await ScaleAutodiscovery.DiscoverAsync(transportFactory: Make, portsOverride: ports); + res.Device!.Port.Should().Be("COM22"); + btTouched.Should().BeFalse("USB-serial найден в первом тире — до Bluetooth дело не дошло"); + } + + [Fact] + public async Task No_device_reports_cleanly() + { + var ports = new List { new("COMX", PortKind.Unknown, "пусто") }; + foodmarket.Pos.Core.Hardware.ISerialTransport Make(string p, int b) + => new foodmarket.UnitTests.FakeScaleTransport { AnswerAck = false }; + var res = await ScaleAutodiscovery.DiscoverAsync(transportFactory: Make, portsOverride: ports, perPortMs: 400); + res.Device.Should().BeNull(); + } +}