Compare commits
2 commits
6f97e327b1
...
8371ef6683
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8371ef6683 | ||
|
|
f15a12695c |
|
|
@ -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" )
|
||||||
|
|
|
||||||
|
|
@ -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` — сводка со скриншотами.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
BIN
docs/pos-scale-2026-10-11/vm-hwcheck-autofind.png
Normal file
BIN
docs/pos-scale-2026-10-11/vm-hwcheck-autofind.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
{
|
||||||
|
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;
|
if (found is not null || cts.IsCancellationRequested) return;
|
||||||
// ASCII-режимы на экзотических скоростях не перебираем — только типовые
|
scanned.Add(pc.Port);
|
||||||
if (proto != ScaleProto.MassaKFrame && baud is 57600 or 38400) continue;
|
var f = await ProbePortAsync(pc, make, perPortMs, log, busy, cts.Token).ConfigureAwait(false);
|
||||||
try
|
if (f is not null) { lock (gate) { if (found is null) { found = f; } } cts.Cancel(); }
|
||||||
{
|
}
|
||||||
using var t = make(port, baud);
|
catch (OperationCanceledException) { }
|
||||||
t.Open();
|
finally { sem.Release(); }
|
||||||
var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto);
|
}).ToList();
|
||||||
if (reading is not null && reading.Value.Kg >= 0m)
|
try { await Task.WhenAll(tasks).ConfigureAwait(false); } catch (OperationCanceledException) { }
|
||||||
{
|
}
|
||||||
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {reading.Value.Kg:0.###} кг; кадр {hex})");
|
|
||||||
return new Result(new Found(port, baud, proto, reading.Value.Kg, hex), busy, scanned);
|
if (found is not null)
|
||||||
}
|
log?.Invoke($"Весы найдены: {found.Port}, {found.Baud}, {found.Proto} (вес {found.SampleKg:0.###} кг).");
|
||||||
}
|
else
|
||||||
catch (UnauthorizedAccessException) { if (!busy.Contains(port)) busy.Add(port); break; }
|
log?.Invoke(busy.Any()
|
||||||
catch { /* нет ответа — следующая комбинация */ }
|
? $"Весы не найдены; заняты порты: {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();
|
||||||
|
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
|
log?.Invoke($"{pc.Port}{DescSuffix(pc)}: нет ответа");
|
||||||
? $"Весы не найдены; заняты порты: {string.Join(", ", busy)}"
|
return null;
|
||||||
: "Весы: автопоиск не нашёл весов");
|
|
||||||
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>Полный цикл: 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 (пустой кадр)");
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
p.Busy = busy;
|
if (kind == ScaleHw.PortKind.Bluetooth)
|
||||||
p.OpenError = openErr;
|
{
|
||||||
// опрос весов только на свободном порту и пока есть бюджет времени
|
p.Busy = false;
|
||||||
if (!busy && sw.Elapsed < Budget - TimeSpan.FromSeconds(3))
|
p.Scale = "Bluetooth-порт — не опрашивался";
|
||||||
p.Scale = ProbeScale(port, sw);
|
}
|
||||||
else if (busy)
|
|
||||||
p.Scale = "порт занят другим приложением — не опрашивался";
|
|
||||||
else
|
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);
|
report.Ports.Add(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
79
tests/food-market.UnitTests/ScaleAutodiscoveryTests.cs
Normal file
79
tests/food-market.UnitTests/ScaleAutodiscoveryTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue