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