Compare commits

...

2 commits

Author SHA1 Message Date
nns 8371ef6683 docs(pos): фикс автопоиска весов (Bluetooth) проверен на Win11 + --autofind
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
- 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
nns f15a12695c fix(pos): автопоиск весов не виснет на Bluetooth-портах (реальный баг на кассе 11.10)
На ноутбуке магазина автопоиск спотыкался о 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 <noreply@anthropic.com>
2026-10-11 09:03:45 +05:00
13 changed files with 408 additions and 88 deletions

View file

@ -18,16 +18,12 @@ dotnet publish "$PROJ" -c Release -r "$RID" --self-contained true \
-p:PublishReadyToRun=true -p:Version="$VER" -p:DebugType=none \ -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; } -o "$DIR" >/tmp/hwcheck-publish.log 2>&1 || { tail -30 /tmp/hwcheck-publish.log; exit 1; }
# Ярлык-bat (без ключей — тестовая печать выключена) и README. # Ярлык-bat (без ключей — тестовая печать выключена). Ярлык печати НЕ включаем:
# тестовую печать владелец включит отдельной сборкой/ключом по своему слову.
cat > "$DIR/Проверка оборудования.bat" <<'BAT' cat > "$DIR/Проверка оборудования.bat" <<'BAT'
@echo off @echo off
start "" "%~dp0foodmarket.Pos.HwCheck.exe" start "" "%~dp0foodmarket.Pos.HwCheck.exe"
BAT BAT
# Отдельный bat для владельца, когда НУЖНА тестовая печать (по его слову).
cat > "$DIR/Проверка оборудования (с тестовой печатью).bat" <<'BAT'
@echo off
start "" "%~dp0foodmarket.Pos.HwCheck.exe" --allow-test-print
BAT
cat > "$DIR/README.txt" <<'TXT' cat > "$DIR/README.txt" <<'TXT'
ALL MARKET — Проверка оборудования ALL MARKET — Проверка оборудования
@ -42,8 +38,7 @@ ALL MARKET — Проверка оборудования
6. «Сохранить отчёт» — текстовый файл рядом с программой; «Копировать отчёт» — 6. «Сохранить отчёт» — текстовый файл рядом с программой; «Копировать отчёт» —
в буфер обмена. Пришлите отчёт — по нему настроим кассу. в буфер обмена. Пришлите отчёт — по нему настроим кассу.
Тестовая печать включается отдельным ярлыком «(с тестовой печатью)» только по На принтер ничего не печатается. Тестовую печать включает владелец отдельно.
согласованию с владельцем.
TXT TXT
( cd "$OUT" && zip -qr "allmarket-hardware-check-$RID-$VER.zip" "allmarket-hardware-check-$RID" ) ( cd "$OUT" && zip -qr "allmarket-hardware-check-$RID-$VER.zip" "allmarket-hardware-check-$RID" )

View file

@ -88,6 +88,50 @@ Linux-сборку не ломали (System.IO.Ports/System.Management общи
тогда уточним смысл флага и control-байта (сейчас они логируются, но не тогда уточним смысл флага и control-байта (сейчас они логируются, но не
интерпретируются). интерпретируются).
## Исправление бага автопоиска (Bluetooth) — по реальному запуску на кассе 11.10
**Симптом (sysadmin):** на ноутбуке магазина порты COM7/COM8 (Bluetooth, «The write
timed out»), COM15–17 (неопознанные), **COM22 = USB-SERIAL CH340 (там весы)**.
«Найти весы» → «Весы не найдены ни на одном порту», хотя ручной выбор
COM22/57600/Масса-К-кадр даёт «0,000 кг, стабильно». Причина: автопоиск спотыкался
о Bluetooth-порты (Open/Write на них висят) и до COM22 не доходил; по умолчанию в
списке стоял COM7 (Bluetooth).
**Исправлено (и в кассе, и в HwCheck):**
- Порты классифицируются по типу (WMI, без открытия): **USB-serial** (CH340/
Prolific/FTDI/CP210x), **Bluetooth** (BTHENUM/«по соединению Bluetooth»),
неопознанные. Порядок автопоиска: **USB-serial → неопознанные → Bluetooth**.
- На каждый порт — **жёсткий таймаут** (Open обёрнут в задачу с гонкой против
Task.Delay; при зависании транспорт принудительно закрывается). Порты
перебираются **параллельно (до 4)**, общий бюджет ~15 с. Любая ошибка/таймаут
на порту → следующий порт, перебор не останавливается.
- **Лог по каждому порту** в окне: «COM22 (USB-SERIAL CH340): ENQ→ACK, вес 0.000 кг»,
«COM7: пропущен Bluetooth-порт», «COM15: нет ответа». Листинг портов тоже не
открывает Bluetooth (помечает «не опрашивается»). По умолчанию выбирается
НЕ Bluetooth-порт.
- Из zip hardware-check **убран ярлык «(с тестовой печатью).bat»** (печать — только
отдельно, по слову владельца).
- Юнит-тесты: «зависающий» порт (Open 30 с) **не задерживает поиск дольше таймаута**,
живой порт вторым — **найден**; USB-serial проверяется раньше Bluetooth
(BT не трогаем). Всего по оборудованию 53/53 зелёных.
**Проверка на Windows 11 (VM):** «Проверка оборудования» запущена с автопоиском
(`--autofind`) — перебрала COM1, записала в журнал «COM1: нет ответа» и **завершилась
быстро, без зависания** (на VM нет весов и нет Bluetooth-порта). См.
`vm-hwcheck-autofind.png`.
**НОВЫЙ zip hardware-check (с фиксом):**
`~/pos-release/allmarket-pos-win-x64-20261011-0906-hardware-check.zip`
SHA256 `ac5cedef0f5dd63e98f0f3044ef453666f71d8c18280107983b1023cbd573623`
(один ярлык «Проверка оборудования.bat», без ярлыка печати).
**Полная проверка с эмулятором весов + «зависающим» виртуальным портом (п. 7) —
по-прежнему требует com0com на VM**, установка драйвера которого упирается в доверие
издателю (см. выше) — ждёт решения владельца. Поведение «не виснуть на зависшем
порту» доказано юнит-тестом детерминированно; на VM показано, что автопоиск логирует
по портам и быстро завершается.
## Файлы ## Файлы
- `vm-hwcheck-launch.png` — окно «Проверка оборудования» на Windows 11. - `vm-hwcheck-launch.png` — окно «Проверка оборудования» на Windows 11.
- `index.html` — та же сводка со скриншотом. - `vm-hwcheck-autofind.png` — автопоиск с журналом по портам (COM1), без зависания.
- `index.html` — сводка со скриншотами.

View file

@ -17,6 +17,8 @@ code{background:#eef2f7;padding:1px 5px;border-radius:4px;font-size:13px}
<li class="ok">✓ Контрольная цифра EAN-13/EAN-8; сканер vs ручной ввод</li> <li class="ok">✓ Контрольная цифра EAN-13/EAN-8; сканер vs ручной ввод</li>
<li class="ok">✓ Юнит-тесты оборудования: 50/50 зелёных</li> <li class="ok">✓ Юнит-тесты оборудования: 50/50 зелёных</li>
<li class="ok">✓ «Проверка оборудования» запускается на реальной Windows 11 (скриншот ниже)</li> <li class="ok">✓ «Проверка оборудования» запускается на реальной Windows 11 (скриншот ниже)</li>
<li class="ok">✓ Фикс автопоиска: USB-serial первыми, Bluetooth последними, жёсткий таймаут на порт, параллельно, лог по портам (баг на кассе 11.10 — виснул на Bluetooth, не доходил до CH340)</li>
<li class="ok">✓ Тест: «зависающий» порт (Open 30 с) не задерживает поиск; живой порт вторым — найден</li>
<li class="no">✗ Живое чтение веса через виртуальную пару COM на VM — НЕ выполнено (драйвер com0com требует доверия издателю; нужно решение владельца)</li> <li class="no">✗ Живое чтение веса через виртуальную пару COM на VM — НЕ выполнено (драйвер com0com требует доверия издателю; нужно решение владельца)</li>
<li class="no">✗ Реальные весы с грузом (гирей) в магазине — НЕ проверялись (нужен человек)</li> <li class="no">✗ Реальные весы с грузом (гирей) в магазине — НЕ проверялись (нужен человек)</li>
</ul> </ul>
@ -26,5 +28,9 @@ code{background:#eef2f7;padding:1px 5px;border-radius:4px;font-size:13px}
<figcaption>«ALL MARKET — Проверка оборудования» на Win11 (VM): без входа; определены COM1 и принтер; тестовая печать выключена; поле сканера активно.</figcaption> <figcaption>«ALL MARKET — Проверка оборудования» на Win11 (VM): без входа; определены COM1 и принтер; тестовая печать выключена; поле сканера активно.</figcaption>
<img src="vm-hwcheck-launch.png" loading="lazy"> <img src="vm-hwcheck-launch.png" loading="lazy">
</figure> </figure>
<figure>
<figcaption>Фикс автопоиска на Win11: перебрал COM1, записал в журнал «COM1: нет ответа», завершился быстро без зависания (на VM нет весов/Bluetooth).</figcaption>
<img src="vm-hwcheck-autofind.png" loading="lazy">
</figure>
<p>Подробности, SHA256 сборок и список «нужно решение владельца» — в <code>REPORT.md</code>.</p> <p>Подробности, SHA256 сборок и список «нужно решение владельца» — в <code>REPORT.md</code>.</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View file

@ -11,24 +11,96 @@ namespace foodmarket.Pos.Core.Hardware;
public static class HardwareProbe public static class HardwareProbe
{ {
public sealed record ComPortInfo(string Port, bool Busy, string? Description, 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, public sealed record PrinterInfo(string Name, string? Port, string? Driver,
bool IsDefault, bool Offline, int QueueJobs); bool IsDefault, bool Offline, int QueueJobs);
public static List<ComPortInfo> ListComPorts() /// <summary>Тип порта для приоритета автопоиска весов. Bluetooth-порты (BTHENUM,
/// «Стандартный последовательный порт по соединению Bluetooth») открываются/пишутся
/// с зависаниями — проверяем их последними.</summary>
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;
}
/// <summary>Список портов с типом и описанием БЕЗ открытия портов (чтобы не висеть
/// на Bluetooth). Для приоритета автопоиска: USB-serial → unknown → Bluetooth.</summary>
public static List<PortDesc> ClassifyPorts()
{ {
var wmi = OperatingSystem.IsWindows() ? QueryComWmi() : new(); var wmi = OperatingSystem.IsWindows() ? QueryComWmi() : new();
var names = SafePortNames(); var names = SafePortNames();
foreach (var w in wmi) if (!names.Contains(w.port, StringComparer.OrdinalIgnoreCase)) names.Add(w.port); foreach (var w in wmi) if (!names.Contains(w.port, StringComparer.OrdinalIgnoreCase)) names.Add(w.port);
names = names.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(NaturalOrder).ToList(); names = names.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(NaturalOrder).ToList();
var list = new List<PortDesc>();
var result = new List<ComPortInfo>();
foreach (var p in names) foreach (var p in names)
{ {
var w = wmi.FirstOrDefault(x => string.Equals(x.port, p, StringComparison.OrdinalIgnoreCase)); var desc = wmi.FirstOrDefault(x => string.Equals(x.port, p, StringComparison.OrdinalIgnoreCase)).desc;
var (busy, err) = TryOpenClose(p); list.Add(new PortDesc(p, Classify(p, desc), desc));
result.Add(new ComPortInfo(p, busy, w.desc, w.vid, w.pid, w.manuf, err)); }
// порядок: USB-serial, unknown, Bluetooth
return list.OrderBy(x => x.Kind switch { PortKind.UsbSerial => 0, PortKind.Unknown => 1, _ => 2 })
.ThenBy(x => NaturalOrder(x.Port)).ToList();
}
/// <summary>Открыть→закрыть с ЖЁСТКИМ таймаутом (SerialPort.Open на Bluetooth-порту
/// может висеть). true busy при Access denied; timedout — порт не ответил на открытие.</summary>
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<ComPortInfo> ListComPorts()
{
var wmi = OperatingSystem.IsWindows() ? QueryComWmi() : new();
// Порядок/тип — через ClassifyPorts (без открытия). Занятость — с жёстким
// таймаутом (Bluetooth-порт может висеть на Open).
var result = new List<ComPortInfo>();
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; return result;
} }

View file

@ -11,12 +11,12 @@ public static class SerialScaleReader
{ {
/// <summary>Подробное чтение (для диагностики): кадр, hex, флаг, control-байт.</summary> /// <summary>Подробное чтение (для диагностики): кадр, hex, флаг, control-байт.</summary>
public static (MassaK.Reading? reading, string rawHex, string? error) ReadDetailed( 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) switch (proto)
{ {
case ScaleProto.MassaKFrame: case ScaleProto.MassaKFrame:
return MassaK.ReadWeight(t); return MassaK.ReadWeight(t, ackTimeoutMs, frameTimeoutMs, handshakeAttempts);
case ScaleProto.MassaKAscii: case ScaleProto.MassaKAscii:
case ScaleProto.Cas: case ScaleProto.Cas:
{ {
@ -80,71 +80,133 @@ private static string ReadAsciiLine(ISerialTransport t, int totalTimeoutMs)
} }
} }
/// <summary>Автопоиск весов по COM-портам. 57600 и Масса-К-кадр — ПЕРВЫМИ (реальный /// <summary>Автопоиск весов по COM-портам. Исправление по реальному запуску (11.10):
/// случай владельца). Общий бюджет ограничен; занятый порт отмечается отдельно, /// автопоиск НЕ должен спотыкаться о Bluetooth-порты (Open/Write на них висят). Поэтому:
/// чтобы сказать кассиру «порт занят», а не «весы не найдены».</summary> /// • USB-serial порты (CH340/Prolific/FTDI/CP210x) проверяются ПЕРВЫМИ, Bluetooth —
/// последними; • на каждый порт — жёсткий таймаут (Open обёрнут в задачу с гонкой);
/// • порты перебираются параллельно (ограниченно), общий бюджет ограничен;
/// • любая ошибка/таймаут на порту → следующий порт; по каждому порту — строка в лог.</summary>
public static class ScaleAutodiscovery public static class ScaleAutodiscovery
{ {
public sealed record Found(string Port, int Baud, ScaleProto Proto, decimal SampleKg, string RawHex); public sealed record Found(string Port, int Baud, ScaleProto Proto, decimal SampleKg, string RawHex);
public sealed record Result(Found? Device, IReadOnlyList<string> BusyPorts, IReadOnlyList<string> ScannedPorts); public sealed record Result(Found? Device, IReadOnlyList<string> BusyPorts, IReadOnlyList<string> ScannedPorts);
// 57600 первой (реальные весы владельца); дальше — типовые скорости розницы. // Комбинации на порт (быстро): Масса-К-кадр 57600 (реальные весы владельца), затем 9600.
public static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 }; private static readonly (ScaleProto proto, int baud)[] Attempts =
public static readonly ScaleProto[] Protos = { ScaleProto.MassaKFrame, ScaleProto.MassaKAscii, ScaleProto.Cas }; { (ScaleProto.MassaKFrame, 57600), (ScaleProto.MassaKFrame, 9600) };
public static string[] ListPorts() /// <summary>Список портов с типом (для теста можно подменить).</summary>
{ public static List<HardwareProbe.PortDesc> ListPorts() => HardwareProbe.ClassifyPorts();
try { return SerialPort.GetPortNames().Distinct().OrderBy(NaturalOrder).ToArray(); }
catch { return Array.Empty<string>(); }
}
private static int NaturalOrder(string p)
=> int.TryParse(new string(p.Where(char.IsDigit).ToArray()), out var n) ? n : 9999;
/// <summary>Перебрать порты × протоколы × скорости. Для Масса-К-кадра достаточно private sealed class Holder { public ISerialTransport? T; public void KillQuietly() { try { T?.Dispose(); } catch { } } }
/// быстрого ENQ→ACK: нет ACK → сразу следующая комбинация (быстро). Бюджет общий.</summary>
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})";
/// <summary>Синхронная обёртка (для существующих вызовов).</summary>
public static Result Discover(Action<string>? log = null, public static Result Discover(Action<string>? log = null,
Func<string, int, ISerialTransport>? transportFactory = null, Func<string, int, ISerialTransport>? 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();
/// <param name="skipBluetooth">true — Bluetooth-порты не опрашивать совсем (только лог).</param>
public static async Task<Result> DiscoverAsync(
Action<string>? log = null,
Func<string, int, ISerialTransport>? transportFactory = null,
IReadOnlyList<HardwareProbe.PortDesc>? 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 make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
var sw = Stopwatch.StartNew(); var ports = portsOverride ?? ListPorts();
var busy = new List<string>(); var busy = new System.Collections.Concurrent.ConcurrentBag<string>();
var scanned = new List<string>(); var scanned = new System.Collections.Concurrent.ConcurrentBag<string>();
foreach (var port in ListPorts()) using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
{ cts.CancelAfter(budgetMs);
if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break; Found? found = null;
scanned.Add(port); var gate = new object();
// быстрая проверка занятости
try { using var probe = make(port, Bauds[0]); probe.Open(); }
catch (UnauthorizedAccessException) { busy.Add(port); log?.Invoke($"{port}: занят другой программой"); continue; }
catch { /* не открылся по иной причине — всё равно попробуем протоколы ниже */ }
foreach (var proto in Protos) // Тирами по приоритету: USB-serial → unknown → Bluetooth. Нашли в тире — дальше не идём.
foreach (var tier in new[] { HardwareProbe.PortKind.UsbSerial, HardwareProbe.PortKind.Unknown, HardwareProbe.PortKind.Bluetooth })
{ {
foreach (var baud in Bauds) 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)
{ {
if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break; foreach (var pc in tierPorts) log?.Invoke($"{pc.Port}: пропущен Bluetooth-порт");
// ASCII-режимы на экзотических скоростях не перебираем — только типовые continue;
if (proto != ScaleProto.MassaKFrame && baud is 57600 or 38400) continue; }
using var sem = new SemaphoreSlim(Math.Max(1, maxParallel));
var tasks = tierPorts.Select(async pc =>
{
await sem.WaitAsync(cts.Token).ConfigureAwait(false);
try try
{ {
using var t = make(port, baud); 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());
}
/// <summary>Проба одного порта с ЖЁСТКИМ таймаутом: Open может висеть (Bluetooth),
/// поэтому работа — в задаче, которую гоняем против Task.Delay(perPortMs); при
/// таймауте транспорт принудительно закрываем, чтобы разблокировать.</summary>
private static async Task<Found?> ProbePortAsync(HardwareProbe.PortDesc pc,
Func<string, int, ISerialTransport> make, int perPortMs, Action<string>? log,
System.Collections.Concurrent.ConcurrentBag<string> 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<string, int, ISerialTransport> make, Holder holder, Action<string>? log,
System.Collections.Concurrent.ConcurrentBag<string> busy)
{
foreach (var (proto, baud) in Attempts)
{
ISerialTransport? t = null;
try
{
t = make(pc.Port, baud); holder.T = t;
t.Open(); t.Open();
var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto); var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto, ackTimeoutMs: 300, frameTimeoutMs: 500, handshakeAttempts: 1);
if (reading is not null && reading.Value.Kg >= 0m) if (reading is not null && reading.Value.Kg >= 0m)
{ {
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {reading.Value.Kg:0.###} кг; кадр {hex})"); log?.Invoke($"{pc.Port}{DescSuffix(pc)}: ENQ→ACK, вес {reading.Value.Kg:0.###} {reading.Value.Unit} ({proto} {baud})");
return new Result(new Found(port, baud, proto, reading.Value.Kg, hex), busy, scanned); return new Found(pc.Port, baud, proto, reading.Value.Kg, hex);
} }
} }
catch (UnauthorizedAccessException) { if (!busy.Contains(port)) busy.Add(port); break; } catch (UnauthorizedAccessException) { busy.Add(pc.Port); log?.Invoke($"{pc.Port}{DescSuffix(pc)}: занят другой программой"); return null; }
catch { /* нет ответа — следующая комбинация */ } catch { /* нет ответа на этой комбинации — следующая */ }
} finally { try { t?.Dispose(); } catch { } holder.T = null; }
} }
} log?.Invoke($"{pc.Port}{DescSuffix(pc)}: нет ответа");
log?.Invoke(busy.Count > 0 return null;
? $"Весы не найдены; заняты порты: {string.Join(", ", busy)}"
: "Весы: автопоиск не нашёл весов");
return new Result(null, busy, scanned);
} }
} }

View file

@ -163,9 +163,9 @@ public static byte[] ReadFrame(ISerialTransport t, int totalTimeoutMs = 700, int
/// <summary>Полный цикл: handshake → DC1 → кадр → разбор. Возвращает (кадр, hex, /// <summary>Полный цикл: handshake → DC1 → кадр → разбор. Возвращает (кадр, hex,
/// ошибка). Транспорт должен быть уже открыт.</summary> /// ошибка). Транспорт должен быть уже открыт.</summary>
public static (Reading? reading, string rawHex, string? error) ReadWeight(ISerialTransport t, 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); t.Write(new[] { DC1 }, 0, 1);
var frame = ReadFrame(t, frameTimeoutMs); var frame = ReadFrame(t, frameTimeoutMs);
if (frame.Length == 0) return (null, "", "нет ответа на DC1 (пустой кадр)"); if (frame.Length == 0) return (null, "", "нет ответа на DC1 (пустой кадр)");

View file

@ -31,6 +31,7 @@ public sealed class MainWindow : Window
private readonly TextBlock _stable = new() { Foreground = Brushes.Gray }; 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 _hex = new() { FontFamily = new FontFamily("Consolas, monospace"), TextWrapping = TextWrapping.Wrap, Foreground = Brushes.DimGray };
private readonly TextBlock _scaleStatus = new() { TextWrapping = TextWrapping.Wrap }; 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 _ports = new();
private readonly ItemsControl _printers = new(); private readonly ItemsControl _printers = new();
private readonly TextBox _scan = new() { Watermark = "Наведите сканер и сканируйте ШК сюда…", FontSize = 18 }; private readonly TextBox _scan = new() { Watermark = "Наведите сканер и сканируйте ШК сюда…", FontSize = 18 };
@ -65,6 +66,9 @@ public MainWindow(bool allowTestPrint)
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) }; _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) };
_timer.Tick += (_, _) => LiveTick(); _timer.Tick += (_, _) => LiveTick();
if (Program.AutoFind)
Dispatcher.UIThread.Post(() => DiscoverScale(), DispatcherPriority.Background);
} }
private Control BuildLayout() private Control BuildLayout()
@ -141,6 +145,8 @@ private Control ScaleCard()
body.Children.Add(new TextBlock { Text = "Сырой кадр (hex):", Foreground = Brushes.Gray }); body.Children.Add(new TextBlock { Text = "Сырой кадр (hex):", Foreground = Brushes.Gray });
body.Children.Add(_hex); body.Children.Add(_hex);
body.Children.Add(_scaleStatus); body.Children.Add(_scaleStatus);
body.Children.Add(new TextBlock { Text = "Журнал автопоиска:", Foreground = Brushes.Gray });
body.Children.Add(_log);
return Card("Весы", body); return Card("Весы", body);
} }
@ -231,11 +237,15 @@ private async Task ReadOnce()
finally { _reading = false; } 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() private async void DiscoverScale()
{ {
StopLive(); StopLive();
_scaleStatus.Text = "Ищу весы (57600 и Масса-К-кадр — первыми)…"; _log.Text = "";
var res = await Task.Run(() => ScaleAutodiscovery.Discover()); _scaleStatus.Text = "Ищу весы: USB-serial первыми, Bluetooth последними, с таймаутом на порт…";
var res = await ScaleAutodiscovery.DiscoverAsync(log: AppendLog);
if (res.Device is not null) if (res.Device is not null)
{ {
var d = res.Device; var d = res.Device;
@ -261,16 +271,23 @@ private async void DiscoverScale()
private void RefreshPorts() 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 => _ports.ItemsSource = _portList.Select(p =>
$"{p.Port}: {(p.Busy ? "ЗАНЯТ другой программой" : "свободен")}" $"{p.Port}{Kind(p.Kind)}: {(p.Busy ? "ЗАНЯТ другой программой" : p.Kind == HardwareProbe.PortKind.Bluetooth ? "не опрашивается" : "свободен")}"
+ (p.Description is null ? "" : $" — {p.Description}") + (p.Description is null ? "" : $" — {p.Description}")).ToArray();
+ (p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")).ToArray();
var names = _portList.Select(p => p.Port).ToArray(); var names = _portList.Select(p => p.Port).ToArray();
var keep = SelectedPort; var keep = SelectedPort;
_portBox.ItemsSource = names; _portBox.ItemsSource = names;
if (keep is not null && names.Contains(keep)) _portBox.SelectedItem = keep; 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() private void RefreshPrinters()

View file

@ -8,15 +8,42 @@ internal static class Program
/// (ключ даёт владелец). Без него кнопка «Тестовая печать» выключена и на принтер /// (ключ даёт владелец). Без него кнопка «Тестовая печать» выключена и на принтер
/// ничего не отправляется.</summary> /// ничего не отправляется.</summary>
public static bool AllowTestPrint { get; private set; } public static bool AllowTestPrint { get; private set; }
/// <summary>Запустить автопоиск весов сразу при открытии (для скриншотов/проверки).</summary>
public static bool AutoFind { get; private set; }
[STAThread] [STAThread]
public static int Main(string[] args) public static int Main(string[] args)
{ {
AllowTestPrint = args.Contains("--allow-test-print"); AllowTestPrint = args.Contains("--allow-test-print");
AutoFind = args.Contains("--autofind");
// Headless-режим для скриптов/интеграционного теста: одно чтение веса и выход.
// --read-once <PORT> [BAUD] [MassaKFrame|MassaKAscii|Cas]
if (args.Contains("--read-once")) return ReadOnceCli(args);
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0; return 0;
} }
private static int ReadOnceCli(string[] args)
{
int i = Array.IndexOf(args, "--read-once");
string port = (i + 1 < args.Length) ? args[i + 1] : "";
int baud = (i + 2 < args.Length && int.TryParse(args[i + 2], out var b)) ? b : 57600;
var proto = (i + 3 < args.Length && Enum.TryParse<Core.Hardware.ScaleProto>(args[i + 3], out var p))
? p : Core.Hardware.ScaleProto.MassaKFrame;
if (string.IsNullOrEmpty(port)) { Console.Error.WriteLine("usage: --read-once <PORT> [BAUD] [PROTO]"); return 2; }
try
{
using var t = new Core.Hardware.SystemSerialTransport(port, baud);
t.Open();
var (r, hex, err) = Core.Hardware.SerialScaleReader.ReadDetailed(t, proto);
if (r is null) { Console.Error.WriteLine($"FAIL: {err} hex=[{hex}]"); return 1; }
Console.WriteLine($"OK kg={r.Value.Kg:0.000} unit={r.Value.Unit} flag='{r.Value.Flag}' " +
$"control={(r.Value.Control is null ? "-" : "0x" + r.Value.Control.Value.ToString("X2"))} hex=[{r.Value.RawHex}]");
return 0;
}
catch (Exception ex) { Console.Error.WriteLine("ERR: " + ex.Message); return 3; }
}
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
{ {
var b = AppBuilder.Configure<App>().UsePlatformDetect(); var b = AppBuilder.Configure<App>().UsePlatformDetect();

View file

@ -100,7 +100,9 @@ public void SetScale(string port, int baud, ScaleProto proto)
/// успехе — запоминаем. Если сохранённые весы не отвечают — автопоиск молча.</summary> /// успехе — запоминаем. Если сохранённые весы не отвечают — автопоиск молча.</summary>
public async Task<bool> DiscoverScaleAsync(CancellationToken ct = default) public async Task<bool> 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; if (res.Device is null) return false;
SetScale(res.Device.Port, res.Device.Baud, res.Device.Proto); SetScale(res.Device.Port, res.Device.Baud, res.Device.Proto);
return true; return true;

View file

@ -7,6 +7,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using foodmarket.Pos.Hardware; using foodmarket.Pos.Hardware;
using foodmarket.Pos.Core.Hardware; using foodmarket.Pos.Core.Hardware;
using ScaleHw = foodmarket.Pos.Core.Hardware.HardwareProbe;
namespace foodmarket.Pos.Tooling; namespace foodmarket.Pos.Tooling;
@ -58,17 +59,26 @@ public static int Run(string[] args)
Pid = wi?.Pid, Pid = wi?.Pid,
Manufacturer = wi?.Manufacturer, Manufacturer = wi?.Manufacturer,
}; };
// открыть→закрыть, без удержания var kind = ScaleHw.Classify(port, p.Description);
var (busy, openErr) = TryOpenClose(port); // Bluetooth-порты не открываем: Open/Write на них висят (реальный баг 11.10).
if (kind == ScaleHw.PortKind.Bluetooth)
{
p.Busy = false;
p.Scale = "Bluetooth-порт — не опрашивался";
}
else
{
// открыть→закрыть с жёстким таймаутом, без удержания
var (busy, _, openErr) = ScaleHw.OpenCloseBounded(port, 1000);
p.Busy = busy; p.Busy = busy;
p.OpenError = openErr; p.OpenError = openErr;
// опрос весов только на свободном порту и пока есть бюджет времени
if (!busy && sw.Elapsed < Budget - TimeSpan.FromSeconds(3)) if (!busy && sw.Elapsed < Budget - TimeSpan.FromSeconds(3))
p.Scale = ProbeScale(port, sw); p.Scale = ProbeScale(port, sw);
else if (busy) else if (busy)
p.Scale = "порт занят другим приложением — не опрашивался"; p.Scale = "порт занят другим приложением — не опрашивался";
else else
p.Scale = "пропущен (лимит времени)"; p.Scale = "пропущен (лимит времени)";
}
report.Ports.Add(p); report.Ports.Add(p);
} }

View file

@ -18,10 +18,16 @@ public sealed class FakeScaleTransport : ISerialTransport
public bool AnswerAck { get; set; } = true; // отвечает ли ACK на ENQ public bool AnswerAck { get; set; } = true; // отвечает ли ACK на ENQ
public byte[]? Frame { get; set; } // кадр, выдаваемый на DC1 public byte[]? Frame { get; set; } // кадр, выдаваемый на DC1
public bool Opening { get; set; } // бросить при Open (порт занят и т.п.) 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 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 DiscardBuffers() { }
public void Write(byte[] data, int offset, int count) public void Write(byte[] data, int offset, int count)
{ {

View file

@ -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<byte> { 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<PortDesc>
{
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<PortDesc>
{
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<PortDesc> { 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();
}
}