Compare commits
No commits in common. "8371ef6683bfe0dc86c12cba0079b674d349fb23" and "6f97e327b1d433e1759a4748dca8c12fa76e5d4a" have entirely different histories.
8371ef6683
...
6f97e327b1
|
|
@ -18,12 +18,16 @@ 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 (без ключей — тестовая печать выключена). Ярлык печати НЕ включаем:
|
||||
# тестовую печать владелец включит отдельной сборкой/ключом по своему слову.
|
||||
# Ярлык-bat (без ключей — тестовая печать выключена) и README.
|
||||
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 — Проверка оборудования
|
||||
|
|
@ -38,7 +42,8 @@ ALL MARKET — Проверка оборудования
|
|||
6. «Сохранить отчёт» — текстовый файл рядом с программой; «Копировать отчёт» —
|
||||
в буфер обмена. Пришлите отчёт — по нему настроим кассу.
|
||||
|
||||
На принтер ничего не печатается. Тестовую печать включает владелец отдельно.
|
||||
Тестовая печать включается отдельным ярлыком «(с тестовой печатью)» только по
|
||||
согласованию с владельцем.
|
||||
TXT
|
||||
|
||||
( cd "$OUT" && zip -qr "allmarket-hardware-check-$RID-$VER.zip" "allmarket-hardware-check-$RID" )
|
||||
|
|
|
|||
|
|
@ -88,50 +88,6 @@ Linux-сборку не ломали (System.IO.Ports/System.Management общи
|
|||
тогда уточним смысл флага и 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-autofind.png` — автопоиск с журналом по портам (COM1), без зависания.
|
||||
- `index.html` — сводка со скриншотами.
|
||||
- `index.html` — та же сводка со скриншотом.
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ code{background:#eef2f7;padding:1px 5px;border-radius:4px;font-size:13px}
|
|||
<li class="ok">✓ Контрольная цифра EAN-13/EAN-8; сканер vs ручной ввод</li>
|
||||
<li class="ok">✓ Юнит-тесты оборудования: 50/50 зелёных</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">✗ Реальные весы с грузом (гирей) в магазине — НЕ проверялись (нужен человек)</li>
|
||||
</ul>
|
||||
|
|
@ -28,9 +26,5 @@ code{background:#eef2f7;padding:1px 5px;border-radius:4px;font-size:13px}
|
|||
<figcaption>«ALL MARKET — Проверка оборудования» на Win11 (VM): без входа; определены COM1 и принтер; тестовая печать выключена; поле сканера активно.</figcaption>
|
||||
<img src="vm-hwcheck-launch.png" loading="lazy">
|
||||
</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>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
|
|
@ -11,96 +11,24 @@ 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, PortKind Kind = PortKind.Unknown);
|
||||
string? Vid, string? Pid, string? Manufacturer, string? OpenError);
|
||||
|
||||
public sealed record PrinterInfo(string Name, string? Port, string? Driver,
|
||||
bool IsDefault, bool Offline, int QueueJobs);
|
||||
|
||||
/// <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()
|
||||
public static List<ComPortInfo> ListComPorts()
|
||||
{
|
||||
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 list = new List<PortDesc>();
|
||||
|
||||
var result = new List<ComPortInfo>();
|
||||
foreach (var p in names)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <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));
|
||||
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));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ public static class SerialScaleReader
|
|||
{
|
||||
/// <summary>Подробное чтение (для диагностики): кадр, hex, флаг, control-байт.</summary>
|
||||
public static (MassaK.Reading? reading, string rawHex, string? error) ReadDetailed(
|
||||
ISerialTransport t, ScaleProto proto, int ackTimeoutMs = 400, int frameTimeoutMs = 700, int handshakeAttempts = 2)
|
||||
ISerialTransport t, ScaleProto proto)
|
||||
{
|
||||
switch (proto)
|
||||
{
|
||||
case ScaleProto.MassaKFrame:
|
||||
return MassaK.ReadWeight(t, ackTimeoutMs, frameTimeoutMs, handshakeAttempts);
|
||||
return MassaK.ReadWeight(t);
|
||||
case ScaleProto.MassaKAscii:
|
||||
case ScaleProto.Cas:
|
||||
{
|
||||
|
|
@ -80,133 +80,71 @@ private static string ReadAsciiLine(ISerialTransport t, int totalTimeoutMs)
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Автопоиск весов по COM-портам. Исправление по реальному запуску (11.10):
|
||||
/// автопоиск НЕ должен спотыкаться о Bluetooth-порты (Open/Write на них висят). Поэтому:
|
||||
/// • USB-serial порты (CH340/Prolific/FTDI/CP210x) проверяются ПЕРВЫМИ, Bluetooth —
|
||||
/// последними; • на каждый порт — жёсткий таймаут (Open обёрнут в задачу с гонкой);
|
||||
/// • порты перебираются параллельно (ограниченно), общий бюджет ограничен;
|
||||
/// • любая ошибка/таймаут на порту → следующий порт; по каждому порту — строка в лог.</summary>
|
||||
/// <summary>Автопоиск весов по COM-портам. 57600 и Масса-К-кадр — ПЕРВЫМИ (реальный
|
||||
/// случай владельца). Общий бюджет ограничен; занятый порт отмечается отдельно,
|
||||
/// чтобы сказать кассиру «порт занят», а не «весы не найдены».</summary>
|
||||
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<string> BusyPorts, IReadOnlyList<string> ScannedPorts);
|
||||
|
||||
// Комбинации на порт (быстро): Масса-К-кадр 57600 (реальные весы владельца), затем 9600.
|
||||
private static readonly (ScaleProto proto, int baud)[] Attempts =
|
||||
{ (ScaleProto.MassaKFrame, 57600), (ScaleProto.MassaKFrame, 9600) };
|
||||
// 57600 первой (реальные весы владельца); дальше — типовые скорости розницы.
|
||||
public static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 };
|
||||
public static readonly ScaleProto[] Protos = { ScaleProto.MassaKFrame, ScaleProto.MassaKAscii, ScaleProto.Cas };
|
||||
|
||||
/// <summary>Список портов с типом (для теста можно подменить).</summary>
|
||||
public static List<HardwareProbe.PortDesc> ListPorts() => HardwareProbe.ClassifyPorts();
|
||||
public static string[] ListPorts()
|
||||
{
|
||||
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;
|
||||
|
||||
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})";
|
||||
|
||||
/// <summary>Синхронная обёртка (для существующих вызовов).</summary>
|
||||
/// <summary>Перебрать порты × протоколы × скорости. Для Масса-К-кадра достаточно
|
||||
/// быстрого ENQ→ACK: нет ACK → сразу следующая комбинация (быстро). Бюджет общий.</summary>
|
||||
public static Result Discover(Action<string>? log = null,
|
||||
Func<string, int, ISerialTransport>? transportFactory = null,
|
||||
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)
|
||||
int budgetMs = 12000, CancellationToken ct = default)
|
||||
{
|
||||
var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
|
||||
var ports = portsOverride ?? ListPorts();
|
||||
var busy = new System.Collections.Concurrent.ConcurrentBag<string>();
|
||||
var scanned = new System.Collections.Concurrent.ConcurrentBag<string>();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(budgetMs);
|
||||
Found? found = null;
|
||||
var gate = new object();
|
||||
var sw = Stopwatch.StartNew();
|
||||
var busy = new List<string>();
|
||||
var scanned = new List<string>();
|
||||
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 { /* не открылся по иной причине — всё равно попробуем протоколы ниже */ }
|
||||
|
||||
// Тирами по приоритету: USB-serial → unknown → Bluetooth. Нашли в тире — дальше не идём.
|
||||
foreach (var tier in new[] { HardwareProbe.PortKind.UsbSerial, HardwareProbe.PortKind.Unknown, HardwareProbe.PortKind.Bluetooth })
|
||||
foreach (var proto in Protos)
|
||||
{
|
||||
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);
|
||||
if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break;
|
||||
// ASCII-режимы на экзотических скоростях не перебираем — только типовые
|
||||
if (proto != ScaleProto.MassaKFrame && baud is 57600 or 38400) continue;
|
||||
try
|
||||
{
|
||||
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;
|
||||
using var t = make(port, baud);
|
||||
t.Open();
|
||||
var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto, ackTimeoutMs: 300, frameTimeoutMs: 500, handshakeAttempts: 1);
|
||||
var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto);
|
||||
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);
|
||||
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) { busy.Add(pc.Port); log?.Invoke($"{pc.Port}{DescSuffix(pc)}: занят другой программой"); return null; }
|
||||
catch { /* нет ответа на этой комбинации — следующая */ }
|
||||
finally { try { t?.Dispose(); } catch { } holder.T = null; }
|
||||
catch (UnauthorizedAccessException) { if (!busy.Contains(port)) busy.Add(port); break; }
|
||||
catch { /* нет ответа — следующая комбинация */ }
|
||||
}
|
||||
log?.Invoke($"{pc.Port}{DescSuffix(pc)}: нет ответа");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
log?.Invoke(busy.Count > 0
|
||||
? $"Весы не найдены; заняты порты: {string.Join(", ", busy)}"
|
||||
: "Весы: автопоиск не нашёл весов");
|
||||
return new Result(null, busy, scanned);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,9 +163,9 @@ public static byte[] ReadFrame(ISerialTransport t, int totalTimeoutMs = 700, int
|
|||
/// <summary>Полный цикл: handshake → DC1 → кадр → разбор. Возвращает (кадр, hex,
|
||||
/// ошибка). Транспорт должен быть уже открыт.</summary>
|
||||
public static (Reading? reading, string rawHex, string? error) ReadWeight(ISerialTransport t,
|
||||
int ackTimeoutMs = 400, int frameTimeoutMs = 700, int handshakeAttempts = 2)
|
||||
int ackTimeoutMs = 400, int frameTimeoutMs = 700)
|
||||
{
|
||||
if (!Handshake(t, ackTimeoutMs, handshakeAttempts)) return (null, "", "весы не ответили ACK на ENQ");
|
||||
if (!Handshake(t, ackTimeoutMs)) return (null, "", "весы не ответили ACK на ENQ");
|
||||
t.Write(new[] { DC1 }, 0, 1);
|
||||
var frame = ReadFrame(t, frameTimeoutMs);
|
||||
if (frame.Length == 0) return (null, "", "нет ответа на DC1 (пустой кадр)");
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ 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 };
|
||||
|
|
@ -66,9 +65,6 @@ public MainWindow(bool allowTestPrint)
|
|||
|
||||
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) };
|
||||
_timer.Tick += (_, _) => LiveTick();
|
||||
|
||||
if (Program.AutoFind)
|
||||
Dispatcher.UIThread.Post(() => DiscoverScale(), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private Control BuildLayout()
|
||||
|
|
@ -145,8 +141,6 @@ 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);
|
||||
}
|
||||
|
||||
|
|
@ -237,15 +231,11 @@ 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();
|
||||
_log.Text = "";
|
||||
_scaleStatus.Text = "Ищу весы: USB-serial первыми, Bluetooth последними, с таймаутом на порт…";
|
||||
var res = await ScaleAutodiscovery.DiscoverAsync(log: AppendLog);
|
||||
_scaleStatus.Text = "Ищу весы (57600 и Масса-К-кадр — первыми)…";
|
||||
var res = await Task.Run(() => ScaleAutodiscovery.Discover());
|
||||
if (res.Device is not null)
|
||||
{
|
||||
var d = res.Device;
|
||||
|
|
@ -271,23 +261,16 @@ private async void DiscoverScale()
|
|||
|
||||
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]",
|
||||
_ => "",
|
||||
};
|
||||
_portList = HardwareProbe.ListComPorts();
|
||||
_ports.ItemsSource = _portList.Select(p =>
|
||||
$"{p.Port}{Kind(p.Kind)}: {(p.Busy ? "ЗАНЯТ другой программой" : p.Kind == HardwareProbe.PortKind.Bluetooth ? "не опрашивается" : "свободен")}"
|
||||
+ (p.Description is null ? "" : $" — {p.Description}")).ToArray();
|
||||
$"{p.Port}: {(p.Busy ? "ЗАНЯТ другой программой" : "свободен")}"
|
||||
+ (p.Description is null ? "" : $" — {p.Description}")
|
||||
+ (p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")).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);
|
||||
else if (names.Length > 0) _portBox.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void RefreshPrinters()
|
||||
|
|
|
|||
|
|
@ -8,42 +8,15 @@ internal static class Program
|
|||
/// (ключ даёт владелец). Без него кнопка «Тестовая печать» выключена и на принтер
|
||||
/// ничего не отправляется.</summary>
|
||||
public static bool AllowTestPrint { get; private set; }
|
||||
/// <summary>Запустить автопоиск весов сразу при открытии (для скриншотов/проверки).</summary>
|
||||
public static bool AutoFind { get; private set; }
|
||||
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
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);
|
||||
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()
|
||||
{
|
||||
var b = AppBuilder.Configure<App>().UsePlatformDetect();
|
||||
|
|
|
|||
|
|
@ -100,9 +100,7 @@ public void SetScale(string port, int baud, ScaleProto proto)
|
|||
/// успехе — запоминаем. Если сохранённые весы не отвечают — автопоиск молча.</summary>
|
||||
public async Task<bool> DiscoverScaleAsync(CancellationToken ct = default)
|
||||
{
|
||||
// USB-serial первыми, Bluetooth последними, жёсткий таймаут на порт, параллельно —
|
||||
// чтобы не висеть на Bluetooth-портах и дойти до CH340 (реальный баг 11.10).
|
||||
var res = await ScaleAutodiscovery.DiscoverAsync(AppendScaleLog, ct: ct);
|
||||
var res = await Task.Run(() => ScaleAutodiscovery.Discover(AppendScaleLog, ct: ct), ct);
|
||||
if (res.Device is null) return false;
|
||||
SetScale(res.Device.Port, res.Device.Baud, res.Device.Proto);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using foodmarket.Pos.Hardware;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
using ScaleHw = foodmarket.Pos.Core.Hardware.HardwareProbe;
|
||||
|
||||
namespace foodmarket.Pos.Tooling;
|
||||
|
||||
|
|
@ -59,26 +58,17 @@ public static int Run(string[] args)
|
|||
Pid = wi?.Pid,
|
||||
Manufacturer = wi?.Manufacturer,
|
||||
};
|
||||
var kind = ScaleHw.Classify(port, p.Description);
|
||||
// Bluetooth-порты не открываем: Open/Write на них висят (реальный баг 11.10).
|
||||
if (kind == ScaleHw.PortKind.Bluetooth)
|
||||
{
|
||||
p.Busy = false;
|
||||
p.Scale = "Bluetooth-порт — не опрашивался";
|
||||
}
|
||||
else
|
||||
{
|
||||
// открыть→закрыть с жёстким таймаутом, без удержания
|
||||
var (busy, _, openErr) = ScaleHw.OpenCloseBounded(port, 1000);
|
||||
// открыть→закрыть, без удержания
|
||||
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 = "порт занят другим приложением — не опрашивался";
|
||||
else
|
||||
p.Scale = "пропущен (лимит времени)";
|
||||
}
|
||||
report.Ports.Add(p);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,16 +18,10 @@ 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 (OpenDelayMs > 0) System.Threading.Thread.Sleep(OpenDelayMs);
|
||||
if (Opening) throw new System.UnauthorizedAccessException("busy");
|
||||
Opened = true;
|
||||
}
|
||||
public void Open() { if (Opening) throw new System.UnauthorizedAccessException("busy"); Opened = true; }
|
||||
public void DiscardBuffers() { }
|
||||
public void Write(byte[] data, int offset, int count)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue