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>
This commit is contained in:
nns 2026-10-11 08:29:06 +05:00
parent d04d1d53fa
commit 25c37415bf
7 changed files with 529 additions and 126 deletions

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,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="System.IO.Ports" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Refit" />
<PackageReference Include="Refit.HttpClientFactory" />

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