From 25c37415bfbb241dc002d54406388e3155d6ce52 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 11 Oct 2026 08:29:06 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos):=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=D0=9C=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B0-=D0=9A=20(57600,=20ENQ/ACK,=20DC1,=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B4=D1=80)=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=83?= =?UTF-8?q?=D0=B3=D0=B0=D0=B4=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По фактическому замеру с ноутбука магазина (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 --- .../Hardware/ScaleDiscovery.cs | 150 +++++++++++++++ .../Hardware/SerialScale.cs | 175 ++++++++++++++++++ .../food-market.pos.core.csproj | 1 + src/food-market.pos/Hardware/Scale.cs | 118 ++---------- src/food-market.pos/Services/AppServices.cs | 20 +- src/food-market.pos/Tooling/ProbeRunner.cs | 30 ++- .../food-market.UnitTests/MassaKScaleTests.cs | 161 ++++++++++++++++ 7 files changed, 529 insertions(+), 126 deletions(-) create mode 100644 src/food-market.pos.core/Hardware/ScaleDiscovery.cs create mode 100644 src/food-market.pos.core/Hardware/SerialScale.cs create mode 100644 tests/food-market.UnitTests/MassaKScaleTests.cs diff --git a/src/food-market.pos.core/Hardware/ScaleDiscovery.cs b/src/food-market.pos.core/Hardware/ScaleDiscovery.cs new file mode 100644 index 0000000..a518b41 --- /dev/null +++ b/src/food-market.pos.core/Hardware/ScaleDiscovery.cs @@ -0,0 +1,150 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.Ports; +using System.Text; + +namespace foodmarket.Pos.Core.Hardware; + +/// Чтение веса на заданном порту/скорости/протоколе + стабильность (два +/// одинаковых чтения подряд). Транспорт абстрагирован — тестируется фейком. +public static class SerialScaleReader +{ + /// Подробное чтение (для диагностики): кадр, hex, флаг, control-байт. + 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(); + } + + /// Считать вес в кг (бросает при отсутствии связи). Фабрика транспорта + /// подменяется в тестах; по умолчанию — реальный COM. + public static decimal ReadWeightKg(string port, int baud, ScaleProto proto, + Func? 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; + } + + /// Стабильность: два чтения с интервалом ~150–200 мс; стабильно, если + /// совпали (флаг весов НЕ интерпретируем — его смысл не подтверждён). + public static (decimal kg, bool stable) ReadStable(string port, int baud, ScaleProto proto, + Func? 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); + } +} + +/// Автопоиск весов по COM-портам. 57600 и Масса-К-кадр — ПЕРВЫМИ (реальный +/// случай владельца). Общий бюджет ограничен; занятый порт отмечается отдельно, +/// чтобы сказать кассиру «порт занят», а не «весы не найдены». +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 BusyPorts, IReadOnlyList 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(); } + } + private static int NaturalOrder(string p) + => int.TryParse(new string(p.Where(char.IsDigit).ToArray()), out var n) ? n : 9999; + + /// Перебрать порты × протоколы × скорости. Для Масса-К-кадра достаточно + /// быстрого ENQ→ACK: нет ACK → сразу следующая комбинация (быстро). Бюджет общий. + public static Result Discover(Action? log = null, + Func? 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(); + var scanned = new List(); + 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); + } +} diff --git a/src/food-market.pos.core/Hardware/SerialScale.cs b/src/food-market.pos.core/Hardware/SerialScale.cs new file mode 100644 index 0000000..9dcf97f --- /dev/null +++ b/src/food-market.pos.core/Hardware/SerialScale.cs @@ -0,0 +1,175 @@ +using System.Globalization; +using System.IO.Ports; +using System.Text; + +namespace foodmarket.Pos.Core.Hardware; + +/// Протоколы весов, которые умеет касса. +/// • — реальный протокол весов владельца (Масса-К, USB-COM +/// CH340, 57600 8N1): рукопожатие ENQ→ACK, запрос веса DC1 → кадр +/// SOH STX «флаг вес единица» CTRL ETX EOT. Разобран по фактическим байтам с +/// ноутбука магазина (замер 11.10), не угадан. +/// • / — прежние «угаданные» ASCII-режимы, +/// оставлены как запасные варианты автопоиска. +public enum ScaleProto { MassaKFrame, MassaKAscii, Cas } + +/// Абстракция последовательного транспорта — чтобы протокол и рукопожатие +/// покрывались юнит-тестами фейковым портом, без реального железа. +public interface ISerialTransport : IDisposable +{ + void Open(); + void DiscardBuffers(); + void Write(byte[] data, int offset, int count); + /// Прочитать один байт; -1 при таймауте/конце потока. + int ReadByte(int timeoutMs); +} + +/// Реальный COM-порт через System.IO.Ports (Windows и Linux). +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(); } +} + +/// Протокол Масса-К (кадровый). Control-байт НЕ валидируем, пока он не +/// подтверждён документацией/замерами с грузом — только логируем сырым hex, чтобы +/// владелец/sysadmin прислали реальные кадры. +public static class MassaK +{ + public const byte ENQ = 0x05, ACK = 0x06, DC1 = 0x11, SOH = 0x01, STX = 0x02, ETX = 0x03, EOT = 0x04; + + /// Разобранный кадр веса. + public readonly record struct Reading(decimal Kg, char Flag, string Unit, byte? Control, string RawHex); + + public static string ToHex(ReadOnlySpan 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(); + } + + /// Разобрать кадр веса. Терпимо к мусору до SOH. Возвращает null и + /// заполняет , если кадр неполон/битый. + /// Структура: [мусор] SOH STX <payload> ETX EOT, где последний байт payload — + /// контрольный (например 0x71 'q'), остальное — «флаг + вес + единица». + public static Reading? ParseFrame(ReadOnlySpan 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); + } + + /// Вытащить вес из ASCII-строки payload. kg по умолчанию; «г»/«g» (не «kg») + /// → граммы (делим на 1000). Поддержаны знак, точка и запятая. + 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; + } + + /// Рукопожатие ENQ→ACK с повтором. true если весы ответили ACK. + 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; + } + + /// Прочитать кадр: байты до EOT (0x04) включительно, с общим таймаутом. + public static byte[] ReadFrame(ISerialTransport t, int totalTimeoutMs = 700, int maxBytes = 64) + { + var buf = new List(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(); + } + + /// Полный цикл: handshake → DC1 → кадр → разбор. Возвращает (кадр, hex, + /// ошибка). Транспорт должен быть уже открыт. + 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); + } +} diff --git a/src/food-market.pos.core/food-market.pos.core.csproj b/src/food-market.pos.core/food-market.pos.core.csproj index d7b27eb..49c4776 100644 --- a/src/food-market.pos.core/food-market.pos.core.csproj +++ b/src/food-market.pos.core/food-market.pos.core.csproj @@ -11,6 +11,7 @@ + diff --git a/src/food-market.pos/Hardware/Scale.cs b/src/food-market.pos/Hardware/Scale.cs index 861e5cd..a588ba1 100644 --- a/src/food-market.pos/Hardware/Scale.cs +++ b/src/food-market.pos/Hardware/Scale.cs @@ -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 ReadWeightKgAsync(CancellationToken ct = default) => Task.FromResult(LastValue); } -/// Разбор ответа весов по COM. Поддержаны простые ASCII-протоколы -/// CAS (строка с граммами/кг + завершитель) и Масса-К (ASCII «вес кг»). Парсер -/// выделен и тестируется отдельно; реальный COM-поток — в . -public static class ScaleProtocol -{ - /// CAS: ASCII-строка вида "ST,GS, 1.234kg" или "+001.234 kg" — тянем - /// первое число, трактуем как кг (если явно граммы — делим). - 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); - } - - /// Масса-К (ASCII-режим): строка с весом в кг, например "1.234". - 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; - } -} - -/// Весы по COM/serial. Протокол: Cas | MassaK. Открывает порт, шлёт -/// запрос веса (опц.), читает строку, парсит. Доступно на Windows и Linux -/// (System.IO.Ports). +/// Весы по COM/serial. Протокол и разбор кадра — в +/// (pos.core, покрыто юнит-тестами). +/// Этот класс — только адаптер к и мониторингу кассы. 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 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; - } -} - -/// Автопоиск весов по всем COM-портам (вкл. USB-COM — CH340/Prolific -/// определяются системой как обычные COMx) с протоколами Масса-К и CAS. Нужен, потому -/// что в кассе МойСклад у владельца «Порт не выбран»/«Таймаут» сотни раз в день — -/// мы сами находим порт и запоминаем его, без ручной возни. -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(); } - } - - /// Перебрать порты × протоколы × скорости, вернуть первый ответивший - /// правдоподобным весом (>= 0). Короткие таймауты, чтобы не висеть. - public static async Task DiscoverAsync(Action? 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 ReadWeightKgAsync(CancellationToken ct = default) + // System.IO.Ports синхронный — уводим в пул, чтобы не блокировать UI-поток. + => Task.Run(() => SerialScaleReader.ReadWeightKg(_port, _baud, _proto), ct); } diff --git a/src/food-market.pos/Services/AppServices.cs b/src/food-market.pos/Services/AppServices.cs index 272d628..f08cac2 100644 --- a/src/food-market.pos/Services/AppServices.cs +++ b/src/food-market.pos/Services/AppServices.cs @@ -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 { /* логирование весов не должно мешать кассе */ } } - /// Поднять сохранённый порт весов (если был выбран ранее). + /// Поднять сохранённый порт весов (если был выбран ранее). По умолчанию + /// протокол Масса-К-кадр, 57600 (реальные весы владельца). 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(Store.GetKv("scale_proto"), out var p) ? p : ScaleProto.MassaKFrame; SetScale(port, baud, proto); } catch { /* нет сохранённого порта — работаем с ручным вводом */ } } /// Назначить весы на конкретный порт и запомнить выбор. - 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()); } - /// Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает, - /// нашлись ли весы. + /// Автопоиск весов по COM-портам (57600 и Масса-К-кадр — первыми); при + /// успехе — запоминаем. Если сохранённые весы не отвечают — автопоиск молча. public async Task 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; } diff --git a/src/food-market.pos/Tooling/ProbeRunner.cs b/src/food-market.pos/Tooling/ProbeRunner.cs index cfbd934..144dfc3 100644 --- a/src/food-market.pos/Tooling/ProbeRunner.cs +++ b/src/food-market.pos/Tooling/ProbeRunner.cs @@ -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) diff --git a/tests/food-market.UnitTests/MassaKScaleTests.cs b/tests/food-market.UnitTests/MassaKScaleTests.cs new file mode 100644 index 0000000..5371bb1 --- /dev/null +++ b/tests/food-market.UnitTests/MassaKScaleTests.cs @@ -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; + +/// Фейковый COM-порт: эмулирует весы Масса-К по фактам с ноутбука магазина +/// (ENQ→ACK, DC1→кадр). Позволяет проверить разбор кадра и рукопожатие без железа. +public sealed class FakeScaleTransport : ISerialTransport +{ + private readonly Queue _rx = new(); + public List 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 { 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(); + } +}