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:
parent
d04d1d53fa
commit
25c37415bf
150
src/food-market.pos.core/Hardware/ScaleDiscovery.cs
Normal file
150
src/food-market.pos.core/Hardware/ScaleDiscovery.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
|
||||
namespace foodmarket.Pos.Core.Hardware;
|
||||
|
||||
/// <summary>Чтение веса на заданном порту/скорости/протоколе + стабильность (два
|
||||
/// одинаковых чтения подряд). Транспорт абстрагирован — тестируется фейком.</summary>
|
||||
public static class SerialScaleReader
|
||||
{
|
||||
/// <summary>Подробное чтение (для диагностики): кадр, hex, флаг, control-байт.</summary>
|
||||
public static (MassaK.Reading? reading, string rawHex, string? error) ReadDetailed(
|
||||
ISerialTransport t, ScaleProto proto)
|
||||
{
|
||||
switch (proto)
|
||||
{
|
||||
case ScaleProto.MassaKFrame:
|
||||
return MassaK.ReadWeight(t);
|
||||
case ScaleProto.MassaKAscii:
|
||||
case ScaleProto.Cas:
|
||||
{
|
||||
// «угаданные» ASCII-режимы: CAS шлёт 'W', Масса-К-ASCII — ничего; читаем строку.
|
||||
t.DiscardBuffers();
|
||||
if (proto == ScaleProto.Cas) t.Write(new[] { (byte)'W' }, 0, 1);
|
||||
var line = ReadAsciiLine(t, 1000);
|
||||
if (string.IsNullOrWhiteSpace(line)) return (null, "", "нет строки от весов (ASCII)");
|
||||
var kg = MassaK.ParseWeight(line, out var unit);
|
||||
if (kg is null) return (null, MassaK.ToHex(Encoding.ASCII.GetBytes(line)), $"не распознан вес в «{line}»");
|
||||
return (new MassaK.Reading(kg.Value, '\0', unit, null, MassaK.ToHex(Encoding.ASCII.GetBytes(line))), "", null);
|
||||
}
|
||||
default:
|
||||
return (null, "", "неизвестный протокол");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadAsciiLine(ISerialTransport t, int totalTimeoutMs)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var deadline = Environment.TickCount64 + totalTimeoutMs;
|
||||
while (sb.Length < 64)
|
||||
{
|
||||
int remaining = (int)(deadline - Environment.TickCount64);
|
||||
if (remaining <= 0) break;
|
||||
int b = t.ReadByte(Math.Min(remaining, 300));
|
||||
if (b < 0) break;
|
||||
if (b == '\r' || b == '\n') { if (sb.Length > 0) break; else continue; }
|
||||
sb.Append((char)b);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Считать вес в кг (бросает при отсутствии связи). Фабрика транспорта
|
||||
/// подменяется в тестах; по умолчанию — реальный COM.</summary>
|
||||
public static decimal ReadWeightKg(string port, int baud, ScaleProto proto,
|
||||
Func<string, int, ISerialTransport>? transportFactory = null)
|
||||
{
|
||||
var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
|
||||
using var t = make(port, baud);
|
||||
t.Open();
|
||||
var (reading, _, error) = ReadDetailed(t, proto);
|
||||
if (reading is null) throw new IOException(error ?? "весы не ответили");
|
||||
return reading.Value.Kg;
|
||||
}
|
||||
|
||||
/// <summary>Стабильность: два чтения с интервалом ~150–200 мс; стабильно, если
|
||||
/// совпали (флаг весов НЕ интерпретируем — его смысл не подтверждён).</summary>
|
||||
public static (decimal kg, bool stable) ReadStable(string port, int baud, ScaleProto proto,
|
||||
Func<string, int, ISerialTransport>? transportFactory = null, int gapMs = 180)
|
||||
{
|
||||
var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
|
||||
using var t = make(port, baud);
|
||||
t.Open();
|
||||
var (r1, _, e1) = ReadDetailed(t, proto);
|
||||
if (r1 is null) throw new IOException(e1 ?? "весы не ответили");
|
||||
Thread.Sleep(gapMs);
|
||||
var (r2, _, _) = ReadDetailed(t, proto);
|
||||
bool stable = r2 is not null && r2.Value.Kg == r1.Value.Kg;
|
||||
return (r2?.Kg ?? r1.Value.Kg, stable);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Автопоиск весов по COM-портам. 57600 и Масса-К-кадр — ПЕРВЫМИ (реальный
|
||||
/// случай владельца). Общий бюджет ограничен; занятый порт отмечается отдельно,
|
||||
/// чтобы сказать кассиру «порт занят», а не «весы не найдены».</summary>
|
||||
public static class ScaleAutodiscovery
|
||||
{
|
||||
public sealed record Found(string Port, int Baud, ScaleProto Proto, decimal SampleKg, string RawHex);
|
||||
public sealed record Result(Found? Device, IReadOnlyList<string> BusyPorts, IReadOnlyList<string> ScannedPorts);
|
||||
|
||||
// 57600 первой (реальные весы владельца); дальше — типовые скорости розницы.
|
||||
public static readonly int[] Bauds = { 57600, 9600, 4800, 19200, 38400, 115200 };
|
||||
public static readonly ScaleProto[] Protos = { ScaleProto.MassaKFrame, ScaleProto.MassaKAscii, ScaleProto.Cas };
|
||||
|
||||
public static string[] ListPorts()
|
||||
{
|
||||
try { return SerialPort.GetPortNames().Distinct().OrderBy(NaturalOrder).ToArray(); }
|
||||
catch { return Array.Empty<string>(); }
|
||||
}
|
||||
private static int NaturalOrder(string p)
|
||||
=> int.TryParse(new string(p.Where(char.IsDigit).ToArray()), out var n) ? n : 9999;
|
||||
|
||||
/// <summary>Перебрать порты × протоколы × скорости. Для Масса-К-кадра достаточно
|
||||
/// быстрого ENQ→ACK: нет ACK → сразу следующая комбинация (быстро). Бюджет общий.</summary>
|
||||
public static Result Discover(Action<string>? log = null,
|
||||
Func<string, int, ISerialTransport>? transportFactory = null,
|
||||
int budgetMs = 12000, CancellationToken ct = default)
|
||||
{
|
||||
var make = transportFactory ?? ((p, b) => new SystemSerialTransport(p, b));
|
||||
var sw = Stopwatch.StartNew();
|
||||
var busy = new List<string>();
|
||||
var scanned = new List<string>();
|
||||
foreach (var port in ListPorts())
|
||||
{
|
||||
if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break;
|
||||
scanned.Add(port);
|
||||
// быстрая проверка занятости
|
||||
try { using var probe = make(port, Bauds[0]); probe.Open(); }
|
||||
catch (UnauthorizedAccessException) { busy.Add(port); log?.Invoke($"{port}: занят другой программой"); continue; }
|
||||
catch { /* не открылся по иной причине — всё равно попробуем протоколы ниже */ }
|
||||
|
||||
foreach (var proto in Protos)
|
||||
{
|
||||
foreach (var baud in Bauds)
|
||||
{
|
||||
if (ct.IsCancellationRequested || sw.ElapsedMilliseconds > budgetMs) break;
|
||||
// ASCII-режимы на экзотических скоростях не перебираем — только типовые
|
||||
if (proto != ScaleProto.MassaKFrame && baud is 57600 or 38400) continue;
|
||||
try
|
||||
{
|
||||
using var t = make(port, baud);
|
||||
t.Open();
|
||||
var (reading, hex, _) = SerialScaleReader.ReadDetailed(t, proto);
|
||||
if (reading is not null && reading.Value.Kg >= 0m)
|
||||
{
|
||||
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {reading.Value.Kg:0.###} кг; кадр {hex})");
|
||||
return new Result(new Found(port, baud, proto, reading.Value.Kg, hex), busy, scanned);
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException) { if (!busy.Contains(port)) busy.Add(port); break; }
|
||||
catch { /* нет ответа — следующая комбинация */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
log?.Invoke(busy.Count > 0
|
||||
? $"Весы не найдены; заняты порты: {string.Join(", ", busy)}"
|
||||
: "Весы: автопоиск не нашёл весов");
|
||||
return new Result(null, busy, scanned);
|
||||
}
|
||||
}
|
||||
175
src/food-market.pos.core/Hardware/SerialScale.cs
Normal file
175
src/food-market.pos.core/Hardware/SerialScale.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
using System.Globalization;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
|
||||
namespace foodmarket.Pos.Core.Hardware;
|
||||
|
||||
/// <summary>Протоколы весов, которые умеет касса.
|
||||
/// • <see cref="MassaKFrame"/> — реальный протокол весов владельца (Масса-К, USB-COM
|
||||
/// CH340, 57600 8N1): рукопожатие ENQ→ACK, запрос веса DC1 → кадр
|
||||
/// SOH STX «флаг вес единица» CTRL ETX EOT. Разобран по фактическим байтам с
|
||||
/// ноутбука магазина (замер 11.10), не угадан.
|
||||
/// • <see cref="MassaKAscii"/>/<see cref="Cas"/> — прежние «угаданные» ASCII-режимы,
|
||||
/// оставлены как запасные варианты автопоиска.</summary>
|
||||
public enum ScaleProto { MassaKFrame, MassaKAscii, Cas }
|
||||
|
||||
/// <summary>Абстракция последовательного транспорта — чтобы протокол и рукопожатие
|
||||
/// покрывались юнит-тестами фейковым портом, без реального железа.</summary>
|
||||
public interface ISerialTransport : IDisposable
|
||||
{
|
||||
void Open();
|
||||
void DiscardBuffers();
|
||||
void Write(byte[] data, int offset, int count);
|
||||
/// <summary>Прочитать один байт; -1 при таймауте/конце потока.</summary>
|
||||
int ReadByte(int timeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>Реальный COM-порт через System.IO.Ports (Windows и Linux).</summary>
|
||||
public sealed class SystemSerialTransport : ISerialTransport
|
||||
{
|
||||
private readonly SerialPort _sp;
|
||||
public SystemSerialTransport(string port, int baud)
|
||||
{
|
||||
// 8N1 — как у весов владельца; DTR/RTS включаем (некоторым USB-COM нужно питание линий).
|
||||
_sp = new SerialPort(port, baud, Parity.None, 8, StopBits.One)
|
||||
{
|
||||
ReadTimeout = 500,
|
||||
WriteTimeout = 500,
|
||||
DtrEnable = true,
|
||||
RtsEnable = true,
|
||||
};
|
||||
}
|
||||
public void Open() => _sp.Open();
|
||||
public void DiscardBuffers() { try { _sp.DiscardInBuffer(); _sp.DiscardOutBuffer(); } catch { } }
|
||||
public void Write(byte[] data, int offset, int count) => _sp.Write(data, offset, count);
|
||||
public int ReadByte(int timeoutMs)
|
||||
{
|
||||
_sp.ReadTimeout = timeoutMs;
|
||||
try { return _sp.ReadByte(); }
|
||||
catch (TimeoutException) { return -1; }
|
||||
}
|
||||
public void Dispose() { try { if (_sp.IsOpen) _sp.Close(); } catch { } _sp.Dispose(); }
|
||||
}
|
||||
|
||||
/// <summary>Протокол Масса-К (кадровый). Control-байт НЕ валидируем, пока он не
|
||||
/// подтверждён документацией/замерами с грузом — только логируем сырым hex, чтобы
|
||||
/// владелец/sysadmin прислали реальные кадры.</summary>
|
||||
public static class MassaK
|
||||
{
|
||||
public const byte ENQ = 0x05, ACK = 0x06, DC1 = 0x11, SOH = 0x01, STX = 0x02, ETX = 0x03, EOT = 0x04;
|
||||
|
||||
/// <summary>Разобранный кадр веса.</summary>
|
||||
public readonly record struct Reading(decimal Kg, char Flag, string Unit, byte? Control, string RawHex);
|
||||
|
||||
public static string ToHex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var sb = new StringBuilder(bytes.Length * 3);
|
||||
for (int i = 0; i < bytes.Length; i++) { if (i > 0) sb.Append(' '); sb.Append(bytes[i].ToString("X2")); }
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Разобрать кадр веса. Терпимо к мусору до SOH. Возвращает null и
|
||||
/// заполняет <paramref name="error"/>, если кадр неполон/битый.
|
||||
/// Структура: [мусор] SOH STX <payload> ETX EOT, где последний байт payload —
|
||||
/// контрольный (например 0x71 'q'), остальное — «флаг + вес + единица».</summary>
|
||||
public static Reading? ParseFrame(ReadOnlySpan<byte> raw, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var rawHex = ToHex(raw);
|
||||
// 1) найти SOH (пропустить мусор до него)
|
||||
int i = raw.IndexOf(SOH);
|
||||
if (i < 0) { error = "нет SOH (0x01) — не кадр весов"; return null; }
|
||||
// 2) STX сразу за SOH
|
||||
if (i + 1 >= raw.Length || raw[i + 1] != STX) { error = "нет STX (0x02) за SOH"; return null; }
|
||||
int payloadStart = i + 2;
|
||||
// 3) ETX
|
||||
int etx = -1;
|
||||
for (int k = payloadStart; k < raw.Length; k++) { if (raw[k] == ETX) { etx = k; break; } }
|
||||
if (etx < 0) { error = "нет ETX (0x03) — кадр обрезан"; return null; }
|
||||
// 4) EOT после ETX (если есть — проверяем; отсутствие не фатально)
|
||||
if (etx + 1 < raw.Length && raw[etx + 1] != EOT) error = "после ETX не EOT (0x04) — проверьте кадр";
|
||||
var payload = raw.Slice(payloadStart, etx - payloadStart);
|
||||
if (payload.Length == 0) { error = "пустой payload между STX и ETX"; return null; }
|
||||
// последний байт payload — контрольный (не валидируем, только сохраняем)
|
||||
byte? control = payload[^1];
|
||||
var textBytes = payload; // число вытаскиваем из всего payload (control-символ не мешает)
|
||||
var text = Encoding.ASCII.GetString(textBytes);
|
||||
var kg = ParseWeight(text, out var unit);
|
||||
if (kg is null) { error = (error is null ? "" : error + "; ") + $"не распознан вес в «{text}»"; return null; }
|
||||
char flag = text.Length > 0 && !char.IsDigit(text[0]) && text[0] != '-' && text[0] != '+' && text[0] != ' '
|
||||
? text[0] : '\0';
|
||||
return new Reading(kg.Value, flag, unit, control, rawHex);
|
||||
}
|
||||
|
||||
/// <summary>Вытащить вес из ASCII-строки payload. kg по умолчанию; «г»/«g» (не «kg»)
|
||||
/// → граммы (делим на 1000). Поддержаны знак, точка и запятая.</summary>
|
||||
public static decimal? ParseWeight(string text, out string unit)
|
||||
{
|
||||
unit = "kg";
|
||||
if (string.IsNullOrEmpty(text)) return null;
|
||||
bool isKg = text.Contains("kg", StringComparison.OrdinalIgnoreCase) || text.Contains("кг");
|
||||
bool isGram = !isKg && (text.Contains('g') || text.Contains('г'));
|
||||
var num = ExtractNumber(text);
|
||||
if (num is null) return null;
|
||||
if (isGram) { unit = "g"; return decimal.Round(num.Value / 1000m, 3); }
|
||||
unit = "kg";
|
||||
return decimal.Round(num.Value, 3);
|
||||
}
|
||||
|
||||
private static decimal? ExtractNumber(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
bool seenDigit = false;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (char.IsDigit(c) || c == '.' || c == ',' || (!seenDigit && (c == '+' || c == '-')))
|
||||
{ sb.Append(c == ',' ? '.' : c); if (char.IsDigit(c)) seenDigit = true; }
|
||||
else if (seenDigit) break; // число кончилось
|
||||
}
|
||||
var str = sb.ToString().Trim('+');
|
||||
return decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
|
||||
}
|
||||
|
||||
/// <summary>Рукопожатие ENQ→ACK с повтором. true если весы ответили ACK.</summary>
|
||||
public static bool Handshake(ISerialTransport t, int ackTimeoutMs = 400, int attempts = 2)
|
||||
{
|
||||
for (int a = 0; a < attempts; a++)
|
||||
{
|
||||
t.DiscardBuffers();
|
||||
t.Write(new[] { ENQ }, 0, 1);
|
||||
int b = t.ReadByte(ackTimeoutMs);
|
||||
if (b == ACK) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Прочитать кадр: байты до EOT (0x04) включительно, с общим таймаутом.</summary>
|
||||
public static byte[] ReadFrame(ISerialTransport t, int totalTimeoutMs = 700, int maxBytes = 64)
|
||||
{
|
||||
var buf = new List<byte>(32);
|
||||
var deadline = Environment.TickCount64 + totalTimeoutMs;
|
||||
while (buf.Count < maxBytes)
|
||||
{
|
||||
int remaining = (int)(deadline - Environment.TickCount64);
|
||||
if (remaining <= 0) break;
|
||||
int b = t.ReadByte(Math.Min(remaining, 300));
|
||||
if (b < 0) break;
|
||||
buf.Add((byte)b);
|
||||
if (b == EOT) break;
|
||||
}
|
||||
return buf.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Полный цикл: handshake → DC1 → кадр → разбор. Возвращает (кадр, hex,
|
||||
/// ошибка). Транспорт должен быть уже открыт.</summary>
|
||||
public static (Reading? reading, string rawHex, string? error) ReadWeight(ISerialTransport t,
|
||||
int ackTimeoutMs = 400, int frameTimeoutMs = 700)
|
||||
{
|
||||
if (!Handshake(t, ackTimeoutMs)) return (null, "", "весы не ответили ACK на ENQ");
|
||||
t.Write(new[] { DC1 }, 0, 1);
|
||||
var frame = ReadFrame(t, frameTimeoutMs);
|
||||
if (frame.Length == 0) return (null, "", "нет ответа на DC1 (пустой кадр)");
|
||||
var r = ParseFrame(frame, out var err);
|
||||
return (r, ToHex(frame), err);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="System.IO.Ports" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Refit" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.IO.Ports;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
|
||||
namespace foodmarket.Pos.Hardware;
|
||||
|
||||
|
|
@ -19,117 +18,22 @@ public sealed class ManualScale : IScale
|
|||
public Task<decimal> ReadWeightKgAsync(CancellationToken ct = default) => Task.FromResult(LastValue);
|
||||
}
|
||||
|
||||
/// <summary>Разбор ответа весов по COM. Поддержаны простые ASCII-протоколы
|
||||
/// CAS (строка с граммами/кг + завершитель) и Масса-К (ASCII «вес кг»). Парсер
|
||||
/// выделен и тестируется отдельно; реальный COM-поток — в <see cref="ComScale"/>.</summary>
|
||||
public static class ScaleProtocol
|
||||
{
|
||||
/// <summary>CAS: ASCII-строка вида "ST,GS, 1.234kg" или "+001.234 kg" — тянем
|
||||
/// первое число, трактуем как кг (если явно граммы — делим).</summary>
|
||||
public static decimal? ParseCas(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var isGrams = raw.Contains('g', StringComparison.OrdinalIgnoreCase) && !raw.Contains("kg", StringComparison.OrdinalIgnoreCase);
|
||||
var num = ExtractNumber(raw);
|
||||
if (num is null) return null;
|
||||
var kg = isGrams ? num.Value / 1000m : num.Value;
|
||||
return decimal.Round(kg, 3);
|
||||
}
|
||||
|
||||
/// <summary>Масса-К (ASCII-режим): строка с весом в кг, например "1.234".</summary>
|
||||
public static decimal? ParseMassaK(string raw)
|
||||
{
|
||||
var num = ExtractNumber(raw);
|
||||
return num is null ? null : decimal.Round(num.Value, 3);
|
||||
}
|
||||
|
||||
private static decimal? ExtractNumber(string s)
|
||||
{
|
||||
var chars = new System.Text.StringBuilder();
|
||||
bool seen = false;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (char.IsDigit(c) || c == '.' || c == ',' || (!seen && (c == '+' || c == '-')))
|
||||
{ chars.Append(c == ',' ? '.' : c); seen = char.IsDigit(c) || seen; }
|
||||
else if (seen && chars.Length > 0 && (chars[^1] is >= '0' and <= '9')) break;
|
||||
}
|
||||
return decimal.TryParse(chars.ToString().Trim('+'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Весы по COM/serial. Протокол: Cas | MassaK. Открывает порт, шлёт
|
||||
/// запрос веса (опц.), читает строку, парсит. Доступно на Windows и Linux
|
||||
/// (System.IO.Ports).</summary>
|
||||
/// <summary>Весы по COM/serial. Протокол и разбор кадра — в
|
||||
/// <see cref="foodmarket.Pos.Core.Hardware.MassaK"/> (pos.core, покрыто юнит-тестами).
|
||||
/// Этот класс — только адаптер к <see cref="IScale"/> и мониторингу кассы.</summary>
|
||||
public sealed class ComScale : IScale
|
||||
{
|
||||
public enum Proto { Cas, MassaK }
|
||||
private readonly string _port;
|
||||
private readonly int _baud;
|
||||
private readonly Proto _proto;
|
||||
public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; }
|
||||
public string Name => $"Весы {_proto} ({_port})";
|
||||
private readonly ScaleProto _proto;
|
||||
public ComScale(string port, int baud, ScaleProto proto) { _port = port; _baud = baud; _proto = proto; }
|
||||
|
||||
public string Name => $"Весы {_proto} ({_port}, {_baud})";
|
||||
public string Port => _port;
|
||||
public int Baud => _baud;
|
||||
public Proto Protocol => _proto;
|
||||
public ScaleProto Protocol => _proto;
|
||||
|
||||
public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1200, WriteTimeout = 800, NewLine = "\r" };
|
||||
sp.Open();
|
||||
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
|
||||
await Task.Delay(120, ct);
|
||||
var line = sp.ReadLine();
|
||||
var kg = _proto == Proto.Cas ? ScaleProtocol.ParseCas(line) : ScaleProtocol.ParseMassaK(line);
|
||||
return kg ?? 0m;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Автопоиск весов по всем COM-портам (вкл. USB-COM — CH340/Prolific
|
||||
/// определяются системой как обычные COMx) с протоколами Масса-К и CAS. Нужен, потому
|
||||
/// что в кассе МойСклад у владельца «Порт не выбран»/«Таймаут» сотни раз в день —
|
||||
/// мы сами находим порт и запоминаем его, без ручной возни.</summary>
|
||||
public static class ScaleDiscovery
|
||||
{
|
||||
public sealed record Found(string Port, int Baud, ComScale.Proto Proto, decimal SampleKg);
|
||||
|
||||
// Типовые скорости весов розницы.
|
||||
private static readonly int[] Bauds = { 9600, 4800, 19200, 115200 };
|
||||
|
||||
public static string[] ListPorts()
|
||||
{
|
||||
try { return SerialPort.GetPortNames().Distinct().OrderBy(x => x).ToArray(); }
|
||||
catch { return Array.Empty<string>(); }
|
||||
}
|
||||
|
||||
/// <summary>Перебрать порты × протоколы × скорости, вернуть первый ответивший
|
||||
/// правдоподобным весом (>= 0). Короткие таймауты, чтобы не висеть.</summary>
|
||||
public static async Task<Found?> DiscoverAsync(Action<string>? log = null, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var port in ListPorts())
|
||||
{
|
||||
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
|
||||
{
|
||||
foreach (var baud in Bauds)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var scale = new ComScale(port, baud, proto);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(1500);
|
||||
var kg = await scale.ReadWeightKgAsync(cts.Token).ConfigureAwait(false);
|
||||
if (kg >= 0m)
|
||||
{
|
||||
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {kg:0.###} кг)");
|
||||
return new Found(port, baud, proto, kg);
|
||||
}
|
||||
}
|
||||
catch { /* порт занят/не те весы/нет ответа — пробуем следующую комбинацию */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
log?.Invoke("Весы: автопоиск не нашёл весов ни на одном порту");
|
||||
return null;
|
||||
}
|
||||
public Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
|
||||
// System.IO.Ports синхронный — уводим в пул, чтобы не блокировать UI-поток.
|
||||
=> Task.Run(() => SerialScaleReader.ReadWeightKg(_port, _baud, _proto), ct);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
using foodmarket.Pos.Core.Hardware;
|
||||
using foodmarket.Pos.Core.Sync;
|
||||
using foodmarket.Pos.Hardware;
|
||||
using ScaleProto = foodmarket.Pos.Core.Hardware.ScaleProto;
|
||||
using Refit;
|
||||
|
||||
namespace foodmarket.Pos.Services;
|
||||
|
|
@ -69,22 +70,23 @@ private static void AppendScaleLog(string msg)
|
|||
catch { /* логирование весов не должно мешать кассе */ }
|
||||
}
|
||||
|
||||
/// <summary>Поднять сохранённый порт весов (если был выбран ранее).</summary>
|
||||
/// <summary>Поднять сохранённый порт весов (если был выбран ранее). По умолчанию
|
||||
/// протокол Масса-К-кадр, 57600 (реальные весы владельца).</summary>
|
||||
public void RestoreScaleFromSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var port = Store.GetKv("scale_port");
|
||||
if (string.IsNullOrWhiteSpace(port)) return;
|
||||
var baud = int.TryParse(Store.GetKv("scale_baud"), out var b) ? b : 9600;
|
||||
var proto = Store.GetKv("scale_proto") == "Cas" ? ComScale.Proto.Cas : ComScale.Proto.MassaK;
|
||||
var baud = int.TryParse(Store.GetKv("scale_baud"), out var b) ? b : 57600;
|
||||
var proto = Enum.TryParse<ScaleProto>(Store.GetKv("scale_proto"), out var p) ? p : ScaleProto.MassaKFrame;
|
||||
SetScale(port, baud, proto);
|
||||
}
|
||||
catch { /* нет сохранённого порта — работаем с ручным вводом */ }
|
||||
}
|
||||
|
||||
/// <summary>Назначить весы на конкретный порт и запомнить выбор.</summary>
|
||||
public void SetScale(string port, int baud, ComScale.Proto proto)
|
||||
public void SetScale(string port, int baud, ScaleProto proto)
|
||||
{
|
||||
var scale = new ComScale(port, baud, proto);
|
||||
Scale = scale;
|
||||
|
|
@ -94,13 +96,13 @@ public void SetScale(string port, int baud, ComScale.Proto proto)
|
|||
Store.SetKv("scale_proto", proto.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает,
|
||||
/// нашлись ли весы.</summary>
|
||||
/// <summary>Автопоиск весов по COM-портам (57600 и Масса-К-кадр — первыми); при
|
||||
/// успехе — запоминаем. Если сохранённые весы не отвечают — автопоиск молча.</summary>
|
||||
public async Task<bool> DiscoverScaleAsync(CancellationToken ct = default)
|
||||
{
|
||||
var found = await ScaleDiscovery.DiscoverAsync(AppendScaleLog, ct);
|
||||
if (found is null) return false;
|
||||
SetScale(found.Port, found.Baud, found.Proto);
|
||||
var res = await Task.Run(() => ScaleAutodiscovery.Discover(AppendScaleLog, ct: ct), ct);
|
||||
if (res.Device is null) return false;
|
||||
SetScale(res.Device.Port, res.Device.Baud, res.Device.Proto);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using foodmarket.Pos.Hardware;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
|
||||
namespace foodmarket.Pos.Tooling;
|
||||
|
||||
|
|
@ -113,24 +114,33 @@ private static (bool busy, string? err) TryOpenClose(string port)
|
|||
catch (Exception ex) { return (false, ex.GetType().Name + ": " + ex.Message); }
|
||||
}
|
||||
|
||||
// Скорости/протоколы: 57600 и Масса-К-кадр ПЕРВЫМИ (реальные весы владельца).
|
||||
private static readonly (ScaleProto proto, int baud)[] ScaleAttempts =
|
||||
{
|
||||
(ScaleProto.MassaKFrame, 57600),
|
||||
(ScaleProto.MassaKFrame, 9600),
|
||||
(ScaleProto.MassaKAscii, 9600),
|
||||
(ScaleProto.Cas, 9600),
|
||||
};
|
||||
|
||||
private static string ProbeScale(string port, Stopwatch sw)
|
||||
{
|
||||
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
|
||||
foreach (var (proto, baud) in ScaleAttempts)
|
||||
{
|
||||
if (sw.Elapsed > Budget - TimeSpan.FromSeconds(1)) return "не опрошены (лимит времени)";
|
||||
try
|
||||
{
|
||||
var scale = new ComScale(port, 9600, proto);
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(1500));
|
||||
var task = scale.ReadWeightKgAsync(cts.Token);
|
||||
if (task.Wait(1800) && task.IsCompletedSuccessfully)
|
||||
return $"весы найдены ({proto}, 9600): вес = {task.Result:0.###} кг";
|
||||
using var t = new SystemSerialTransport(port, baud);
|
||||
t.Open();
|
||||
var (reading, hex, err) = SerialScaleReader.ReadDetailed(t, proto);
|
||||
if (reading is not null)
|
||||
return $"весы найдены ({proto}, {baud}): вес = {reading.Value.Kg:0.###} {reading.Value.Unit}, "
|
||||
+ $"флаг '{reading.Value.Flag}', кадр [{hex}]";
|
||||
}
|
||||
catch (AggregateException ae) when (ae.InnerException is UnauthorizedAccessException)
|
||||
{ return "порт занят другим приложением"; }
|
||||
catch { /* нет ответа на этом протоколе — пробуем следующий */ }
|
||||
catch (UnauthorizedAccessException) { return "порт занят другим приложением"; }
|
||||
catch { /* нет ответа на этой комбинации — пробуем следующую */ }
|
||||
}
|
||||
return "весы не ответили (Масса-К/CAS, 9600, 1.5 с)";
|
||||
return "весы не ответили (Масса-К-кадр 57600/9600, Масса-К-ASCII/CAS 9600)";
|
||||
}
|
||||
|
||||
private static int NaturalComOrder(string p)
|
||||
|
|
|
|||
161
tests/food-market.UnitTests/MassaKScaleTests.cs
Normal file
161
tests/food-market.UnitTests/MassaKScaleTests.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using foodmarket.Pos.Core.Hardware;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.UnitTests;
|
||||
|
||||
/// <summary>Фейковый COM-порт: эмулирует весы Масса-К по фактам с ноутбука магазина
|
||||
/// (ENQ→ACK, DC1→кадр). Позволяет проверить разбор кадра и рукопожатие без железа.</summary>
|
||||
public sealed class FakeScaleTransport : ISerialTransport
|
||||
{
|
||||
private readonly Queue<int> _rx = new();
|
||||
public List<byte> Written { get; } = new();
|
||||
public bool Opened { get; private set; }
|
||||
|
||||
public bool AnswerAck { get; set; } = true; // отвечает ли ACK на ENQ
|
||||
public byte[]? Frame { get; set; } // кадр, выдаваемый на DC1
|
||||
public bool Opening { get; set; } // бросить при Open (порт занят и т.п.)
|
||||
|
||||
public void PreloadRx(params byte[] bytes) { foreach (var b in bytes) _rx.Enqueue(b); }
|
||||
|
||||
public void Open() { if (Opening) throw new System.UnauthorizedAccessException("busy"); Opened = true; }
|
||||
public void DiscardBuffers() { }
|
||||
public void Write(byte[] data, int offset, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var b = data[offset + i];
|
||||
Written.Add(b);
|
||||
if (b == MassaK.ENQ && AnswerAck) _rx.Enqueue(MassaK.ACK);
|
||||
if (b == MassaK.DC1 && Frame is not null) foreach (var fb in Frame) _rx.Enqueue(fb);
|
||||
}
|
||||
}
|
||||
public int ReadByte(int timeoutMs) => _rx.Count > 0 ? _rx.Dequeue() : -1;
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
public class MassaKScaleTests
|
||||
{
|
||||
// Кадр из реального замера: 01 02 'S 0.000kg' 'q' 03 04
|
||||
private static readonly byte[] Frame0 =
|
||||
{ 0x01, 0x02, 0x53, 0x20, 0x20, 0x30, 0x2E, 0x30, 0x30, 0x30, 0x6B, 0x67, 0x71, 0x03, 0x04 };
|
||||
|
||||
private static byte[] BuildFrame(string payloadText, byte control = 0x71)
|
||||
{
|
||||
var body = new List<byte> { MassaK.SOH, MassaK.STX };
|
||||
body.AddRange(Encoding.ASCII.GetBytes(payloadText));
|
||||
body.Add(control);
|
||||
body.Add(MassaK.ETX);
|
||||
body.Add(MassaK.EOT);
|
||||
return body.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_real_zero_frame_from_shop_laptop()
|
||||
{
|
||||
var r = MassaK.ParseFrame(Frame0, out var err);
|
||||
err.Should().BeNull();
|
||||
r.Should().NotBeNull();
|
||||
r!.Value.Kg.Should().Be(0.000m);
|
||||
r.Value.Flag.Should().Be('S');
|
||||
r.Value.Unit.Should().Be("kg");
|
||||
r.Value.Control.Should().Be(0x71);
|
||||
r.Value.RawHex.Should().StartWith("01 02 53");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("S 1.235kg", 1.235)]
|
||||
[InlineData("S 12.500kg", 12.500)]
|
||||
[InlineData("S 0.000kg", 0.000)]
|
||||
[InlineData("S -1.235kg", -1.235)]
|
||||
public void Parses_various_weights(string payload, double expected)
|
||||
{
|
||||
var r = MassaK.ParseFrame(BuildFrame(payload), out var err);
|
||||
err.Should().BeNull();
|
||||
r!.Value.Kg.Should().Be((decimal)expected);
|
||||
r.Value.Unit.Should().Be("kg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grams_payload_is_converted_to_kg()
|
||||
{
|
||||
var r = MassaK.ParseFrame(BuildFrame("S 1235g"), out _);
|
||||
r!.Value.Kg.Should().Be(1.235m);
|
||||
r.Value.Unit.Should().Be("g");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skips_garbage_before_soh()
|
||||
{
|
||||
var withGarbage = new byte[] { 0xFF, 0x00, 0x7E }.Concat(Frame0).ToArray();
|
||||
var r = MassaK.ParseFrame(withGarbage, out var err);
|
||||
err.Should().BeNull();
|
||||
r!.Value.Kg.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_frame_without_etx_is_error()
|
||||
{
|
||||
var bad = new byte[] { 0x01, 0x02, 0x53, 0x20, 0x30, 0x2E, 0x30 }; // нет ETX/EOT
|
||||
var r = MassaK.ParseFrame(bad, out var err);
|
||||
r.Should().BeNull();
|
||||
err.Should().Contain("ETX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pure_garbage_without_soh_is_error()
|
||||
{
|
||||
var r = MassaK.ParseFrame(new byte[] { 0xAA, 0xBB, 0xCC }, out var err);
|
||||
r.Should().BeNull();
|
||||
err.Should().Contain("SOH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handshake_succeeds_when_scale_answers_ack()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = true };
|
||||
MassaK.Handshake(t).Should().BeTrue();
|
||||
t.Written.Should().Contain(MassaK.ENQ);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handshake_fails_without_ack()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = false };
|
||||
MassaK.Handshake(t, ackTimeoutMs: 50, attempts: 2).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Full_read_cycle_handshake_then_frame()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = true, Frame = BuildFrame("S 1.235kg") };
|
||||
var (reading, hex, err) = MassaK.ReadWeight(t);
|
||||
err.Should().BeNull();
|
||||
reading!.Value.Kg.Should().Be(1.235m);
|
||||
hex.Should().Contain("01 02");
|
||||
t.Written.Should().Contain(MassaK.DC1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_cycle_reports_missing_ack()
|
||||
{
|
||||
var t = new FakeScaleTransport { AnswerAck = false };
|
||||
var (reading, _, err) = MassaK.ReadWeight(t, ackTimeoutMs: 50);
|
||||
reading.Should().BeNull();
|
||||
err.Should().Contain("ACK");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadStable_detects_stable_and_unstable()
|
||||
{
|
||||
// стабильно: один и тот же вес дважды
|
||||
var stableFrame = BuildFrame("S 2.000kg");
|
||||
var (kg, stable) = SerialScaleReader.ReadStable("COMX", 57600, ScaleProto.MassaKFrame,
|
||||
transportFactory: (_, _) => new FakeScaleTransport { AnswerAck = true, Frame = stableFrame }, gapMs: 1);
|
||||
kg.Should().Be(2.000m);
|
||||
stable.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue