feat(pos): CLI-диагностика оборудования кассы --probe-hw и тест-печать --test-print
- ProbeRunner: COM-порты (имя/VID/PID/описание через WMI), open→close (занят/свободен без удержания), опрос весов Масса-К+CAS 9600 (таймаут 1.5с), принтеры Windows (имя/порт/драйвер, БЕЗ печати), HID-клавиатуры (возможные сканеры). --port COMx — один порт; занятый не трогаем. - --test-print "<принтер>": отдельный флаг, ESC/POS тест-чек «ТЕСТ ALL MARKET» через spooler (winspool RAW). Без флага ничего не печатается. - WinExe → AttachConsole(parent) для видимого вывода; --out пишет чистый UTF-8 JSON. - System.Management (WMI, только Windows, под guard'ами). Проверено на Win11 VM: выход <10с, COM1/принтер/HID определяются, test-print на несуществующий принтер — мягкая ошибка (код 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8be44055a2
commit
574de0dafb
|
|
@ -71,6 +71,7 @@
|
||||||
<PackageVersion Include="Avalonia.Headless" Version="11.1.0" />
|
<PackageVersion Include="Avalonia.Headless" Version="11.1.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||||
<PackageVersion Include="System.IO.Ports" Version="8.0.0" />
|
<PackageVersion Include="System.IO.Ports" Version="8.0.0" />
|
||||||
|
<PackageVersion Include="System.Management" Version="8.0.0" />
|
||||||
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
|
|
||||||
<!-- Testing -->
|
<!-- Testing -->
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# Перенос магазина МойСклад → ALL MARKET (с историей) — прогресс
|
# Перенос магазина МойСклад → ALL MARKET (с историей) — прогресс
|
||||||
|
|
||||||
|
PROBE BUILD: ~/pos-release/allmarket-pos-win-x64-20261009-1845-probe.zip — касса `foodmarket.Pos.exe --probe-hw [--out <файл>] [--port COMx]` (COM/весы Масса-К·CAS/принтеры/HID, без печати) + `--test-print "<принтер>"` (ESC/POS тест-чек, отдельно); проверено на Win11 VM, выход < 10 с, ничего не трогает.
|
||||||
|
|
||||||
Решение владельца 09.10: перенести ВСЁ из МойСклада с историей, перейти на ALL MARKET.
|
Решение владельца 09.10: перенести ВСЁ из МойСклада с историей, перейти на ALL MARKET.
|
||||||
Фискализации нет и в МойСкладе (не блокер), маркировка не используется.
|
Фискализации нет и в МойСкладе (не блокер), маркировка не используется.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using foodmarket.Pos.Tooling;
|
using foodmarket.Pos.Tooling;
|
||||||
|
|
||||||
|
|
@ -5,10 +6,29 @@ namespace foodmarket.Pos;
|
||||||
|
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
|
[DllImport("kernel32.dll")] private static extern bool AttachConsole(int pid);
|
||||||
|
private const int ATTACH_PARENT_PROCESS = -1;
|
||||||
|
|
||||||
|
/// <summary>POS собран как WinExe (GUI-subsystem) — у него нет консоли, поэтому для
|
||||||
|
/// CLI-режимов на Windows подключаемся к консоли родителя (cmd/PowerShell), иначе вывод
|
||||||
|
/// Console.* уходит в никуда. На Linux WinExe игнорируется — консоль есть по умолчанию.</summary>
|
||||||
|
private static void EnsureConsole()
|
||||||
|
{
|
||||||
|
// Не переопределяем Console.OutputEncoding: родительская консоль (обычно cp866 на
|
||||||
|
// RU-Windows) сама отрисует кириллицу; принудительный UTF-8 даёт кракозябры в cp866.
|
||||||
|
// Чистый UTF-8 артефакт — файл --out (JSON пишется UTF-8 независимо от консоли).
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try { AttachConsole(ATTACH_PARENT_PROCESS); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static int Main(string[] args)
|
public static int Main(string[] args)
|
||||||
{
|
{
|
||||||
PosFormat.Apply(); // единый формат чисел (KZ/RU: «3 422 ₸», «1,235 кг»)
|
PosFormat.Apply(); // единый формат чисел (KZ/RU: «3 422 ₸», «1,235 кг»)
|
||||||
|
// CLI-режимы оборудования — подключить консоль и UTF-8 до любого вывода.
|
||||||
|
if (args.Contains("--probe-hw") || args.Contains("--test-print")) EnsureConsole();
|
||||||
// CLI-режимы (без интерактивного окна):
|
// CLI-режимы (без интерактивного окна):
|
||||||
// --smoke : прогон сценария кассы через логику (без UI), печать результатов.
|
// --smoke : прогон сценария кассы через логику (без UI), печать результатов.
|
||||||
// --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768).
|
// --screenshots : headless-рендер экранов в docs/pos-screenshots (1920×1080 + 1366×768).
|
||||||
|
|
@ -19,6 +39,15 @@ public static int Main(string[] args)
|
||||||
if (args.Contains("--density")) return ScreenshotRunner.RunDensity(args);
|
if (args.Contains("--density")) return ScreenshotRunner.RunDensity(args);
|
||||||
if (args.Contains("--uitest")) return UiTestRunner.Run(args);
|
if (args.Contains("--uitest")) return UiTestRunner.Run(args);
|
||||||
if (args.Contains("--bench")) return BenchRunner.Run(args);
|
if (args.Contains("--bench")) return BenchRunner.Run(args);
|
||||||
|
// --probe-hw : диагностика оборудования (COM/весы/принтеры/HID), без окна, без печати.
|
||||||
|
// --test-print N : отдельный флаг — короткий тестовый чек ESC/POS на принтер Windows.
|
||||||
|
if (args.Contains("--probe-hw")) return ProbeRunner.Run(args);
|
||||||
|
if (args.Contains("--test-print"))
|
||||||
|
{
|
||||||
|
var i = Array.IndexOf(args, "--test-print");
|
||||||
|
var name = (i >= 0 && i + 1 < args.Length) ? args[i + 1] : "";
|
||||||
|
return ProbeRunner.TestPrint(name);
|
||||||
|
}
|
||||||
|
|
||||||
// --demo : показать реальное окно с примерами данных (для скриншота на Windows).
|
// --demo : показать реальное окно с примерами данных (для скриншота на Windows).
|
||||||
if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true;
|
if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true;
|
||||||
|
|
|
||||||
365
src/food-market.pos/Tooling/ProbeRunner.cs
Normal file
365
src/food-market.pos/Tooling/ProbeRunner.cs
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Ports;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using foodmarket.Pos.Hardware;
|
||||||
|
|
||||||
|
namespace foodmarket.Pos.Tooling;
|
||||||
|
|
||||||
|
/// <summary>Диагностика оборудования кассы БЕЗ интерактивного окна (для проверки
|
||||||
|
/// сисадмином на ноутбуке кассы, где параллельно открыта «Касса МойСклад»).
|
||||||
|
///
|
||||||
|
/// --probe-hw [--out файл] — отчёт: COM-порты (имя, VID/PID, описание, свободен/занят),
|
||||||
|
/// попытка опроса весов Масса-К и CAS на свободных портах (таймаут 1–2 с), принтеры
|
||||||
|
/// Windows (имя/порт/драйвер, БЕЗ печати), HID-клавиатуры (возможные сканеры).
|
||||||
|
/// --probe-hw --port COM22 — опрос конкретного порта; занятый порт НЕ трогаем.
|
||||||
|
/// --test-print "Имя принтера" — ОТДЕЛЬНО: короткий тестовый чек ESC/POS через очередь Windows.
|
||||||
|
///
|
||||||
|
/// Ничего не удерживает (порт открыл→закрыл), ничего не печатает без --test-print,
|
||||||
|
/// гарантированный выход за < 10 с (общий бюджет + короткие таймауты).</summary>
|
||||||
|
public static class ProbeRunner
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(9);
|
||||||
|
|
||||||
|
public static int Run(string[] args)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var only = GetArg(args, "--port");
|
||||||
|
var outFile = GetArg(args, "--out");
|
||||||
|
|
||||||
|
var report = new ProbeReport
|
||||||
|
{
|
||||||
|
GeneratedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
Os = RuntimeInformation.OSDescription,
|
||||||
|
IsWindows = OperatingSystem.IsWindows(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. COM-порты (имя/VID/PID/описание из WMI + базовый список System.IO.Ports)
|
||||||
|
var wmiPorts = OperatingSystem.IsWindows() ? QueryComPortsWmi() : new();
|
||||||
|
var portNames = SerialPort.GetPortNames().Distinct().ToList();
|
||||||
|
foreach (var w in wmiPorts) if (!portNames.Contains(w.Port, StringComparer.OrdinalIgnoreCase)) portNames.Add(w.Port);
|
||||||
|
portNames = portNames.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(p => NaturalComOrder(p)).ToList();
|
||||||
|
if (only is not null)
|
||||||
|
portNames = portNames.Where(p => string.Equals(p, only, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
foreach (var port in portNames)
|
||||||
|
{
|
||||||
|
var wi = wmiPorts.FirstOrDefault(x => string.Equals(x.Port, port, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var p = new ComPortInfo
|
||||||
|
{
|
||||||
|
Port = port,
|
||||||
|
Description = wi?.Description,
|
||||||
|
Vid = wi?.Vid,
|
||||||
|
Pid = wi?.Pid,
|
||||||
|
Manufacturer = wi?.Manufacturer,
|
||||||
|
};
|
||||||
|
// открыть→закрыть, без удержания
|
||||||
|
var (busy, openErr) = TryOpenClose(port);
|
||||||
|
p.Busy = busy;
|
||||||
|
p.OpenError = openErr;
|
||||||
|
// опрос весов только на свободном порту и пока есть бюджет времени
|
||||||
|
if (!busy && sw.Elapsed < Budget - TimeSpan.FromSeconds(3))
|
||||||
|
p.Scale = ProbeScale(port, sw);
|
||||||
|
else if (busy)
|
||||||
|
p.Scale = "порт занят другим приложением — не опрашивался";
|
||||||
|
else
|
||||||
|
p.Scale = "пропущен (лимит времени)";
|
||||||
|
report.Ports.Add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Принтеры Windows (имя/порт/драйвер) — БЕЗ печати
|
||||||
|
if (OperatingSystem.IsWindows() && only is null)
|
||||||
|
report.Printers = QueryPrintersWmi();
|
||||||
|
|
||||||
|
// 3. HID-клавиатуры (сканер ШК обычно эмулирует клавиатуру — детект без скана невозможен)
|
||||||
|
if (OperatingSystem.IsWindows() && only is null)
|
||||||
|
report.Keyboards = QueryKeyboardsWmi();
|
||||||
|
|
||||||
|
report.ElapsedMs = (int)sw.ElapsedMilliseconds;
|
||||||
|
report.Note = OperatingSystem.IsWindows()
|
||||||
|
? "Детект сканера без фактического скана невозможен — перечислены HID-клавиатуры."
|
||||||
|
: "Не Windows: WMI недоступен, перечислены только COM-порты (System.IO.Ports).";
|
||||||
|
|
||||||
|
// Вывод: текст в stdout; JSON в --out (если задан)
|
||||||
|
Console.WriteLine(RenderText(report));
|
||||||
|
if (outFile is not null)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // кириллица как есть
|
||||||
|
});
|
||||||
|
File.WriteAllText(outFile, json);
|
||||||
|
Console.WriteLine($"\nJSON сохранён: {outFile}");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── COM ─────────────────────────
|
||||||
|
private static (bool busy, string? err) TryOpenClose(string port)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var sp = new SerialPort(port) { ReadTimeout = 300, WriteTimeout = 300 };
|
||||||
|
sp.Open();
|
||||||
|
sp.Close();
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException) { return (true, "занят другим приложением (Access denied)"); }
|
||||||
|
catch (Exception ex) { return (false, ex.GetType().Name + ": " + ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ProbeScale(string port, Stopwatch sw)
|
||||||
|
{
|
||||||
|
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
|
||||||
|
{
|
||||||
|
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.###} кг";
|
||||||
|
}
|
||||||
|
catch (AggregateException ae) when (ae.InnerException is UnauthorizedAccessException)
|
||||||
|
{ return "порт занят другим приложением"; }
|
||||||
|
catch { /* нет ответа на этом протоколе — пробуем следующий */ }
|
||||||
|
}
|
||||||
|
return "весы не ответили (Масса-К/CAS, 9600, 1.5 с)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NaturalComOrder(string p)
|
||||||
|
=> int.TryParse(Regex.Match(p, @"\d+").Value, out var n) ? n : 9999;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static List<WmiPort> QueryComPortsWmi()
|
||||||
|
{
|
||||||
|
var list = new List<WmiPort>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var s = new System.Management.ManagementObjectSearcher(
|
||||||
|
"SELECT Name, PNPDeviceID, Description, Manufacturer FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'");
|
||||||
|
foreach (var o in s.Get())
|
||||||
|
{
|
||||||
|
var name = o["Name"]?.ToString() ?? "";
|
||||||
|
var m = Regex.Match(name, @"\((COM\d+)\)");
|
||||||
|
if (!m.Success) continue;
|
||||||
|
var pnp = o["PNPDeviceID"]?.ToString() ?? "";
|
||||||
|
list.Add(new WmiPort
|
||||||
|
{
|
||||||
|
Port = m.Groups[1].Value,
|
||||||
|
Description = name,
|
||||||
|
Manufacturer = o["Manufacturer"]?.ToString(),
|
||||||
|
Vid = Regex.Match(pnp, @"VID_([0-9A-Fa-f]{4})").Groups[1].Value is { Length: 4 } vid ? vid : null,
|
||||||
|
Pid = Regex.Match(pnp, @"PID_([0-9A-Fa-f]{4})").Groups[1].Value is { Length: 4 } pid ? pid : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* WMI недоступен — вернём пусто, порты всё равно возьмём из GetPortNames */ }
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static List<PrinterInfo> QueryPrintersWmi()
|
||||||
|
{
|
||||||
|
var list = new List<PrinterInfo>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var s = new System.Management.ManagementObjectSearcher(
|
||||||
|
"SELECT Name, PortName, DriverName, Default, WorkOffline FROM Win32_Printer");
|
||||||
|
foreach (var o in s.Get())
|
||||||
|
list.Add(new PrinterInfo
|
||||||
|
{
|
||||||
|
Name = o["Name"]?.ToString() ?? "",
|
||||||
|
Port = o["PortName"]?.ToString(),
|
||||||
|
Driver = o["DriverName"]?.ToString(),
|
||||||
|
IsDefault = o["Default"] as bool? ?? false,
|
||||||
|
Offline = o["WorkOffline"] as bool? ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private static List<string> QueryKeyboardsWmi()
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var s = new System.Management.ManagementObjectSearcher(
|
||||||
|
"SELECT Name, Description, DeviceID FROM Win32_Keyboard");
|
||||||
|
foreach (var o in s.Get())
|
||||||
|
{
|
||||||
|
var desc = o["Description"]?.ToString() ?? o["Name"]?.ToString() ?? "клавиатура";
|
||||||
|
var id = o["DeviceID"]?.ToString();
|
||||||
|
list.Add(string.IsNullOrEmpty(id) ? desc : $"{desc} [{id}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── тестовая печать ─────────────────────────
|
||||||
|
public static int TestPrint(string printerName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(printerName))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Укажите имя принтера: --test-print \"Имя принтера\"");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Тестовая печать через очередь Windows доступна только на Windows.");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
var doc = new ReceiptDoc { Title = "ТЕСТ ALL MARKET", WidthChars = 48 };
|
||||||
|
doc.Add("Проверка принтера кассы", center: true);
|
||||||
|
doc.Add(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), center: true);
|
||||||
|
doc.Separator();
|
||||||
|
doc.Add("Если этот чек напечатан —", center: true);
|
||||||
|
doc.Add("принтер и очередь Windows", center: true);
|
||||||
|
doc.Add("работают корректно.", center: true);
|
||||||
|
doc.Separator();
|
||||||
|
doc.Add("ALL MARKET · касса", center: true);
|
||||||
|
var bytes = EscPos.Build(doc);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!RawPrinter.Send(printerName, bytes, "ТЕСТ ALL MARKET", out var err))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Не удалось напечатать: {err}");
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Тестовый чек отправлен на принтер «{printerName}» (RAW ESC/POS, 80 мм).");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Console.Error.WriteLine("Ошибка печати: " + ex.Message); return 4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── вывод ─────────────────────────
|
||||||
|
private static string RenderText(ProbeReport r)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("=== ПРОВЕРКА ОБОРУДОВАНИЯ КАССЫ (ALL MARKET) ===");
|
||||||
|
sb.AppendLine($"Время: {r.GeneratedAt} ОС: {r.Os} (выполнено за {r.ElapsedMs} мс)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"COM-порты ({r.Ports.Count}):");
|
||||||
|
if (r.Ports.Count == 0) sb.AppendLine(" (не найдено)");
|
||||||
|
foreach (var p in r.Ports)
|
||||||
|
{
|
||||||
|
var vidpid = (p.Vid is not null || p.Pid is not null) ? $" VID_{p.Vid}/PID_{p.Pid}" : "";
|
||||||
|
sb.AppendLine($" {p.Port}: {(p.Busy ? "ЗАНЯТ" : "свободен")}{vidpid}");
|
||||||
|
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($" {p.Description}");
|
||||||
|
if (p.Scale is not null) sb.AppendLine($" весы: {p.Scale}");
|
||||||
|
if (p.OpenError is not null) sb.AppendLine($" порт: {p.OpenError}");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Принтеры Windows ({r.Printers.Count}) — печать НЕ выполнялась:");
|
||||||
|
if (r.Printers.Count == 0) sb.AppendLine(" (не найдено / не Windows)");
|
||||||
|
foreach (var pr in r.Printers)
|
||||||
|
sb.AppendLine($" {pr.Name}{(pr.IsDefault ? " [по умолчанию]" : "")} — порт {pr.Port}, драйвер {pr.Driver}{(pr.Offline ? ", OFFLINE" : "")}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"HID-клавиатуры ({r.Keyboards.Count}) — сканер ШК обычно в этом списке:");
|
||||||
|
if (r.Keyboards.Count == 0) sb.AppendLine(" (не найдено / не Windows)");
|
||||||
|
foreach (var k in r.Keyboards) sb.AppendLine($" {k}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(r.Note);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetArg(string[] args, string key)
|
||||||
|
{
|
||||||
|
var i = Array.FindIndex(args, a => string.Equals(a, key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return (i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--")) ? args[i + 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── модели отчёта ──
|
||||||
|
private sealed class ProbeReport
|
||||||
|
{
|
||||||
|
public string GeneratedAt { get; set; } = "";
|
||||||
|
public string Os { get; set; } = "";
|
||||||
|
public bool IsWindows { get; set; }
|
||||||
|
public int ElapsedMs { get; set; }
|
||||||
|
public string Note { get; set; } = "";
|
||||||
|
public List<ComPortInfo> Ports { get; set; } = new();
|
||||||
|
public List<PrinterInfo> Printers { get; set; } = new();
|
||||||
|
public List<string> Keyboards { get; set; } = new();
|
||||||
|
}
|
||||||
|
private sealed class ComPortInfo
|
||||||
|
{
|
||||||
|
public string Port { get; set; } = "";
|
||||||
|
public bool Busy { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Vid { get; set; }
|
||||||
|
public string? Pid { get; set; }
|
||||||
|
public string? Manufacturer { get; set; }
|
||||||
|
public string? Scale { get; set; }
|
||||||
|
public string? OpenError { get; set; }
|
||||||
|
}
|
||||||
|
private sealed class PrinterInfo
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string? Port { get; set; }
|
||||||
|
public string? Driver { get; set; }
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
public bool Offline { get; set; }
|
||||||
|
}
|
||||||
|
private sealed class WmiPort
|
||||||
|
{
|
||||||
|
public string Port = "";
|
||||||
|
public string? Description;
|
||||||
|
public string? Vid;
|
||||||
|
public string? Pid;
|
||||||
|
public string? Manufacturer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>RAW-печать байтов ESC/POS на именованный принтер Windows через spooler
|
||||||
|
/// (winspool.drv). Используется только из --test-print под Windows.</summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal static class RawPrinter
|
||||||
|
{
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct DOCINFO { [MarshalAs(UnmanagedType.LPWStr)] public string pDocName; [MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile; [MarshalAs(UnmanagedType.LPWStr)] public string pDataType; }
|
||||||
|
|
||||||
|
[DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool OpenPrinter(string src, out IntPtr h, IntPtr pd);
|
||||||
|
[DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true)]
|
||||||
|
private static extern bool ClosePrinter(IntPtr h);
|
||||||
|
[DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool StartDocPrinter(IntPtr h, int level, ref DOCINFO di);
|
||||||
|
[DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true)]
|
||||||
|
private static extern bool EndDocPrinter(IntPtr h);
|
||||||
|
[DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true)]
|
||||||
|
private static extern bool StartPagePrinter(IntPtr h);
|
||||||
|
[DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true)]
|
||||||
|
private static extern bool EndPagePrinter(IntPtr h);
|
||||||
|
[DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true)]
|
||||||
|
private static extern bool WritePrinter(IntPtr h, IntPtr buf, int count, out int written);
|
||||||
|
|
||||||
|
public static bool Send(string printer, byte[] bytes, string docName, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
if (!OpenPrinter(printer, out var h, IntPtr.Zero))
|
||||||
|
{ error = $"OpenPrinter: Win32 {Marshal.GetLastWin32Error()} (принтер не найден?)"; return false; }
|
||||||
|
var di = new DOCINFO { pDocName = docName, pDataType = "RAW" };
|
||||||
|
var ptr = Marshal.AllocCoTaskMem(bytes.Length);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
||||||
|
if (!StartDocPrinter(h, 1, ref di)) { error = $"StartDocPrinter: Win32 {Marshal.GetLastWin32Error()}"; return false; }
|
||||||
|
if (!StartPagePrinter(h)) { error = $"StartPagePrinter: Win32 {Marshal.GetLastWin32Error()}"; EndDocPrinter(h); return false; }
|
||||||
|
var ok = WritePrinter(h, ptr, bytes.Length, out var written);
|
||||||
|
EndPagePrinter(h);
|
||||||
|
EndDocPrinter(h);
|
||||||
|
if (!ok || written != bytes.Length) { error = $"WritePrinter: записано {written}/{bytes.Length}"; return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally { Marshal.FreeCoTaskMem(ptr); ClosePrinter(h); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,9 @@
|
||||||
<PackageReference Include="Refit.HttpClientFactory" />
|
<PackageReference Include="Refit.HttpClientFactory" />
|
||||||
<PackageReference Include="Polly" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="System.IO.Ports" />
|
<PackageReference Include="System.IO.Ports" />
|
||||||
|
<!-- WMI для детекта оборудования (COM VID/PID, принтеры, HID-клавиатуры) — только Windows,
|
||||||
|
вызовы в ProbeRunner под OperatingSystem.IsWindows(). -->
|
||||||
|
<PackageReference Include="System.Management" />
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue