Compare commits
4 commits
d04d1d53fa
...
6f97e327b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f97e327b1 | ||
|
|
19cbdbbf11 | ||
|
|
ff1580d162 | ||
|
|
25c37415bf |
50
deploy/hwcheck-publish.sh
Normal file
50
deploy/hwcheck-publish.sh
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
# Сборка «ALL MARKET — Проверка оборудования» (food-market.pos.hwcheck) под Windows x64.
|
||||
# Self-contained + ReadyToRun (без установленного .NET). В zip кладём ярлык-bat и README.
|
||||
# Обычная касса в этот zip НЕ входит — этим exe нельзя пользоваться как кассой.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PROJ=src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj
|
||||
OUT=deploy/pos-dist
|
||||
VER="$(date +%Y.%m.%d)"
|
||||
RID=win-x64
|
||||
DIR="$OUT/allmarket-hardware-check-$RID"
|
||||
mkdir -p "$OUT"
|
||||
rm -rf "$DIR"
|
||||
|
||||
echo "[hwcheck] publish $RID …"
|
||||
dotnet publish "$PROJ" -c Release -r "$RID" --self-contained true \
|
||||
-p:PublishReadyToRun=true -p:Version="$VER" -p:DebugType=none \
|
||||
-o "$DIR" >/tmp/hwcheck-publish.log 2>&1 || { tail -30 /tmp/hwcheck-publish.log; exit 1; }
|
||||
|
||||
# Ярлык-bat (без ключей — тестовая печать выключена) и README.
|
||||
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 — Проверка оборудования
|
||||
|
||||
1. Распакуйте папку целиком в любое место (например, на Рабочий стол).
|
||||
2. На время проверки закройте «Кассу МойСклад» (она держит COM-порт весов).
|
||||
3. Запустите «Проверка оборудования.bat». Вход и пароль НЕ нужны.
|
||||
4. Весы: нажмите «Найти весы», затем «Запустить» — вес обновляется вживую.
|
||||
Для проверки с грузом положите гирю/товар и смотрите значение и «стабильно».
|
||||
5. Принтеры и COM-порты показываются только для чтения.
|
||||
На принтер НИЧЕГО не печатается (тестовая печать выключена).
|
||||
6. «Сохранить отчёт» — текстовый файл рядом с программой; «Копировать отчёт» —
|
||||
в буфер обмена. Пришлите отчёт — по нему настроим кассу.
|
||||
|
||||
Тестовая печать включается отдельным ярлыком «(с тестовой печатью)» только по
|
||||
согласованию с владельцем.
|
||||
TXT
|
||||
|
||||
( cd "$OUT" && zip -qr "allmarket-hardware-check-$RID-$VER.zip" "allmarket-hardware-check-$RID" )
|
||||
echo "[hwcheck] zip: $OUT/allmarket-hardware-check-$RID-$VER.zip"
|
||||
93
docs/pos-scale-2026-10-11/REPORT.md
Normal file
93
docs/pos-scale-2026-10-11/REPORT.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Касса ALL MARKET — реальный протокол весов Масса-К (11.10.2026)
|
||||
|
||||
Отчёт по задаче: заменить угаданный протокол весов на реальный (по замеру с
|
||||
ноутбука магазина) и сделать отдельную программу «Проверка оборудования» для
|
||||
sysadmin без входа в кассу.
|
||||
|
||||
## Что сделано
|
||||
|
||||
### Приоритет 0 — «ALL MARKET — Проверка оборудования» (без входа/сервера/БД)
|
||||
Отдельная программа `food-market.pos.hwcheck` (новый exe), ссылается ТОЛЬКО на
|
||||
`pos.core` ради модулей оборудования. **Не умеет быть кассой**: нет экрана входа,
|
||||
нет подключения к серверу, нет работы с БД продаж/каталога, нет токенов и сетевых
|
||||
запросов. Обычная касса с входом в zip для sysadmin НЕ входит.
|
||||
|
||||
Окно (Avalonia, светлая тема, русский): весы (ручной выбор порта/скорости/протокола,
|
||||
«Найти весы», «Прочитать вес сейчас», «Запустить/Остановить» с live-весом ~350 мс,
|
||||
сырой hex кадра), COM-порты с пометкой «занят другой программой», принтеры Windows
|
||||
(только чтение — очередь не трогаем, число заданий), сканер (контрольная цифра
|
||||
EAN-13/EAN-8 + отличие сканера от ручного набора по интервалу), «Сохранить/
|
||||
Копировать отчёт». **Тестовая печать ВЫКЛ по умолчанию**, включается только
|
||||
запуском с `--allow-test-print` (ключ даёт владелец) — в zip два ярлыка.
|
||||
|
||||
Запущена и проверена на реальной Windows 11 (VM 192.168.1.231) — см.
|
||||
`vm-hwcheck-launch.png`: окно отрисовано, определены порт **COM1** и принтер
|
||||
«Microsoft Print to PDF», кнопка «Тестовая печать» неактивна, поле сканера активно.
|
||||
|
||||
### Реальный протокол весов (пп. 1–6)
|
||||
По фактическому замеру sysadmin (11.10, ноутбук магазина, при закрытой кассе
|
||||
МойСклад): весы Масса-К (USB-COM CH340) отвечают **только на 57600 8N1**;
|
||||
рукопожатие **ENQ(0x05)→ACK(0x06)**; запрос веса **DC1(0x11)** → кадр
|
||||
**SOH STX «флаг вес единица» CTRL ETX EOT** (например
|
||||
`01 02 'S 0.000kg' 'q'(0x71) 03 04`).
|
||||
|
||||
- `pos.core/Hardware/SerialScale.cs` — `ISerialTransport` (абстракция для тестов) +
|
||||
`SystemSerialTransport` (8N1, DTR/RTS), `MassaK.ParseFrame/Handshake/ReadFrame/
|
||||
ReadWeight`. **Control-байт НЕ валидируем** (смысл не подтверждён) — только пишем
|
||||
сырой hex (чтобы по кадрам с грузом уточнить). Единицы kg; «г/g» → /1000.
|
||||
- `pos.core/Hardware/ScaleDiscovery.cs` — чтение + **стабильность по двум одинаковым
|
||||
чтениям** (флаг весов не интерпретируем); автопоиск **57600 и Масса-К-кадр —
|
||||
первыми**, общий бюджет ~12 с, занятые порты отмечаются отдельно → кассиру
|
||||
«порт занят (закройте кассу МойСклад)», а не «весы не найдены».
|
||||
- `pos` — `ComScale` стал тонким адаптером; настройки хранят `scale_proto` и по
|
||||
умолчанию **MassaKFrame/57600**; если сохранённые весы не отвечают — автопоиск
|
||||
молча + ручной выбор в настройках. `ProbeRunner (--probe-hw)` печатает сырой hex.
|
||||
- Весовой товар на кассе (п. 6): вес берётся через `ScaleMonitor` (опрос по
|
||||
требованию, мягкая деградация); нет связи → **ручной ввод веса остаётся** (как
|
||||
сейчас), с понятным сообщением. Логика не менялась — уже была корректной.
|
||||
|
||||
### Юнит-тесты (п. 5) — 50/50 зелёных
|
||||
`tests/food-market.UnitTests/MassaKScaleTests.cs` на фейковом порту
|
||||
(`FakeScaleTransport`): разбор **реального нулевого кадра**
|
||||
`01 02 53 20 20 30 2E 30 30 30 6B 67 71 03 04`, вес 1.235 / 12.500 / отрицательный /
|
||||
граммы, мусор до SOH, обрезанный кадр без ETX, мусор без SOH, рукопожатие с ACK и
|
||||
без ACK, полный цикл, стабильность. `BarcodeScanTests.cs`: контрольная цифра
|
||||
EAN-13/EAN-8 и классификация ввода.
|
||||
|
||||
### Сборки (п. 7 + Приоритет 0) — в ~/pos-release/
|
||||
- **Проверка оборудования:**
|
||||
`allmarket-pos-win-x64-20261011-0839-hardware-check.zip`
|
||||
SHA256 `f4f5a53ffedc4c7e55452d6fad1c139848340e57232553d33f1ea7f8bc449623`
|
||||
(внутри: `foodmarket.Pos.HwCheck.exe`, два ярлыка-bat, `README.txt`).
|
||||
- **Диагностическая касса (с новым протоколом):**
|
||||
`allmarket-pos-win-x64-20261011-0840-probe.zip`
|
||||
SHA256 `67b9e72cdbc163ed9f93c49f2724e10740c1430d16a6c6d1f6954ef69a47c1f4`.
|
||||
|
||||
Linux-сборку не ломали (System.IO.Ports/System.Management общие; WMI под
|
||||
`OperatingSystem.IsWindows()`). pos.core/pos/hwcheck собираются, тесты зелёные.
|
||||
|
||||
## Что проверено
|
||||
- Протокол — **по фактическим байтам** (юнит-тесты, 50/50).
|
||||
- Программа «Проверка оборудования» **запускается на реальной Windows 11**,
|
||||
определяет COM-порт и принтер, отрисовывает русский интерфейс, тестовая печать
|
||||
выключена (скриншот `vm-hwcheck-launch.png`).
|
||||
|
||||
## Что НЕ проверено (нужен человек / решение владельца)
|
||||
1. **Живое чтение веса через виртуальную пару COM на VM (п. 8) — НЕ выполнено.**
|
||||
com0com — подписанный установщик `Setup_com0com_v3.0.0.0_W7_x64_signed.exe`
|
||||
скачан и запущен (`/S`, exit 0), но **виртуальная пара COM10/COM11 не
|
||||
зарегистрировалась**: на Win11 установка драйвера требует либо интерактивного
|
||||
подтверждения доверия издателю (нельзя нажать по ssh), либо импорта сертификата
|
||||
издателя в хранилище доверенных — это **ослабление доверия ОС**, я не стал делать
|
||||
автономно. Нужно решение владельца: разрешить доверить сертификат издателя
|
||||
com0com и установить драйвер на тест-VM (один раз), после чего я доведу
|
||||
эмулятор весов + live-чтение + скриншоты.
|
||||
2. **Реальные весы с грузом (гирей) в магазине — НЕ проверялись** (нет доступа к
|
||||
железу; касса МойСклад и ноутбук магазина не трогали). Нужен человек с гирей:
|
||||
положить груз, сверить вес и «стабильно», прислать сырые hex-кадры с грузом —
|
||||
тогда уточним смысл флага и control-байта (сейчас они логируются, но не
|
||||
интерпретируются).
|
||||
|
||||
## Файлы
|
||||
- `vm-hwcheck-launch.png` — окно «Проверка оборудования» на Windows 11.
|
||||
- `index.html` — та же сводка со скриншотом.
|
||||
30
docs/pos-scale-2026-10-11/index.html
Normal file
30
docs/pos-scale-2026-10-11/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!doctype html><meta charset="utf-8"><title>Касса — протокол весов Масса-К + Проверка оборудования (11.10)</title>
|
||||
<style>body{font:15px system-ui;margin:0;padding:24px;max-width:1000px;margin:auto;background:#f8fafc;color:#0f172a}
|
||||
h1{font-size:22px}h2{font-size:17px;margin-top:28px}
|
||||
figure{margin:0 0 20px;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
|
||||
figcaption{padding:8px 12px;font-size:13px;color:#475569;background:#f1f5f9}img{display:block;width:100%}
|
||||
code{background:#eef2f7;padding:1px 5px;border-radius:4px;font-size:13px}
|
||||
.ok{color:#16a34a}.no{color:#dc2626;font-weight:600}ul{line-height:1.6}</style>
|
||||
<h1>Касса ALL MARKET — реальный протокол весов Масса-К + «Проверка оборудования»</h1>
|
||||
<p>11.10.2026. Протокол весов заменён на реальный (по замеру с ноутбука магазина);
|
||||
сделана отдельная программа диагностики оборудования без входа в кассу.</p>
|
||||
|
||||
<h2>Автопроверки</h2>
|
||||
<ul>
|
||||
<li class="ok">✓ Разбор реального кадра <code>01 02 53 20 20 30 2E 30 30 30 6B 67 71 03 04</code> → 0.000 кг, флаг 'S', control 0x71</li>
|
||||
<li class="ok">✓ Веса 1.235 / 12.500 / отрицательный / граммы; мусор до SOH; обрезанный кадр; без ACK — обработаны</li>
|
||||
<li class="ok">✓ Рукопожатие ENQ→ACK и запрос DC1 на фейковом порту; стабильность по двум чтениям</li>
|
||||
<li class="ok">✓ Контрольная цифра EAN-13/EAN-8; сканер vs ручной ввод</li>
|
||||
<li class="ok">✓ Юнит-тесты оборудования: 50/50 зелёных</li>
|
||||
<li class="ok">✓ «Проверка оборудования» запускается на реальной Windows 11 (скриншот ниже)</li>
|
||||
<li class="no">✗ Живое чтение веса через виртуальную пару COM на VM — НЕ выполнено (драйвер com0com требует доверия издателю; нужно решение владельца)</li>
|
||||
<li class="no">✗ Реальные весы с грузом (гирей) в магазине — НЕ проверялись (нужен человек)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Скриншот — Windows 11</h2>
|
||||
<figure>
|
||||
<figcaption>«ALL MARKET — Проверка оборудования» на Win11 (VM): без входа; определены COM1 и принтер; тестовая печать выключена; поле сканера активно.</figcaption>
|
||||
<img src="vm-hwcheck-launch.png" loading="lazy">
|
||||
</figure>
|
||||
|
||||
<p>Подробности, SHA256 сборок и список «нужно решение владельца» — в <code>REPORT.md</code>.</p>
|
||||
BIN
docs/pos-scale-2026-10-11/vm-hwcheck-launch.png
Normal file
BIN
docs/pos-scale-2026-10-11/vm-hwcheck-launch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
|
|
@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "te
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.IntegrationTests", "tests\food-market.IntegrationTests\food-market.IntegrationTests.csproj", "{9122ECF4-9111-40B3-BB59-ED7C112FF575}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos.hwcheck", "src\food-market.pos.hwcheck\food-market.pos.hwcheck.csproj", "{18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -70,6 +72,10 @@ Global
|
|||
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9122ECF4-9111-40B3-BB59-ED7C112FF575}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
||||
|
|
@ -81,5 +87,6 @@ Global
|
|||
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
||||
{D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
|
||||
{9122ECF4-9111-40B3-BB59-ED7C112FF575} = {F29E3026-31A5-4277-A265-081E87C76A28}
|
||||
{18A087E9-7DA1-4B7A-BFF9-C60BF49B30D2} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
58
src/food-market.pos.core/Hardware/BarcodeScan.cs
Normal file
58
src/food-market.pos.core/Hardware/BarcodeScan.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
namespace foodmarket.Pos.Core.Hardware;
|
||||
|
||||
/// <summary>Анализ ввода сканера ШК: валидность контрольной цифры EAN-13/EAN-8 и
|
||||
/// оценка «HID-сканер vs ручной набор» по среднему интервалу между символами.
|
||||
/// Чистая логика — покрыта юнит-тестами.</summary>
|
||||
public static class BarcodeScan
|
||||
{
|
||||
/// <summary>Проверить контрольную цифру EAN-13 или EAN-8. Для прочих длин — false
|
||||
/// (валидность «не применимо»).</summary>
|
||||
public static bool IsValidEan(string code)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code) || !code.All(char.IsDigit)) return false;
|
||||
if (code.Length != 13 && code.Length != 8) return false;
|
||||
int sum = 0;
|
||||
// Крайняя правая цифра — контрольная; веса чередуются 1/3 справа налево (без контрольной).
|
||||
for (int i = 0; i < code.Length - 1; i++)
|
||||
{
|
||||
int digit = code[code.Length - 2 - i] - '0';
|
||||
sum += (i % 2 == 0) ? digit * 3 : digit;
|
||||
}
|
||||
int check = (10 - sum % 10) % 10;
|
||||
return check == (code[^1] - '0');
|
||||
}
|
||||
|
||||
public enum InputKind { Empty, Scanner, Manual, Unknown }
|
||||
|
||||
public sealed record ScanAnalysis(string Code, int Length, bool IsEan, bool EanValid,
|
||||
double AvgIntervalMs, InputKind Kind);
|
||||
|
||||
/// <summary>Классифицировать ввод по символам и их временам (мс от начала).
|
||||
/// Сканер печатает быстро и равномерно (обычно < 30 мс/символ); человек —
|
||||
/// медленнее и неравномерно. Порог намеренно консервативный.</summary>
|
||||
public static ScanAnalysis Analyze(string code, IReadOnlyList<long>? charTimestampsMs, double scannerThresholdMs = 40)
|
||||
{
|
||||
var len = code.Length;
|
||||
bool isEan = len is 13 or 8 && code.All(char.IsDigit);
|
||||
bool eanValid = isEan && IsValidEan(code);
|
||||
double avg = 0;
|
||||
if (charTimestampsMs is { Count: > 1 })
|
||||
{
|
||||
long span = charTimestampsMs[^1] - charTimestampsMs[0];
|
||||
avg = (double)span / (charTimestampsMs.Count - 1);
|
||||
}
|
||||
InputKind kind = len == 0 ? InputKind.Empty
|
||||
: charTimestampsMs is not { Count: > 1 } ? InputKind.Unknown
|
||||
: avg <= scannerThresholdMs ? InputKind.Scanner
|
||||
: InputKind.Manual;
|
||||
return new ScanAnalysis(code, len, isEan, eanValid, avg, kind);
|
||||
}
|
||||
|
||||
public static string KindRu(InputKind k) => k switch
|
||||
{
|
||||
InputKind.Scanner => "похоже на сканер (быстрый равномерный ввод)",
|
||||
InputKind.Manual => "похоже на ручной набор",
|
||||
InputKind.Empty => "пусто",
|
||||
_ => "не определено",
|
||||
};
|
||||
}
|
||||
110
src/food-market.pos.core/Hardware/HardwareProbe.cs
Normal file
110
src/food-market.pos.core/Hardware/HardwareProbe.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.IO.Ports;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace foodmarket.Pos.Core.Hardware;
|
||||
|
||||
/// <summary>Детект оборудования для диагностики (COM-порты, принтеры Windows).
|
||||
/// Только чтение: порты открываем→закрываем, очередь принтера не трогаем.
|
||||
/// WMI-части — Windows-only (под OperatingSystem.IsWindows()); на Linux вернётся
|
||||
/// базовый список COM из System.IO.Ports.</summary>
|
||||
public static class HardwareProbe
|
||||
{
|
||||
public sealed record ComPortInfo(string Port, bool Busy, string? Description,
|
||||
string? Vid, string? Pid, string? Manufacturer, string? OpenError);
|
||||
|
||||
public sealed record PrinterInfo(string Name, string? Port, string? Driver,
|
||||
bool IsDefault, bool Offline, int QueueJobs);
|
||||
|
||||
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 result = new List<ComPortInfo>();
|
||||
foreach (var p in names)
|
||||
{
|
||||
var w = wmi.FirstOrDefault(x => string.Equals(x.port, p, StringComparison.OrdinalIgnoreCase));
|
||||
var (busy, err) = TryOpenClose(p);
|
||||
result.Add(new ComPortInfo(p, busy, w.desc, w.vid, w.pid, w.manuf, err));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> SafePortNames()
|
||||
{
|
||||
try { return SerialPort.GetPortNames().Distinct().ToList(); }
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private static int NaturalOrder(string p)
|
||||
=> int.TryParse(Regex.Match(p, @"\d+").Value, out var n) ? n : 9999;
|
||||
|
||||
/// <summary>Открыть→закрыть без удержания. Busy=true, если занят другой программой.</summary>
|
||||
public static (bool busy, string? err) TryOpenClose(string port)
|
||||
{
|
||||
try { using var sp = new SerialPort(port) { ReadTimeout = 300, WriteTimeout = 300 }; sp.Open(); sp.Close(); return (false, null); }
|
||||
catch (UnauthorizedAccessException) { return (true, "занят другой программой (Access denied)"); }
|
||||
catch (Exception ex) { return (false, ex.GetType().Name + ": " + ex.Message); }
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static List<(string port, string? desc, string? vid, string? pid, string? manuf)> QueryComWmi()
|
||||
{
|
||||
var list = new List<(string, string?, string?, string?, string?)>();
|
||||
try
|
||||
{
|
||||
using var s = new System.Management.ManagementObjectSearcher(
|
||||
"SELECT Name, PNPDeviceID, Description, Manufacturer FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'");
|
||||
foreach (var o in s.Get())
|
||||
{
|
||||
var name = o["Name"]?.ToString() ?? "";
|
||||
var m = Regex.Match(name, @"\((COM\d+)\)");
|
||||
if (!m.Success) continue;
|
||||
var pnp = o["PNPDeviceID"]?.ToString() ?? "";
|
||||
var vid = Regex.Match(pnp, @"VID_([0-9A-Fa-f]{4})").Groups[1].Value;
|
||||
var pid = Regex.Match(pnp, @"PID_([0-9A-Fa-f]{4})").Groups[1].Value;
|
||||
list.Add((m.Groups[1].Value, name, vid.Length == 4 ? vid : null, pid.Length == 4 ? pid : null,
|
||||
o["Manufacturer"]?.ToString()));
|
||||
}
|
||||
}
|
||||
catch { /* WMI недоступен — порты всё равно есть из GetPortNames */ }
|
||||
return list;
|
||||
}
|
||||
|
||||
public static List<PrinterInfo> ListPrinters()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return new();
|
||||
return QueryPrintersWmi();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static List<PrinterInfo> QueryPrintersWmi()
|
||||
{
|
||||
var list = new List<PrinterInfo>();
|
||||
try
|
||||
{
|
||||
using var s = new System.Management.ManagementObjectSearcher(
|
||||
"SELECT Name, PortName, DriverName, Default, WorkOffline FROM Win32_Printer");
|
||||
foreach (var o in s.Get())
|
||||
{
|
||||
var name = o["Name"]?.ToString() ?? "";
|
||||
int jobs = 0;
|
||||
try
|
||||
{
|
||||
using var js = new System.Management.ManagementObjectSearcher(
|
||||
$"SELECT Name FROM Win32_PrintJob WHERE Name LIKE '{name.Replace("'", "''")},%'");
|
||||
jobs = js.Get().Count;
|
||||
}
|
||||
catch { /* очередь недоступна — оставляем 0, задания НЕ трогаем */ }
|
||||
list.Add(new PrinterInfo(
|
||||
name, o["PortName"]?.ToString(), o["DriverName"]?.ToString(),
|
||||
o["Default"] as bool? ?? false, o["WorkOffline"] as bool? ?? false, jobs));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return list;
|
||||
}
|
||||
}
|
||||
150
src/food-market.pos.core/Hardware/ScaleDiscovery.cs
Normal file
150
src/food-market.pos.core/Hardware/ScaleDiscovery.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
|
||||
namespace foodmarket.Pos.Core.Hardware;
|
||||
|
||||
/// <summary>Чтение веса на заданном порту/скорости/протоколе + стабильность (два
|
||||
/// одинаковых чтения подряд). Транспорт абстрагирован — тестируется фейком.</summary>
|
||||
public static class SerialScaleReader
|
||||
{
|
||||
/// <summary>Подробное чтение (для диагностики): кадр, hex, флаг, control-байт.</summary>
|
||||
public static (MassaK.Reading? reading, string rawHex, string? error) ReadDetailed(
|
||||
ISerialTransport t, ScaleProto proto)
|
||||
{
|
||||
switch (proto)
|
||||
{
|
||||
case ScaleProto.MassaKFrame:
|
||||
return MassaK.ReadWeight(t);
|
||||
case ScaleProto.MassaKAscii:
|
||||
case ScaleProto.Cas:
|
||||
{
|
||||
// «угаданные» ASCII-режимы: CAS шлёт 'W', Масса-К-ASCII — ничего; читаем строку.
|
||||
t.DiscardBuffers();
|
||||
if (proto == ScaleProto.Cas) t.Write(new[] { (byte)'W' }, 0, 1);
|
||||
var line = ReadAsciiLine(t, 1000);
|
||||
if (string.IsNullOrWhiteSpace(line)) return (null, "", "нет строки от весов (ASCII)");
|
||||
var kg = MassaK.ParseWeight(line, out var unit);
|
||||
if (kg is null) return (null, MassaK.ToHex(Encoding.ASCII.GetBytes(line)), $"не распознан вес в «{line}»");
|
||||
return (new MassaK.Reading(kg.Value, '\0', unit, null, MassaK.ToHex(Encoding.ASCII.GetBytes(line))), "", null);
|
||||
}
|
||||
default:
|
||||
return (null, "", "неизвестный протокол");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadAsciiLine(ISerialTransport t, int totalTimeoutMs)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var deadline = Environment.TickCount64 + totalTimeoutMs;
|
||||
while (sb.Length < 64)
|
||||
{
|
||||
int remaining = (int)(deadline - Environment.TickCount64);
|
||||
if (remaining <= 0) break;
|
||||
int b = t.ReadByte(Math.Min(remaining, 300));
|
||||
if (b < 0) break;
|
||||
if (b == '\r' || b == '\n') { if (sb.Length > 0) break; else continue; }
|
||||
sb.Append((char)b);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Считать вес в кг (бросает при отсутствии связи). Фабрика транспорта
|
||||
/// подменяется в тестах; по умолчанию — реальный COM.</summary>
|
||||
public static decimal ReadWeightKg(string port, int baud, ScaleProto proto,
|
||||
Func<string, int, ISerialTransport>? transportFactory = null)
|
||||
{
|
||||
var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
|
||||
using var t = make(port, baud);
|
||||
t.Open();
|
||||
var (reading, _, error) = ReadDetailed(t, proto);
|
||||
if (reading is null) throw new IOException(error ?? "весы не ответили");
|
||||
return reading.Value.Kg;
|
||||
}
|
||||
|
||||
/// <summary>Стабильность: два чтения с интервалом ~150–200 мс; стабильно, если
|
||||
/// совпали (флаг весов НЕ интерпретируем — его смысл не подтверждён).</summary>
|
||||
public static (decimal kg, bool stable) ReadStable(string port, int baud, ScaleProto proto,
|
||||
Func<string, int, ISerialTransport>? transportFactory = null, int gapMs = 180)
|
||||
{
|
||||
var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
|
||||
using var t = make(port, baud);
|
||||
t.Open();
|
||||
var (r1, _, e1) = ReadDetailed(t, proto);
|
||||
if (r1 is null) throw new IOException(e1 ?? "весы не ответили");
|
||||
Thread.Sleep(gapMs);
|
||||
var (r2, _, _) = ReadDetailed(t, proto);
|
||||
bool stable = r2 is not null && r2.Value.Kg == r1.Value.Kg;
|
||||
return (r2?.Kg ?? r1.Value.Kg, stable);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 первой (реальные весы владельца); дальше — типовые скорости розницы.
|
||||
public static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 };
|
||||
public static readonly ScaleProto[] Protos = { ScaleProto.MassaKFrame, ScaleProto.MassaKAscii, ScaleProto.Cas };
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>Перебрать порты × протоколы × скорости. Для Масса-К-кадра достаточно
|
||||
/// быстрого ENQ→ACK: нет ACK → сразу следующая комбинация (быстро). Бюджет общий.</summary>
|
||||
public static Result Discover(Action<string>? log = null,
|
||||
Func<string, int, ISerialTransport>? transportFactory = null,
|
||||
int budgetMs = 12000, CancellationToken ct = default)
|
||||
{
|
||||
var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
|
||||
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 { /* не открылся по иной причине — всё равно попробуем протоколы ниже */ }
|
||||
|
||||
foreach (var proto in Protos)
|
||||
{
|
||||
foreach (var baud in Bauds)
|
||||
{
|
||||
if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break;
|
||||
// ASCII-режимы на экзотических скоростях не перебираем — только типовые
|
||||
if (proto != ScaleProto.MassaKFrame && baud is 57600 or 38400) continue;
|
||||
try
|
||||
{
|
||||
using var t = make(port, baud);
|
||||
t.Open();
|
||||
var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto);
|
||||
if (reading is not null && reading.Value.Kg >= 0m)
|
||||
{
|
||||
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {reading.Value.Kg:0.###} кг; кадр {hex})");
|
||||
return new Result(new Found(port, baud, proto, reading.Value.Kg, hex), busy, scanned);
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException) { if (!busy.Contains(port)) busy.Add(port); break; }
|
||||
catch { /* нет ответа — следующая комбинация */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
log?.Invoke(busy.Count > 0
|
||||
? $"Весы не найдены; заняты порты: {string.Join(", ", busy)}"
|
||||
: "Весы: автопоиск не нашёл весов");
|
||||
return new Result(null, busy, scanned);
|
||||
}
|
||||
}
|
||||
175
src/food-market.pos.core/Hardware/SerialScale.cs
Normal file
175
src/food-market.pos.core/Hardware/SerialScale.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
using System.Globalization;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
|
||||
namespace foodmarket.Pos.Core.Hardware;
|
||||
|
||||
/// <summary>Протоколы весов, которые умеет касса.
|
||||
/// • <see cref="MassaKFrame"/> — реальный протокол весов владельца (Масса-К, USB-COM
|
||||
/// CH340, 57600 8N1): рукопожатие ENQ→ACK, запрос веса DC1 → кадр
|
||||
/// SOH STX «флаг вес единица» CTRL ETX EOT. Разобран по фактическим байтам с
|
||||
/// ноутбука магазина (замер 11.10), не угадан.
|
||||
/// • <see cref="MassaKAscii"/>/<see cref="Cas"/> — прежние «угаданные» ASCII-режимы,
|
||||
/// оставлены как запасные варианты автопоиска.</summary>
|
||||
public enum ScaleProto { MassaKFrame, MassaKAscii, Cas }
|
||||
|
||||
/// <summary>Абстракция последовательного транспорта — чтобы протокол и рукопожатие
|
||||
/// покрывались юнит-тестами фейковым портом, без реального железа.</summary>
|
||||
public interface ISerialTransport : IDisposable
|
||||
{
|
||||
void Open();
|
||||
void DiscardBuffers();
|
||||
void Write(byte[] data, int offset, int count);
|
||||
/// <summary>Прочитать один байт; -1 при таймауте/конце потока.</summary>
|
||||
int ReadByte(int timeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>Реальный COM-порт через System.IO.Ports (Windows и Linux).</summary>
|
||||
public sealed class SystemSerialTransport : ISerialTransport
|
||||
{
|
||||
private readonly SerialPort _sp;
|
||||
public SystemSerialTransport(string port, int baud)
|
||||
{
|
||||
// 8N1 — как у весов владельца; DTR/RTS включаем (некоторым USB-COM нужно питание линий).
|
||||
_sp = new SerialPort(port, baud, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
ReadTimeout = 500,
|
||||
WriteTimeout = 500,
|
||||
DtrEnable = true,
|
||||
RtsEnable = true,
|
||||
};
|
||||
}
|
||||
public void Open() => _sp.Open();
|
||||
public void DiscardBuffers() { try { _sp.DiscardInBuffer(); _sp.DiscardOutBuffer(); } catch { } }
|
||||
public void Write(byte[] data, int offset, int count) => _sp.Write(data, offset, count);
|
||||
public int ReadByte(int timeoutMs)
|
||||
{
|
||||
_sp.ReadTimeout = timeoutMs;
|
||||
try { return _sp.ReadByte(); }
|
||||
catch (TimeoutException) { return -1; }
|
||||
}
|
||||
public void Dispose() { try { if (_sp.IsOpen) _sp.Close(); } catch { } _sp.Dispose(); }
|
||||
}
|
||||
|
||||
/// <summary>Протокол Масса-К (кадровый). Control-байт НЕ валидируем, пока он не
|
||||
/// подтверждён документацией/замерами с грузом — только логируем сырым hex, чтобы
|
||||
/// владелец/sysadmin прислали реальные кадры.</summary>
|
||||
public static class MassaK
|
||||
{
|
||||
public const byte ENQ = 0x05, ACK = 0x06, DC1 = 0x11, SOH = 0x01, STX = 0x02, ETX = 0x03, EOT = 0x04;
|
||||
|
||||
/// <summary>Разобранный кадр веса.</summary>
|
||||
public readonly record struct Reading(decimal Kg, char Flag, string Unit, byte? Control, string RawHex);
|
||||
|
||||
public static string ToHex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var sb = new StringBuilder(bytes.Length * 3);
|
||||
for (int i = 0; i < bytes.Length; i++) { if (i > 0) sb.Append(' '); sb.Append(bytes[i].ToString("X2")); }
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Разобрать кадр веса. Терпимо к мусору до SOH. Возвращает null и
|
||||
/// заполняет <paramref name="error"/>, если кадр неполон/битый.
|
||||
/// Структура: [мусор] SOH STX <payload> ETX EOT, где последний байт payload —
|
||||
/// контрольный (например 0x71 'q'), остальное — «флаг + вес + единица».</summary>
|
||||
public static Reading? ParseFrame(ReadOnlySpan<byte> raw, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var rawHex = ToHex(raw);
|
||||
// 1) найти SOH (пропустить мусор до него)
|
||||
int i = raw.IndexOf(SOH);
|
||||
if (i < 0) { error = "нет SOH (0x01) — не кадр весов"; return null; }
|
||||
// 2) STX сразу за SOH
|
||||
if (i + 1 >= raw.Length || raw[i + 1] != STX) { error = "нет STX (0x02) за SOH"; return null; }
|
||||
int payloadStart = i + 2;
|
||||
// 3) ETX
|
||||
int etx = -1;
|
||||
for (int k = payloadStart; k < raw.Length; k++) { if (raw[k] == ETX) { etx = k; break; } }
|
||||
if (etx < 0) { error = "нет ETX (0x03) — кадр обрезан"; return null; }
|
||||
// 4) EOT после ETX (если есть — проверяем; отсутствие не фатально)
|
||||
if (etx + 1 < raw.Length && raw[etx + 1] != EOT) error = "после ETX не EOT (0x04) — проверьте кадр";
|
||||
var payload = raw.Slice(payloadStart, etx - payloadStart);
|
||||
if (payload.Length == 0) { error = "пустой payload между STX и ETX"; return null; }
|
||||
// последний байт payload — контрольный (не валидируем, только сохраняем)
|
||||
byte? control = payload[^1];
|
||||
var textBytes = payload; // число вытаскиваем из всего payload (control-символ не мешает)
|
||||
var text = Encoding.ASCII.GetString(textBytes);
|
||||
var kg = ParseWeight(text, out var unit);
|
||||
if (kg is null) { error = (error is null ? "" : error + "; ") + $"не распознан вес в «{text}»"; return null; }
|
||||
char flag = text.Length > 0 && !char.IsDigit(text[0]) && text[0] != '-' && text[0] != '+' && text[0] != ' '
|
||||
? text[0] : '\0';
|
||||
return new Reading(kg.Value, flag, unit, control, rawHex);
|
||||
}
|
||||
|
||||
/// <summary>Вытащить вес из ASCII-строки payload. kg по умолчанию; «г»/«g» (не «kg»)
|
||||
/// → граммы (делим на 1000). Поддержаны знак, точка и запятая.</summary>
|
||||
public static decimal? ParseWeight(string text, out string unit)
|
||||
{
|
||||
unit = "kg";
|
||||
if (string.IsNullOrEmpty(text)) return null;
|
||||
bool isKg = text.Contains("kg", StringComparison.OrdinalIgnoreCase) || text.Contains("кг");
|
||||
bool isGram = !isKg && (text.Contains('g') || text.Contains('г'));
|
||||
var num = ExtractNumber(text);
|
||||
if (num is null) return null;
|
||||
if (isGram) { unit = "g"; return decimal.Round(num.Value / 1000m, 3); }
|
||||
unit = "kg";
|
||||
return decimal.Round(num.Value, 3);
|
||||
}
|
||||
|
||||
private static decimal? ExtractNumber(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
bool seenDigit = false;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (char.IsDigit(c) || c == '.' || c == ',' || (!seenDigit && (c == '+' || c == '-')))
|
||||
{ sb.Append(c == ',' ? '.' : c); if (char.IsDigit(c)) seenDigit = true; }
|
||||
else if (seenDigit) break; // число кончилось
|
||||
}
|
||||
var str = sb.ToString().Trim('+');
|
||||
return decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
|
||||
}
|
||||
|
||||
/// <summary>Рукопожатие ENQ→ACK с повтором. true если весы ответили ACK.</summary>
|
||||
public static bool Handshake(ISerialTransport t, int ackTimeoutMs = 400, int attempts = 2)
|
||||
{
|
||||
for (int a = 0; a < attempts; a++)
|
||||
{
|
||||
t.DiscardBuffers();
|
||||
t.Write(new[] { ENQ }, 0, 1);
|
||||
int b = t.ReadByte(ackTimeoutMs);
|
||||
if (b == ACK) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Прочитать кадр: байты до EOT (0x04) включительно, с общим таймаутом.</summary>
|
||||
public static byte[] ReadFrame(ISerialTransport t, int totalTimeoutMs = 700, int maxBytes = 64)
|
||||
{
|
||||
var buf = new List<byte>(32);
|
||||
var deadline = Environment.TickCount64 + totalTimeoutMs;
|
||||
while (buf.Count < maxBytes)
|
||||
{
|
||||
int remaining = (int)(deadline - Environment.TickCount64);
|
||||
if (remaining <= 0) break;
|
||||
int b = t.ReadByte(Math.Min(remaining, 300));
|
||||
if (b < 0) break;
|
||||
buf.Add((byte)b);
|
||||
if (b == EOT) break;
|
||||
}
|
||||
return buf.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Полный цикл: handshake → DC1 → кадр → разбор. Возвращает (кадр, hex,
|
||||
/// ошибка). Транспорт должен быть уже открыт.</summary>
|
||||
public static (Reading? reading, string rawHex, string? error) ReadWeight(ISerialTransport t,
|
||||
int ackTimeoutMs = 400, int frameTimeoutMs = 700)
|
||||
{
|
||||
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 (пустой кадр)");
|
||||
var r = ParseFrame(frame, out var err);
|
||||
return (r, ToHex(frame), err);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="System.IO.Ports" />
|
||||
<!-- WMI для детекта оборудования (COM VID/PID, принтеры) — только Windows;
|
||||
все вызовы под OperatingSystem.IsWindows(). -->
|
||||
<PackageReference Include="System.Management" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Refit" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
|
|
|
|||
22
src/food-market.pos.hwcheck/App.cs
Normal file
22
src/food-market.pos.hwcheck/App.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Themes.Fluent;
|
||||
|
||||
namespace foodmarket.Pos.HwCheck;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
Styles.Add(new FluentTheme());
|
||||
RequestedThemeVariant = ThemeVariant.Light; // светлый, читаемо (требование)
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainWindow(Program.AllowTestPrint);
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
373
src/food-market.pos.hwcheck/MainWindow.cs
Normal file
373
src/food-market.pos.hwcheck/MainWindow.cs
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
|
||||
namespace foodmarket.Pos.HwCheck;
|
||||
|
||||
/// <summary>Окно «ALL MARKET — Проверка оборудования». Без входа, без сервера, без БД.
|
||||
/// Весы (Масса-К-кадр), COM-порты, принтеры (только чтение), сканер, сохранение отчёта.</summary>
|
||||
public sealed class MainWindow : Window
|
||||
{
|
||||
private readonly bool _allowTestPrint;
|
||||
private static readonly (string label, ScaleProto proto)[] Protos =
|
||||
{
|
||||
("Масса-К (кадр, 57600)", ScaleProto.MassaKFrame),
|
||||
("Масса-К (ASCII)", ScaleProto.MassaKAscii),
|
||||
("CAS (ASCII)", ScaleProto.Cas),
|
||||
};
|
||||
private static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 };
|
||||
|
||||
private readonly ComboBox _portBox = new() { MinWidth = 220 };
|
||||
private readonly ComboBox _baudBox = new() { ItemsSource = Bauds, SelectedIndex = 0, MinWidth = 100 };
|
||||
private readonly ComboBox _protoBox = new() { MinWidth = 220 };
|
||||
private readonly TextBlock _weight = new() { FontSize = 46, FontWeight = FontWeight.Bold, Text = "— кг" };
|
||||
private readonly TextBlock _stable = new() { Foreground = Brushes.Gray };
|
||||
private readonly TextBlock _hex = new() { FontFamily = new FontFamily("Consolas, monospace"), TextWrapping = TextWrapping.Wrap, Foreground = Brushes.DimGray };
|
||||
private readonly TextBlock _scaleStatus = new() { TextWrapping = TextWrapping.Wrap };
|
||||
private readonly ItemsControl _ports = new();
|
||||
private readonly ItemsControl _printers = new();
|
||||
private readonly TextBox _scan = new() { Watermark = "Наведите сканер и сканируйте ШК сюда…", FontSize = 18 };
|
||||
private readonly TextBlock _scanInfo = new() { TextWrapping = TextWrapping.Wrap };
|
||||
private readonly Button _testPrint;
|
||||
private readonly TextBlock _footer = new() { Foreground = Brushes.Gray };
|
||||
|
||||
private readonly DispatcherTimer _timer;
|
||||
private bool _reading;
|
||||
private decimal? _lastKg;
|
||||
private string _lastPrinterPick = "";
|
||||
private readonly List<long> _scanTimes = new();
|
||||
private readonly Stopwatch _scanSw = new();
|
||||
|
||||
public MainWindow(bool allowTestPrint)
|
||||
{
|
||||
_allowTestPrint = allowTestPrint;
|
||||
Title = "ALL MARKET — Проверка оборудования";
|
||||
Width = 1000; Height = 720; MinWidth = 820; MinHeight = 560;
|
||||
Background = Brushes.White;
|
||||
FontFamily = new FontFamily("Inter, Segoe UI, sans-serif");
|
||||
|
||||
_protoBox.ItemsSource = Protos.Select(p => p.label).ToArray();
|
||||
_protoBox.SelectedIndex = 0;
|
||||
_testPrint = new Button { Content = "Тестовая печать", IsEnabled = allowTestPrint };
|
||||
_testPrint.Click += (_, _) => DoTestPrint();
|
||||
|
||||
Content = BuildLayout();
|
||||
RefreshPorts();
|
||||
RefreshPrinters();
|
||||
WireScanner();
|
||||
|
||||
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(350) };
|
||||
_timer.Tick += (_, _) => LiveTick();
|
||||
}
|
||||
|
||||
private Control BuildLayout()
|
||||
{
|
||||
var header = new StackPanel { Spacing = 2, Margin = new Thickness(0, 0, 0, 10) };
|
||||
header.Children.Add(new TextBlock { Text = "ALL MARKET — Проверка оборудования", FontSize = 22, FontWeight = FontWeight.Bold });
|
||||
header.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "Диагностика весов, принтера и сканера. Без входа в кассу, без сервера и без доступа к данным магазина."
|
||||
+ (_allowTestPrint ? " Тестовая печать РАЗРЕШЕНА (--allow-test-print)." : " Тестовая печать выключена."),
|
||||
Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
|
||||
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), RowDefinitions = new RowDefinitions("*") };
|
||||
var left = new StackPanel { Spacing = 10, Margin = new Thickness(0, 0, 8, 0) };
|
||||
left.Children.Add(ScaleCard());
|
||||
left.Children.Add(PortsCard());
|
||||
var right = new StackPanel { Spacing = 10, Margin = new Thickness(8, 0, 0, 0) };
|
||||
right.Children.Add(PrintersCard());
|
||||
right.Children.Add(ScannerCard());
|
||||
Grid.SetColumn(left, 0); Grid.SetColumn(right, 1);
|
||||
grid.Children.Add(left); grid.Children.Add(right);
|
||||
|
||||
var footerBar = new DockPanel { Margin = new Thickness(0, 10, 0, 0) };
|
||||
var btns = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
||||
var save = new Button { Content = "Сохранить отчёт" }; save.Click += (_, _) => SaveReport();
|
||||
var copy = new Button { Content = "Копировать отчёт" }; copy.Click += (_, _) => CopyReport();
|
||||
btns.Children.Add(save); btns.Children.Add(copy);
|
||||
DockPanel.SetDock(btns, Dock.Left);
|
||||
footerBar.Children.Add(btns);
|
||||
footerBar.Children.Add(_footer);
|
||||
|
||||
var root = new DockPanel { Margin = new Thickness(16) };
|
||||
DockPanel.SetDock(header, Dock.Top);
|
||||
DockPanel.SetDock(footerBar, Dock.Bottom);
|
||||
root.Children.Add(header);
|
||||
root.Children.Add(footerBar);
|
||||
root.Children.Add(new ScrollViewer { Content = grid });
|
||||
return root;
|
||||
}
|
||||
|
||||
private static Border Card(string title, Control body)
|
||||
{
|
||||
var panel = new StackPanel { Spacing = 8 };
|
||||
panel.Children.Add(new TextBlock { Text = title, FontSize = 16, FontWeight = FontWeight.SemiBold });
|
||||
panel.Children.Add(body);
|
||||
return new Border
|
||||
{
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#E2E8F0")), BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(10), Padding = new Thickness(14), Child = panel,
|
||||
Background = new SolidColorBrush(Color.Parse("#FCFCFD")),
|
||||
};
|
||||
}
|
||||
|
||||
private Control ScaleCard()
|
||||
{
|
||||
var sel = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), RowDefinitions = new RowDefinitions("Auto,Auto,Auto") };
|
||||
void Row(int r, string label, Control c) { var t = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 4, 10, 4) }; c.Margin = new Thickness(0, 4, 0, 4); Grid.SetRow(t, r); Grid.SetColumn(t, 0); Grid.SetRow(c, r); Grid.SetColumn(c, 1); sel.Children.Add(t); sel.Children.Add(c); }
|
||||
Row(0, "Порт", _portBox); Row(1, "Скорость", _baudBox); Row(2, "Протокол", _protoBox);
|
||||
|
||||
var findBtn = new Button { Content = "Найти весы" }; findBtn.Click += (_, _) => DiscoverScale();
|
||||
var readBtn = new Button { Content = "Прочитать вес сейчас" }; readBtn.Click += (_, _) => _ = ReadOnce();
|
||||
var startBtn = new Button { Content = "Запустить (вес в реальном времени)" }; startBtn.Click += (_, _) => StartLive();
|
||||
var stopBtn = new Button { Content = "Остановить" }; stopBtn.Click += (_, _) => StopLive();
|
||||
var row1 = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; row1.Children.Add(findBtn); row1.Children.Add(readBtn);
|
||||
var row2 = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; row2.Children.Add(startBtn); row2.Children.Add(stopBtn);
|
||||
|
||||
var body = new StackPanel { Spacing = 10 };
|
||||
body.Children.Add(sel);
|
||||
body.Children.Add(row1);
|
||||
body.Children.Add(row2);
|
||||
body.Children.Add(_weight);
|
||||
body.Children.Add(_stable);
|
||||
body.Children.Add(new TextBlock { Text = "Сырой кадр (hex):", Foreground = Brushes.Gray });
|
||||
body.Children.Add(_hex);
|
||||
body.Children.Add(_scaleStatus);
|
||||
return Card("Весы", body);
|
||||
}
|
||||
|
||||
private Control PortsCard()
|
||||
{
|
||||
var refresh = new Button { Content = "Обновить список портов" }; refresh.Click += (_, _) => RefreshPorts();
|
||||
var body = new StackPanel { Spacing = 8 };
|
||||
body.Children.Add(refresh);
|
||||
body.Children.Add(_ports);
|
||||
return Card("COM-порты", body);
|
||||
}
|
||||
|
||||
private Control PrintersCard()
|
||||
{
|
||||
var refresh = new Button { Content = "Обновить список принтеров" }; refresh.Click += (_, _) => RefreshPrinters();
|
||||
var note = new TextBlock { Text = "Только чтение: очередь не очищается, задания не удаляются.", Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap };
|
||||
var body = new StackPanel { Spacing = 8 };
|
||||
body.Children.Add(refresh);
|
||||
body.Children.Add(_printers);
|
||||
body.Children.Add(_testPrint);
|
||||
body.Children.Add(note);
|
||||
return Card("Принтеры Windows", body);
|
||||
}
|
||||
|
||||
private Control ScannerCard()
|
||||
{
|
||||
var body = new StackPanel { Spacing = 8 };
|
||||
body.Children.Add(new TextBlock { Text = "Поставьте курсор в поле и отсканируйте штрихкод. Для EAN-13/EAN-8 проверяется контрольная цифра; по скорости ввода отличаем сканер от ручного набора.", Foreground = Brushes.Gray, TextWrapping = TextWrapping.Wrap });
|
||||
body.Children.Add(_scan);
|
||||
body.Children.Add(_scanInfo);
|
||||
return Card("Сканер штрихкодов", body);
|
||||
}
|
||||
|
||||
// ───────────────────────── Весы ─────────────────────────
|
||||
private string? SelectedPort => _portBox.SelectedItem as string;
|
||||
private int SelectedBaud => _baudBox.SelectedItem is int b ? b : 57600;
|
||||
private ScaleProto SelectedProto => Protos[Math.Max(0, _protoBox.SelectedIndex)].proto;
|
||||
|
||||
private void StartLive() { if (SelectedPort is null) { _scaleStatus.Text = "Выберите порт или нажмите «Найти весы»."; return; } _timer.Start(); _scaleStatus.Text = "Идёт опрос весов…"; }
|
||||
private void StopLive() { _timer.Stop(); _scaleStatus.Text = "Опрос остановлен."; }
|
||||
|
||||
private void LiveTick()
|
||||
{
|
||||
if (_reading || SelectedPort is null) return;
|
||||
_ = ReadOnce();
|
||||
}
|
||||
|
||||
private async Task ReadOnce()
|
||||
{
|
||||
var port = SelectedPort; if (port is null) { _scaleStatus.Text = "Порт не выбран."; return; }
|
||||
var baud = SelectedBaud; var proto = SelectedProto;
|
||||
_reading = true;
|
||||
try
|
||||
{
|
||||
var (kg, hex, status, stable) = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var t = new SystemSerialTransport(port, baud);
|
||||
t.Open();
|
||||
var (r1, h1, e1) = SerialScaleReader.ReadDetailed(t, proto);
|
||||
if (r1 is null) return ((decimal?)null, h1, e1 ?? "нет ответа", false);
|
||||
// стабильность: второе чтение
|
||||
Thread.Sleep(160);
|
||||
var (r2, _, _) = SerialScaleReader.ReadDetailed(t, proto);
|
||||
bool st = r2 is not null && r2.Value.Kg == r1.Value.Kg;
|
||||
var rr = r2 ?? r1;
|
||||
return ((decimal?)rr.Value.Kg, rr.Value.RawHex, $"флаг '{rr.Value.Flag}', ед. {rr.Value.Unit}, control {(rr.Value.Control is null ? "—" : "0x" + rr.Value.Control.Value.ToString("X2"))}", st);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return (null, "", "порт занят другой программой (закройте кассу МойСклад)", false); }
|
||||
catch (Exception ex) { return (null, "", ex.GetType().Name + ": " + ex.Message, false); }
|
||||
});
|
||||
if (kg is not null)
|
||||
{
|
||||
_lastKg = kg;
|
||||
_weight.Text = $"{kg.Value:0.000} кг";
|
||||
_weight.Foreground = Brushes.Black;
|
||||
_stable.Text = stable ? "стабильно" : "нестабильно (вес меняется)";
|
||||
_hex.Text = hex;
|
||||
_scaleStatus.Text = status;
|
||||
}
|
||||
else
|
||||
{
|
||||
_weight.Foreground = Brushes.IndianRed;
|
||||
_scaleStatus.Text = status;
|
||||
}
|
||||
}
|
||||
finally { _reading = false; }
|
||||
}
|
||||
|
||||
private async void DiscoverScale()
|
||||
{
|
||||
StopLive();
|
||||
_scaleStatus.Text = "Ищу весы (57600 и Масса-К-кадр — первыми)…";
|
||||
var res = await Task.Run(() => ScaleAutodiscovery.Discover());
|
||||
if (res.Device is not null)
|
||||
{
|
||||
var d = res.Device;
|
||||
_portBox.SelectedItem = d.Port;
|
||||
_baudBox.SelectedItem = d.Baud;
|
||||
_protoBox.SelectedIndex = Array.FindIndex(Protos, p => p.proto == d.Proto);
|
||||
_weight.Text = $"{d.SampleKg:0.000} кг"; _weight.Foreground = Brushes.Black;
|
||||
_hex.Text = d.RawHex;
|
||||
_scaleStatus.Text = $"Найдены: {d.Port}, {d.Baud}, {d.Proto}.";
|
||||
RefreshPorts();
|
||||
}
|
||||
else
|
||||
{
|
||||
_scaleStatus.Text = res.BusyPorts.Count > 0
|
||||
? $"Весы не найдены. Заняты порты: {string.Join(", ", res.BusyPorts)} (закройте кассу МойСклад)."
|
||||
: "Весы не найдены ни на одном порту.";
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Порты / принтеры ─────────────────────────
|
||||
private List<HardwareProbe.ComPortInfo> _portList = new();
|
||||
private List<HardwareProbe.PrinterInfo> _printerList = new();
|
||||
|
||||
private void RefreshPorts()
|
||||
{
|
||||
_portList = HardwareProbe.ListComPorts();
|
||||
_ports.ItemsSource = _portList.Select(p =>
|
||||
$"{p.Port}: {(p.Busy ? "ЗАНЯТ другой программой" : "свободен")}"
|
||||
+ (p.Description is null ? "" : $" — {p.Description}")
|
||||
+ (p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")).ToArray();
|
||||
var names = _portList.Select(p => p.Port).ToArray();
|
||||
var keep = SelectedPort;
|
||||
_portBox.ItemsSource = names;
|
||||
if (keep is not null && names.Contains(keep)) _portBox.SelectedItem = keep;
|
||||
else if (names.Length > 0) _portBox.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void RefreshPrinters()
|
||||
{
|
||||
_printerList = HardwareProbe.ListPrinters();
|
||||
_printers.ItemsSource = _printerList.Count == 0
|
||||
? new[] { "(принтеры не найдены / не Windows)" }
|
||||
: _printerList.Select(p =>
|
||||
$"{p.Name}{(p.IsDefault ? " [по умолчанию]" : "")} — порт {p.Port}, {(p.Offline ? "НЕ в сети" : "в сети")}, заданий в очереди: {p.QueueJobs}").ToArray();
|
||||
_lastPrinterPick = _printerList.FirstOrDefault(p => p.IsDefault)?.Name ?? _printerList.FirstOrDefault()?.Name ?? "";
|
||||
}
|
||||
|
||||
private void DoTestPrint()
|
||||
{
|
||||
if (!_allowTestPrint) { _footer.Text = "Тестовая печать выключена (нет --allow-test-print)."; return; }
|
||||
if (!OperatingSystem.IsWindows()) { _footer.Text = "Печать доступна только на Windows."; return; }
|
||||
if (string.IsNullOrEmpty(_lastPrinterPick)) { _footer.Text = "Нет принтера для теста."; return; }
|
||||
try
|
||||
{
|
||||
var ok = WinRawPrint.Send(_lastPrinterPick, WinRawPrint.BuildTestSlip(), out var err);
|
||||
_footer.Text = ok ? $"Тестовый чек отправлен на «{_lastPrinterPick}»." : $"Ошибка печати: {err}";
|
||||
}
|
||||
catch (Exception ex) { _footer.Text = "Ошибка печати: " + ex.Message; }
|
||||
}
|
||||
|
||||
// ───────────────────────── Сканер ─────────────────────────
|
||||
private void WireScanner()
|
||||
{
|
||||
_scan.AddHandler(InputElement.KeyDownEvent, (_, e) =>
|
||||
{
|
||||
if (!_scanSw.IsRunning) { _scanSw.Restart(); _scanTimes.Clear(); }
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
AnalyzeScan();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
_scanTimes.Add(_scanSw.ElapsedMilliseconds);
|
||||
}, RoutingStrategies.Tunnel);
|
||||
}
|
||||
|
||||
private void AnalyzeScan()
|
||||
{
|
||||
var code = (_scan.Text ?? "").Trim();
|
||||
var a = BarcodeScan.Analyze(code, _scanTimes.ToList());
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"Длина: {a.Length}. ");
|
||||
sb.Append(a.IsEan ? (a.EanValid ? "EAN контрольная цифра ВЕРНА. " : "EAN контрольная цифра НЕВЕРНА. ") : "Не EAN-13/EAN-8. ");
|
||||
if (a.AvgIntervalMs > 0) sb.Append($"Средний интервал {a.AvgIntervalMs:0} мс/символ — {BarcodeScan.KindRu(a.Kind)}.");
|
||||
_scanInfo.Text = sb.ToString();
|
||||
_scanSw.Reset();
|
||||
}
|
||||
|
||||
// ───────────────────────── Отчёт ─────────────────────────
|
||||
private string BuildReportText()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== ALL MARKET — отчёт проверки оборудования ===");
|
||||
sb.AppendLine($"Время: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||||
sb.AppendLine($"Компьютер: {Environment.MachineName}");
|
||||
sb.AppendLine($"ОС: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"COM-порты ({_portList.Count}):");
|
||||
foreach (var p in _portList)
|
||||
sb.AppendLine($" {p.Port}: {(p.Busy ? "ЗАНЯТ" : "свободен")}{(p.Description is null ? "" : " — " + p.Description)}{(p.Vid is null ? "" : $" [VID_{p.Vid}/PID_{p.Pid}]")}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Весы:");
|
||||
sb.AppendLine($" порт {SelectedPort}, скорость {SelectedBaud}, протокол {SelectedProto}");
|
||||
sb.AppendLine($" вес: {(_lastKg is null ? "—" : _lastKg.Value.ToString("0.000") + " кг")}; {_stable.Text}");
|
||||
sb.AppendLine($" последний кадр (hex): {_hex.Text}");
|
||||
sb.AppendLine($" статус: {_scaleStatus.Text}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Принтеры ({_printerList.Count}):");
|
||||
foreach (var p in _printerList)
|
||||
sb.AppendLine($" {p.Name}{(p.IsDefault ? " [по умолчанию]" : "")} — порт {p.Port}, {(p.Offline ? "НЕ в сети" : "в сети")}, заданий {p.QueueJobs}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Сканер:");
|
||||
sb.AppendLine($" {( string.IsNullOrEmpty(_scan.Text) ? "(не сканировали)" : _scan.Text )} — {_scanInfo.Text}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void SaveReport()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
var path = Path.Combine(dir, $"hardware-check-{DateTime.Now:yyyyMMdd-HHmmss}.txt");
|
||||
File.WriteAllText(path, BuildReportText(), new UTF8Encoding(true));
|
||||
_footer.Text = $"Отчёт сохранён: {path}";
|
||||
}
|
||||
catch (Exception ex) { _footer.Text = "Не удалось сохранить отчёт: " + ex.Message; }
|
||||
}
|
||||
|
||||
private async void CopyReport()
|
||||
{
|
||||
var cb = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (cb is not null) { await cb.SetTextAsync(BuildReportText()); _footer.Text = "Отчёт скопирован в буфер обмена."; }
|
||||
else _footer.Text = "Буфер обмена недоступен.";
|
||||
}
|
||||
}
|
||||
28
src/food-market.pos.hwcheck/Program.cs
Normal file
28
src/food-market.pos.hwcheck/Program.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using Avalonia;
|
||||
|
||||
namespace foodmarket.Pos.HwCheck;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>Тестовая печать разрешена ТОЛЬКО при запуске с --allow-test-print
|
||||
/// (ключ даёт владелец). Без него кнопка «Тестовая печать» выключена и на принтер
|
||||
/// ничего не отправляется.</summary>
|
||||
public static bool AllowTestPrint { get; private set; }
|
||||
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
AllowTestPrint = args.Contains("--allow-test-print");
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
var b = AppBuilder.Configure<App>().UsePlatformDetect();
|
||||
// На headless/VM без GPU — ПО-рендеринг, чтобы окно не было чёрным.
|
||||
if (OperatingSystem.IsWindows())
|
||||
b = b.With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } });
|
||||
return b.WithInterFont().LogToTrace();
|
||||
}
|
||||
}
|
||||
60
src/food-market.pos.hwcheck/WinRawPrint.cs
Normal file
60
src/food-market.pos.hwcheck/WinRawPrint.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
|
||||
namespace foodmarket.Pos.HwCheck;
|
||||
|
||||
/// <summary>RAW-печать короткого тестового чека ESC/POS через spooler Windows.
|
||||
/// Используется ТОЛЬКО если программа запущена с --allow-test-print. Очередь принтера
|
||||
/// не чистится, задания не удаляются — просто одно новое задание.</summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class WinRawPrint
|
||||
{
|
||||
public static byte[] BuildTestSlip()
|
||||
{
|
||||
var cp866 = CodePagesEncodingProvider.Instance.GetEncoding(866) ?? Encoding.UTF8;
|
||||
var buf = new List<byte>();
|
||||
buf.AddRange(new byte[] { 0x1B, 0x40 }); // init
|
||||
buf.AddRange(new byte[] { 0x1B, 0x74, 0x11 }); // CP866
|
||||
buf.AddRange(new byte[] { 0x1B, 0x61, 0x01 }); // center
|
||||
void Line(string s) { buf.AddRange(cp866.GetBytes(s)); buf.Add(0x0A); }
|
||||
buf.AddRange(new byte[] { 0x1B, 0x45, 0x01 }); Line("ALL MARKET"); buf.AddRange(new byte[] { 0x1B, 0x45, 0x00 });
|
||||
Line("Проверка оборудования");
|
||||
Line(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
Line(new string('-', 32));
|
||||
Line("Если этот чек напечатан —");
|
||||
Line("принтер и очередь Windows в порядке.");
|
||||
buf.AddRange(new byte[] { 0x0A, 0x0A, 0x0A });
|
||||
buf.AddRange(new byte[] { 0x1D, 0x56, 0x42, 0x00 }); // cut
|
||||
return buf.ToArray();
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct DOCINFO { [MarshalAs(UnmanagedType.LPWStr)] public string pDocName; [MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile; [MarshalAs(UnmanagedType.LPWStr)] public string pDataType; }
|
||||
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool OpenPrinter(string src, out IntPtr h, IntPtr pd);
|
||||
[DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true)] private static extern bool ClosePrinter(IntPtr h);
|
||||
[DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool StartDocPrinter(IntPtr h, int level, ref DOCINFO di);
|
||||
[DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true)] private static extern bool EndDocPrinter(IntPtr h);
|
||||
[DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true)] private static extern bool StartPagePrinter(IntPtr h);
|
||||
[DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true)] private static extern bool EndPagePrinter(IntPtr h);
|
||||
[DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true)] private static extern bool WritePrinter(IntPtr h, IntPtr buf, int count, out int written);
|
||||
|
||||
public static bool Send(string printer, byte[] bytes, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (!OpenPrinter(printer, out var h, IntPtr.Zero)) { error = $"OpenPrinter: Win32 {Marshal.GetLastWin32Error()}"; return false; }
|
||||
var di = new DOCINFO { pDocName = "ALL MARKET тест оборудования", pDataType = "RAW" };
|
||||
var ptr = Marshal.AllocCoTaskMem(bytes.Length);
|
||||
try
|
||||
{
|
||||
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
||||
if (!StartDocPrinter(h, 1, ref di)) { error = $"StartDocPrinter: {Marshal.GetLastWin32Error()}"; return false; }
|
||||
if (!StartPagePrinter(h)) { error = $"StartPagePrinter: {Marshal.GetLastWin32Error()}"; EndDocPrinter(h); return false; }
|
||||
var ok = WritePrinter(h, ptr, bytes.Length, out var written);
|
||||
EndPagePrinter(h); EndDocPrinter(h);
|
||||
if (!ok || written != bytes.Length) { error = $"WritePrinter: {written}/{bytes.Length}"; return false; }
|
||||
return true;
|
||||
}
|
||||
finally { Marshal.FreeCoTaskMem(ptr); ClosePrinter(h); }
|
||||
}
|
||||
}
|
||||
23
src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj
Normal file
23
src/food-market.pos.hwcheck/food-market.pos.hwcheck.csproj
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>foodmarket.Pos.HwCheck</RootNamespace>
|
||||
<AssemblyName>foodmarket.Pos.HwCheck</AssemblyName>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Только оборудование: ссылается на pos.core РАДИ Hardware/* (весы, детект,
|
||||
сканер). Сервер/БД/авторизация НЕ используются — этот exe не умеет быть кассой. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\food-market.pos.core\food-market.pos.core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.IO.Ports;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
|
||||
namespace foodmarket.Pos.Hardware;
|
||||
|
||||
|
|
@ -19,117 +18,22 @@ public sealed class ManualScale : IScale
|
|||
public Task<decimal> ReadWeightKgAsync(CancellationToken ct = default) => Task.FromResult(LastValue);
|
||||
}
|
||||
|
||||
/// <summary>Разбор ответа весов по COM. Поддержаны простые ASCII-протоколы
|
||||
/// CAS (строка с граммами/кг + завершитель) и Масса-К (ASCII «вес кг»). Парсер
|
||||
/// выделен и тестируется отдельно; реальный COM-поток — в <see cref="ComScale"/>.</summary>
|
||||
public static class ScaleProtocol
|
||||
{
|
||||
/// <summary>CAS: ASCII-строка вида "ST,GS, 1.234kg" или "+001.234 kg" — тянем
|
||||
/// первое число, трактуем как кг (если явно граммы — делим).</summary>
|
||||
public static decimal? ParseCas(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var isGrams = raw.Contains('g', StringComparison.OrdinalIgnoreCase) && !raw.Contains("kg", StringComparison.OrdinalIgnoreCase);
|
||||
var num = ExtractNumber(raw);
|
||||
if (num is null) return null;
|
||||
var kg = isGrams ? num.Value / 1000m : num.Value;
|
||||
return decimal.Round(kg, 3);
|
||||
}
|
||||
|
||||
/// <summary>Масса-К (ASCII-режим): строка с весом в кг, например "1.234".</summary>
|
||||
public static decimal? ParseMassaK(string raw)
|
||||
{
|
||||
var num = ExtractNumber(raw);
|
||||
return num is null ? null : decimal.Round(num.Value, 3);
|
||||
}
|
||||
|
||||
private static decimal? ExtractNumber(string s)
|
||||
{
|
||||
var chars = new System.Text.StringBuilder();
|
||||
bool seen = false;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (char.IsDigit(c) || c == '.' || c == ',' || (!seen && (c == '+' || c == '-')))
|
||||
{ chars.Append(c == ',' ? '.' : c); seen = char.IsDigit(c) || seen; }
|
||||
else if (seen && chars.Length > 0 && (chars[^1] is >= '0' and <= '9')) break;
|
||||
}
|
||||
return decimal.TryParse(chars.ToString().Trim('+'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Весы по COM/serial. Протокол: Cas | MassaK. Открывает порт, шлёт
|
||||
/// запрос веса (опц.), читает строку, парсит. Доступно на Windows и Linux
|
||||
/// (System.IO.Ports).</summary>
|
||||
/// <summary>Весы по COM/serial. Протокол и разбор кадра — в
|
||||
/// <see cref="foodmarket.Pos.Core.Hardware.MassaK"/> (pos.core, покрыто юнит-тестами).
|
||||
/// Этот класс — только адаптер к <see cref="IScale"/> и мониторингу кассы.</summary>
|
||||
public sealed class ComScale : IScale
|
||||
{
|
||||
public enum Proto { Cas, MassaK }
|
||||
private readonly string _port;
|
||||
private readonly int _baud;
|
||||
private readonly Proto _proto;
|
||||
public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; }
|
||||
public string Name => $"Весы {_proto} ({_port})";
|
||||
private readonly ScaleProto _proto;
|
||||
public ComScale(string port, int baud, ScaleProto proto) { _port = port; _baud = baud; _proto = proto; }
|
||||
|
||||
public string Name => $"Весы {_proto} ({_port}, {_baud})";
|
||||
public string Port => _port;
|
||||
public int Baud => _baud;
|
||||
public Proto Protocol => _proto;
|
||||
public ScaleProto Protocol => _proto;
|
||||
|
||||
public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1200, WriteTimeout = 800, NewLine = "\r" };
|
||||
sp.Open();
|
||||
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
|
||||
await Task.Delay(120, ct);
|
||||
var line = sp.ReadLine();
|
||||
var kg = _proto == Proto.Cas ? ScaleProtocol.ParseCas(line) : ScaleProtocol.ParseMassaK(line);
|
||||
return kg ?? 0m;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Автопоиск весов по всем COM-портам (вкл. USB-COM — CH340/Prolific
|
||||
/// определяются системой как обычные COMx) с протоколами Масса-К и CAS. Нужен, потому
|
||||
/// что в кассе МойСклад у владельца «Порт не выбран»/«Таймаут» сотни раз в день —
|
||||
/// мы сами находим порт и запоминаем его, без ручной возни.</summary>
|
||||
public static class ScaleDiscovery
|
||||
{
|
||||
public sealed record Found(string Port, int Baud, ComScale.Proto Proto, decimal SampleKg);
|
||||
|
||||
// Типовые скорости весов розницы.
|
||||
private static readonly int[] Bauds = { 9600, 4800, 19200, 115200 };
|
||||
|
||||
public static string[] ListPorts()
|
||||
{
|
||||
try { return SerialPort.GetPortNames().Distinct().OrderBy(x => x).ToArray(); }
|
||||
catch { return Array.Empty<string>(); }
|
||||
}
|
||||
|
||||
/// <summary>Перебрать порты × протоколы × скорости, вернуть первый ответивший
|
||||
/// правдоподобным весом (>= 0). Короткие таймауты, чтобы не висеть.</summary>
|
||||
public static async Task<Found?> DiscoverAsync(Action<string>? log = null, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var port in ListPorts())
|
||||
{
|
||||
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
|
||||
{
|
||||
foreach (var baud in Bauds)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var scale = new ComScale(port, baud, proto);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(1500);
|
||||
var kg = await scale.ReadWeightKgAsync(cts.Token).ConfigureAwait(false);
|
||||
if (kg >= 0m)
|
||||
{
|
||||
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {kg:0.###} кг)");
|
||||
return new Found(port, baud, proto, kg);
|
||||
}
|
||||
}
|
||||
catch { /* порт занят/не те весы/нет ответа — пробуем следующую комбинацию */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
log?.Invoke("Весы: автопоиск не нашёл весов ни на одном порту");
|
||||
return null;
|
||||
}
|
||||
public Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
|
||||
// System.IO.Ports синхронный — уводим в пул, чтобы не блокировать UI-поток.
|
||||
=> Task.Run(() => SerialScaleReader.ReadWeightKg(_port, _baud, _proto), ct);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
using foodmarket.Pos.Core.Hardware;
|
||||
using foodmarket.Pos.Core.Sync;
|
||||
using foodmarket.Pos.Hardware;
|
||||
using ScaleProto = foodmarket.Pos.Core.Hardware.ScaleProto;
|
||||
using Refit;
|
||||
|
||||
namespace foodmarket.Pos.Services;
|
||||
|
|
@ -69,22 +70,23 @@ private static void AppendScaleLog(string msg)
|
|||
catch { /* логирование весов не должно мешать кассе */ }
|
||||
}
|
||||
|
||||
/// <summary>Поднять сохранённый порт весов (если был выбран ранее).</summary>
|
||||
/// <summary>Поднять сохранённый порт весов (если был выбран ранее). По умолчанию
|
||||
/// протокол Масса-К-кадр, 57600 (реальные весы владельца).</summary>
|
||||
public void RestoreScaleFromSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var port = Store.GetKv("scale_port");
|
||||
if (string.IsNullOrWhiteSpace(port)) return;
|
||||
var baud = int.TryParse(Store.GetKv("scale_baud"), out var b) ? b : 9600;
|
||||
var proto = Store.GetKv("scale_proto") == "Cas" ? ComScale.Proto.Cas : ComScale.Proto.MassaK;
|
||||
var baud = int.TryParse(Store.GetKv("scale_baud"), out var b) ? b : 57600;
|
||||
var proto = Enum.TryParse<ScaleProto>(Store.GetKv("scale_proto"), out var p) ? p : ScaleProto.MassaKFrame;
|
||||
SetScale(port, baud, proto);
|
||||
}
|
||||
catch { /* нет сохранённого порта — работаем с ручным вводом */ }
|
||||
}
|
||||
|
||||
/// <summary>Назначить весы на конкретный порт и запомнить выбор.</summary>
|
||||
public void SetScale(string port, int baud, ComScale.Proto proto)
|
||||
public void SetScale(string port, int baud, ScaleProto proto)
|
||||
{
|
||||
var scale = new ComScale(port, baud, proto);
|
||||
Scale = scale;
|
||||
|
|
@ -94,13 +96,13 @@ public void SetScale(string port, int baud, ComScale.Proto proto)
|
|||
Store.SetKv("scale_proto", proto.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает,
|
||||
/// нашлись ли весы.</summary>
|
||||
/// <summary>Автопоиск весов по COM-портам (57600 и Масса-К-кадр — первыми); при
|
||||
/// успехе — запоминаем. Если сохранённые весы не отвечают — автопоиск молча.</summary>
|
||||
public async Task<bool> DiscoverScaleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var found = await ScaleDiscovery.DiscoverAsync(AppendScaleLog, ct);
|
||||
if (found is null) return false;
|
||||
SetScale(found.Port, found.Baud, found.Proto);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using foodmarket.Pos.Hardware;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
|
||||
namespace foodmarket.Pos.Tooling;
|
||||
|
||||
|
|
@ -113,24 +114,33 @@ private static (bool busy, string? err) TryOpenClose(string port)
|
|||
catch (Exception ex) { return (false, ex.GetType().Name + ": " + ex.Message); }
|
||||
}
|
||||
|
||||
// Скорости/протоколы: 57600 и Масса-К-кадр ПЕРВЫМИ (реальные весы владельца).
|
||||
private static readonly (ScaleProto proto, int baud)[] ScaleAttempts =
|
||||
{
|
||||
(ScaleProto.MassaKFrame, 57600),
|
||||
(ScaleProto.MassaKFrame, 9600),
|
||||
(ScaleProto.MassaKAscii, 9600),
|
||||
(ScaleProto.Cas, 9600),
|
||||
};
|
||||
|
||||
private static string ProbeScale(string port, Stopwatch sw)
|
||||
{
|
||||
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
|
||||
foreach (var (proto, baud) in ScaleAttempts)
|
||||
{
|
||||
if (sw.Elapsed > Budget - TimeSpan.FromSeconds(1)) return "не опрошены (лимит времени)";
|
||||
try
|
||||
{
|
||||
var scale = new ComScale(port, 9600, proto);
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(1500));
|
||||
var task = scale.ReadWeightKgAsync(cts.Token);
|
||||
if (task.Wait(1800) && task.IsCompletedSuccessfully)
|
||||
return $"весы найдены ({proto}, 9600): вес = {task.Result:0.###} кг";
|
||||
using var t = new SystemSerialTransport(port, baud);
|
||||
t.Open();
|
||||
var (reading, hex, err) = SerialScaleReader.ReadDetailed(t, proto);
|
||||
if (reading is not null)
|
||||
return $"весы найдены ({proto}, {baud}): вес = {reading.Value.Kg:0.###} {reading.Value.Unit}, "
|
||||
+ $"флаг '{reading.Value.Flag}', кадр [{hex}]";
|
||||
}
|
||||
catch (AggregateException ae) when (ae.InnerException is UnauthorizedAccessException)
|
||||
{ return "порт занят другим приложением"; }
|
||||
catch { /* нет ответа на этом протоколе — пробуем следующий */ }
|
||||
catch (UnauthorizedAccessException) { return "порт занят другим приложением"; }
|
||||
catch { /* нет ответа на этой комбинации — пробуем следующую */ }
|
||||
}
|
||||
return "весы не ответили (Масса-К/CAS, 9600, 1.5 с)";
|
||||
return "весы не ответили (Масса-К-кадр 57600/9600, Масса-К-ASCII/CAS 9600)";
|
||||
}
|
||||
|
||||
private static int NaturalComOrder(string p)
|
||||
|
|
|
|||
46
tests/food-market.UnitTests/BarcodeScanTests.cs
Normal file
46
tests/food-market.UnitTests/BarcodeScanTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.UnitTests;
|
||||
|
||||
public class BarcodeScanTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("4600051000057", true)] // валидный EAN-13
|
||||
[InlineData("4607034170011", true)]
|
||||
[InlineData("96385074", true)] // валидный EAN-8
|
||||
[InlineData("4600051000058", false)] // битая контрольная
|
||||
[InlineData("1234567890123", false)]
|
||||
[InlineData("12345", false)] // не EAN-длина
|
||||
[InlineData("abcdefghabcde", false)] // не цифры
|
||||
public void IsValidEan_checks_control_digit(string code, bool expected)
|
||||
=> BarcodeScan.IsValidEan(code).Should().Be(expected);
|
||||
|
||||
[Fact]
|
||||
public void Fast_even_input_is_classified_as_scanner()
|
||||
{
|
||||
// 13 символов, ~15 мс между ними
|
||||
var ts = new List<long>();
|
||||
for (int i = 0; i < 13; i++) ts.Add(i * 15);
|
||||
var a = BarcodeScan.Analyze("4600051000057", ts);
|
||||
a.Kind.Should().Be(BarcodeScan.InputKind.Scanner);
|
||||
a.IsEan.Should().BeTrue();
|
||||
a.EanValid.Should().BeTrue();
|
||||
a.AvgIntervalMs.Should().BeLessThan(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slow_input_is_classified_as_manual()
|
||||
{
|
||||
var ts = new List<long>();
|
||||
for (int i = 0; i < 8; i++) ts.Add(i * 250);
|
||||
var a = BarcodeScan.Analyze("96385074", ts);
|
||||
a.Kind.Should().Be(BarcodeScan.InputKind.Manual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_input_is_empty()
|
||||
=> BarcodeScan.Analyze("", null).Kind.Should().Be(BarcodeScan.InputKind.Empty);
|
||||
}
|
||||
161
tests/food-market.UnitTests/MassaKScaleTests.cs
Normal file
161
tests/food-market.UnitTests/MassaKScaleTests.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.UnitTests;
|
||||
|
||||
/// <summary>Фейковый COM-порт: эмулирует весы Масса-К по фактам с ноутбука магазина
|
||||
/// (ENQ→ACK, DC1→кадр). Позволяет проверить разбор кадра и рукопожатие без железа.</summary>
|
||||
public sealed class FakeScaleTransport : ISerialTransport
|
||||
{
|
||||
private readonly Queue<int> _rx = new();
|
||||
public List<byte> Written { get; } = new();
|
||||
public bool Opened { get; private set; }
|
||||
|
||||
public bool AnswerAck { get; set; } = true; // отвечает ли ACK на ENQ
|
||||
public byte[]? Frame { get; set; } // кадр, выдаваемый на DC1
|
||||
public bool Opening { get; set; } // бросить при Open (порт занят и т.п.)
|
||||
|
||||
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 DiscardBuffers() { }
|
||||
public void Write(byte[] data, int offset, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var b = data[offset + i];
|
||||
Written.Add(b);
|
||||
if (b == MassaK.ENQ && AnswerAck) _rx.Enqueue(MassaK.ACK);
|
||||
if (b == MassaK.DC1 && Frame is not null) foreach (var fb in Frame) _rx.Enqueue(fb);
|
||||
}
|
||||
}
|
||||
public int ReadByte(int timeoutMs) => _rx.Count > 0 ? _rx.Dequeue() : -1;
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
public class MassaKScaleTests
|
||||
{
|
||||
// Кадр из реального замера: 01 02 'S 0.000kg' 'q' 03 04
|
||||
private static readonly byte[] Frame0 =
|
||||
{ 0x01, 0x02, 0x53, 0x20, 0x20, 0x30, 0x2E, 0x30, 0x30, 0x30, 0x6B, 0x67, 0x71, 0x03, 0x04 };
|
||||
|
||||
private static byte[] BuildFrame(string payloadText, byte control = 0x71)
|
||||
{
|
||||
var body = new List<byte> { MassaK.SOH, MassaK.STX };
|
||||
body.AddRange(Encoding.ASCII.GetBytes(payloadText));
|
||||
body.Add(control);
|
||||
body.Add(MassaK.ETX);
|
||||
body.Add(MassaK.EOT);
|
||||
return body.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_real_zero_frame_from_shop_laptop()
|
||||
{
|
||||
var r = MassaK.ParseFrame(Frame0, out var err);
|
||||
err.Should().BeNull();
|
||||
r.Should().NotBeNull();
|
||||
r!.Value.Kg.Should().Be(0.000m);
|
||||
r.Value.Flag.Should().Be('S');
|
||||
r.Value.Unit.Should().Be("kg");
|
||||
r.Value.Control.Should().Be(0x71);
|
||||
r.Value.RawHex.Should().StartWith("01 02 53");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("S 1.235kg", 1.235)]
|
||||
[InlineData("S 12.500kg", 12.500)]
|
||||
[InlineData("S 0.000kg", 0.000)]
|
||||
[InlineData("S -1.235kg", -1.235)]
|
||||
public void Parses_various_weights(string payload, double expected)
|
||||
{
|
||||
var r = MassaK.ParseFrame(BuildFrame(payload), out var err);
|
||||
err.Should().BeNull();
|
||||
r!.Value.Kg.Should().Be((decimal)expected);
|
||||
r.Value.Unit.Should().Be("kg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grams_payload_is_converted_to_kg()
|
||||
{
|
||||
var r = MassaK.ParseFrame(BuildFrame("S 1235g"), out _);
|
||||
r!.Value.Kg.Should().Be(1.235m);
|
||||
r.Value.Unit.Should().Be("g");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skips_garbage_before_soh()
|
||||
{
|
||||
var withGarbage = new byte[] { 0xFF, 0x00, 0x7E }.Concat(Frame0).ToArray();
|
||||
var r = MassaK.ParseFrame(withGarbage, out var err);
|
||||
err.Should().BeNull();
|
||||
r!.Value.Kg.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_frame_without_etx_is_error()
|
||||
{
|
||||
var bad = new byte[] { 0x01, 0x02, 0x53, 0x20, 0x30, 0x2E, 0x30 }; // нет ETX/EOT
|
||||
var r = MassaK.ParseFrame(bad, out var err);
|
||||
r.Should().BeNull();
|
||||
err.Should().Contain("ETX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pure_garbage_without_soh_is_error()
|
||||
{
|
||||
var r = MassaK.ParseFrame(new byte[] { 0xAA, 0xBB, 0xCC }, out var err);
|
||||
r.Should().BeNull();
|
||||
err.Should().Contain("SOH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handshake_succeeds_when_scale_answers_ack()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = true };
|
||||
MassaK.Handshake(t).Should().BeTrue();
|
||||
t.Written.Should().Contain(MassaK.ENQ);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handshake_fails_without_ack()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = false };
|
||||
MassaK.Handshake(t, ackTimeoutMs: 50, attempts: 2).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Full_read_cycle_handshake_then_frame()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = true, Frame = BuildFrame("S 1.235kg") };
|
||||
var (reading, hex, err) = MassaK.ReadWeight(t);
|
||||
err.Should().BeNull();
|
||||
reading!.Value.Kg.Should().Be(1.235m);
|
||||
hex.Should().Contain("01 02");
|
||||
t.Written.Should().Contain(MassaK.DC1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_cycle_reports_missing_ack()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = false };
|
||||
var (reading, _, err) = MassaK.ReadWeight(t, ackTimeoutMs: 50);
|
||||
reading.Should().BeNull();
|
||||
err.Should().Contain("ACK");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadStable_detects_stable_and_unstable()
|
||||
{
|
||||
// стабильно: один и тот же вес дважды
|
||||
var stableFrame = BuildFrame("S 2.000kg");
|
||||
var (kg, stable) = SerialScaleReader.ReadStable("COMX", 57600, ScaleProto.MassaKFrame,
|
||||
transportFactory: (_, _) => new FakeScaleTransport { AnswerAck = true, Frame = stableFrame }, gapMs: 1);
|
||||
kg.Should().Be(2.000m);
|
||||
stable.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue