Compare commits

...

4 commits

Author SHA1 Message Date
nns 6f97e327b1 docs(pos): отчёт по протоколу весов Масса-К + «Проверка оборудования» (скриншот Win11)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Протокол и тулзы — готовы и проверены (50/50 юнит-тестов по реальным байтам;
окно «Проверка оборудования» запущено на Windows 11). НЕ выполнено: live-чтение
веса через виртуальную пару COM на VM (драйвер com0com требует доверия издателю —
решение владельца) и реальные весы с грузом в магазине (нужен человек с гирей).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 08:54:53 +05:00
nns 19cbdbbf11 build(pos): скрипт сборки «Проверка оборудования» под Windows x64 (zip + ярлык + README)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 08:41:25 +05:00
nns ff1580d162 feat(pos): отдельная программа «ALL MARKET — Проверка оборудования» (без входа/сервера/БД)
Приоритет владельца: sysadmin проверяет весы/принтер/сканер на ноутбуке магазина
без входа в кассу. Отдельный exe (food-market.pos.hwcheck), ссылается ТОЛЬКО на
pos.core ради Hardware/* — не умеет быть кассой (нет логина, сервера, БД, токенов,
сетевых запросов).

- pos.core/Hardware/HardwareProbe.cs: детект COM-портов (занят/свободен, VID/PID,
  описание) и принтеров Windows (имя/порт/в сети/число заданий) — только чтение,
  очередь не трогаем. WMI под OperatingSystem.IsWindows(); на Linux — базовый COM.
- pos.core/Hardware/BarcodeScan.cs: контрольная цифра EAN-13/EAN-8 и оценка
  «сканер vs ручной набор» по среднему интервалу между символами (чистая логика).
- food-market.pos.hwcheck: окно на Avalonia (код, без XAML), светлая тема, русский.
  Весы (Найти/Прочитать/Запустить-Остановить, live-вес ~350 мс, сырой hex, ручной
  выбор порта/скорости/протокола), COM-порты с пометкой «занят», принтеры (только
  чтение), сканер (EAN + тип ввода), «Сохранить/Копировать отчёт». Тестовая печать
  ВЫКЛ по умолчанию, включается только --allow-test-print.
- tests: EAN контрольная цифра (13/8, валид/битый) + классификация ввода. 50/50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 08:37:55 +05:00
nns 25c37415bf feat(pos): реальный протокол весов Масса-К (57600, ENQ/ACK, DC1, кадр) вместо угаданного
По фактическому замеру с ноутбука магазина (11.10): весы Масса-К (USB-COM CH340)
отвечают только на 57600 8N1; рукопожатие ENQ(0x05)→ACK(0x06); запрос DC1(0x11) →
кадр SOH STX «флаг вес единица» CTRL ETX EOT.

- 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: SerialScaleReader (+ стабильность по двум
  одинаковым чтениям) и ScaleAutodiscovery — 57600 и Масса-К-кадр ПЕРВЫМИ, общий
  бюджет, отдельно отмечаем занятые порты («порт занят», а не «весы не найдены»).
- pos: ComScale — тонкий адаптер к pos.core; AppServices хранит scale_proto и по
  умолчанию MassaKFrame/57600; ProbeRunner (--probe-hw) печатает сырой hex кадра.
- tests: разбор реального нулевого кадра 01 02 53 20 20 30 2E 30 30 30 6B 67 71 03 04
  и вариантов (1.235 / 12.500 / отрицательный / граммы / мусор до SOH / обрезанный /
  без ACK) + рукопожатие на фейковом порту. 14/14 зелёных.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 08:29:06 +05:00
20 changed files with 1432 additions and 126 deletions

50
deploy/hwcheck-publish.sh Normal file
View 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"

View 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` — та же сводка со скриншотом.

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View file

@ -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

View 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>Классифицировать ввод по символам и их временам (мс от начала).
/// Сканер печатает быстро и равномерно (обычно &lt; 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 => "пусто",
_ => "не определено",
};
}

View 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;
}
}

View 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);
}
}

View 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 &lt;payload&gt; 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);
}
}

View file

@ -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" />

View 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();
}
}

View 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 = "Буфер обмена недоступен.";
}
}

View 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();
}
}

View 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); }
}
}

View 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>

View file

@ -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>Перебрать порты × протоколы × скорости, вернуть первый ответивший
/// правдоподобным весом (&gt;= 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);
}

View file

@ -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;
}

View file

@ -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)

View 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);
}

View 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();
}
}