Merge branch 'master' (POS895/POS896 integration) into main
Resolution notes: - Inspectron.Epson.Tests was added on both sides. Took the master version wholesale (POS895, CompositeDiscoveryService, SprtDiscoveryService, BitImagePrinting, PrinterRegistration tests). This drops the Configuration/, Sanity/ and Telemetry/ tests that existed only on main, and the test project's reference to EpsonPrintService. - CLAUDE.md, PrintServerBootstrapper.cs and Inspectron.Epson.slnx auto-merged; verified the composite discovery binding and the Insin telemetry bindings both survive in the bootstrapper. Solution builds with 0 errors; unit tests 33/33 pass on the merged tree. Claude-Session: https://claude.ai/code/session_01KyCpB2jH3urYByRG5t98gz
This commit is contained in:
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -61,3 +61,7 @@
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
|
||||
# packet captures
|
||||
*.pcapng binary
|
||||
*.etl binary
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -13,6 +13,8 @@ This is a C# .NET 8.0 solution for Epson thermal receipt printer integration. Th
|
||||
- **Inspectron.Epson** - Core SDK library providing printer communication, status monitoring, ESC/POS commands, print queue management, and receipt conversion
|
||||
- **EpsonPrintService** - Production console application with SignalR-based remote job source, printer discovery, and systemd service support
|
||||
- **EpsonTest** - Test/example project for SDK functionality and receipt conversion
|
||||
- **Inspectron.Epson.Tests** - xunit unit test project (POS895, CompositeDiscoveryService, SprtDiscoveryService, receipt converters, etc.)
|
||||
- **Pos895Test** - Scratch console app for probing POS895/POS896 hardware directly; not production code
|
||||
|
||||
## Common Build Commands
|
||||
|
||||
@@ -30,6 +32,9 @@ dotnet run --project EpsonPrintService/EpsonPrintService.csproj
|
||||
# Run tests/examples
|
||||
dotnet run --project EpsonTest/EpsonTest.csproj
|
||||
|
||||
# Run the unit test suite
|
||||
dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
|
||||
|
||||
# Clean build artifacts
|
||||
dotnet clean
|
||||
```
|
||||
@@ -45,6 +50,8 @@ dotnet clean
|
||||
- `IDiscoveryService` / `DiscoveryService`: Network printer discovery (SLP, ENPC)
|
||||
- `PreconfiguredDiscoveryService`: Preconfigured printer list (no network scan)
|
||||
- `BonjourDiscovery`: mDNS-based printer discovery
|
||||
- `SprtDiscoveryService`: UDP broadcast discovery for SPRT-family printers (POS895 / POS896), which do not answer Epson ENPC
|
||||
- `CompositeDiscoveryService`: Runs several `IDiscoveryService` implementations concurrently and merges results by IP; absorbs individual child failures but rethrows if every child fails, so a genuinely broken discovery pass is distinguishable from an empty network
|
||||
- `HtmlPrinter`: HTML output implementation for testing without hardware
|
||||
- Status classes: PrinterStatus, OfflineStatus, ErrorStatus, PaperSensorStatus, OverallStatus
|
||||
- Exception hierarchy: EpsonPrinterException -> EpsonConnectionException, EpsonCommandException
|
||||
@@ -86,6 +93,7 @@ Receipt content is converted from JSON to `PrintCommand` lists via `IReceiptConv
|
||||
`IPrinter` implementations wrap `IEpsonPrinter` with model-specific behavior:
|
||||
- `TM-T30III` / `TM-T30IIITranslated` - Thermal receipt printer (384px image width)
|
||||
- `TM-U220II` / `TM-U220IITranslated` - Dot matrix receipt printer (narrower image width)
|
||||
- `POS895` - SPRT-family ESC/POS clone (POS895 / POS896), 576-dot line, Font A at 48 columns. See "Non-Epson printers" below for the firmware quirks it works around.
|
||||
|
||||
Created via `IWrapperPrinterFactory.CreatePrinterFromId(modelIdByte, epsonPrinter)`.
|
||||
|
||||
@@ -173,13 +181,24 @@ Printers are registered via `PreconfiguredDiscoveryService` with hardcoded IPs i
|
||||
|
||||
## Adding New Printer Models
|
||||
|
||||
1. Get printer ID byte via `EpsonPrinter.GetPrinterIdAsync()` (GS I 1 command)
|
||||
1. Get printer ID byte via `EpsonPrinter.GetPrinterIdAsync()` (GS I 1 command). Note: ID `0x20` is already taken by the POS895 - pick a different byte.
|
||||
2. Create new class implementing `IPrinter` in `Inspectron.Epson/PrintServer/Printers/`
|
||||
3. Implement required methods: InitAsync, PrintImageAsync, SetFontSizeAsync, PrintAsync, Cut
|
||||
4. Add case to `PrinterWrapperFactory.CreatePrinterFromId()` with the printer ID byte
|
||||
5. Add receipt converter support in `ReceiptConverterFactory` if needed
|
||||
6. Test with actual hardware - different models have different image widths and positioning
|
||||
|
||||
### Non-Epson printers (POS895 / POS896)
|
||||
|
||||
POS895 / POS896 support has shipped: `POS895` (`Inspectron.Epson/PrintServer/Printers/POS895.cs`) is the `IPrinter` adapter, `SprtDiscoveryService` is the discovery strategy, and `PrinterWrapperFactory` maps printer ID `0x20` to `POS895`. The full write-up, including the hardware checks that are still open, lives in `POS895_POS896_integration_notes.md` - read it before touching discovery, printer identification (`GetPrinterIdAsync`), or `PrinterWrapperFactory` for a non-Epson model.
|
||||
|
||||
Established hardware facts (confirmed on hardware, so treat as given rather than re-deriving them):
|
||||
- 576 dots wide; Font A is 48 columns.
|
||||
- `GS I 1` (`GetPrinterIdAsync`) answers with `0x20`.
|
||||
- The firmware ignores `ESC d`, so `POS895` feeds with literal LF bytes via `SendRawCommandAsync` instead of `IEpsonPrinter.FeedLinesAsync`. Using `ESC d` here would cut the receipt through its last printed line.
|
||||
- `GS ( L` raster mode prints nothing, so logos go out via `ESC *` bit-image mode (`PrintImageBitModeAsync`, `DoubleDensity24Dot`) instead of `LoadImageAsync`.
|
||||
- Discovery is a UDP broadcast of `"SPRT-Infor\0\0\r"` to port 3289, sent from source port 9100 (`SprtDiscoveryService`); this does not go through Epson's ENPC protocol.
|
||||
|
||||
## Adding New Receipt Types
|
||||
|
||||
1. Create a new folder under `Inspectron.Epson/PrintServer/Printers/Utils/`
|
||||
|
||||
@@ -59,17 +59,19 @@ public class PrintServerBootstrapper
|
||||
{
|
||||
["127.0.0.2"] = ("emulation/u220.html", 0x13, 258),
|
||||
["127.0.0.3"] = ("emulation/t30.html", 0x01, 384),
|
||||
["127.0.0.4"] = ("emulation/pos895.html", 0x20, 576),
|
||||
};
|
||||
|
||||
var discovery = new PreconfiguredDiscoveryService();
|
||||
discovery.AddPrinter("127.0.0.2", "TM-U220II");
|
||||
discovery.AddPrinter("127.0.0.3", "TM-T30III");
|
||||
discovery.AddPrinter("127.0.0.4", "POS895");
|
||||
_kernel.Bind<IDiscoveryService>().ToConstant(discovery).InSingletonScope();
|
||||
|
||||
_kernel.Bind<IPrinterFactory>().ToMethod(ctx =>
|
||||
new EmulationPrinterFactory(emulationConfigs, ctx.Kernel.Get<ILogger>()));
|
||||
|
||||
Console.WriteLine("Emulation mode enabled: 127.0.0.2 (TM-U220II), 127.0.0.3 (TM-T30III)");
|
||||
Console.WriteLine("Emulation mode enabled: 127.0.0.2 (TM-U220II), 127.0.0.3 (TM-T30III), 127.0.0.4 (POS895)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -80,7 +82,19 @@ public class PrintServerBootstrapper
|
||||
//discovery.AddPrinter("192.168.1.124", "TM-T30III");
|
||||
//_kernel.Bind<IDiscoveryService>().ToConstant(discovery).InSingletonScope();\
|
||||
|
||||
_kernel.Bind<IDiscoveryService>().To<DiscoveryService>().InSingletonScope();
|
||||
// Epson printers answer ENPC; POS895/POS896 answer SPRT. Both broadcast
|
||||
// on UDP 3289, but only the composite sees them all.
|
||||
_kernel.Bind<IDiscoveryService>().ToMethod(ctx =>
|
||||
{
|
||||
var logger = ctx.Kernel.Get<ILogger>();
|
||||
return new CompositeDiscoveryService(
|
||||
new IDiscoveryService[]
|
||||
{
|
||||
new DiscoveryService(logger),
|
||||
new SprtDiscoveryService(logger)
|
||||
},
|
||||
logger);
|
||||
}).InSingletonScope();
|
||||
_kernel.Bind<IPrinterFactory>().To<EpsonPrinterFactory>();
|
||||
}
|
||||
_kernel.Bind<IPrintJobSource, SignalRPrintJobSource>().To<SignalRPrintJobSource>().InSingletonScope();
|
||||
|
||||
@@ -18,8 +18,8 @@ if (args.Length > 0)
|
||||
return;
|
||||
}
|
||||
|
||||
int pt = args.Length > 1 && args[1].Equals("epson", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
|
||||
Console.WriteLine($"Running {GetReceiptTypeName(rt)} on {(pt == 0 ? "HTML" : "Epson")} printer...\n");
|
||||
int pt = ParsePrinterTargetArg(args.Length > 1 ? args[1] : "html");
|
||||
Console.WriteLine($"Running {GetReceiptTypeName(rt)} on {GetPrinterTargetName(pt)} printer...\n");
|
||||
await RunTest(rt, pt);
|
||||
Console.WriteLine("\nDone.");
|
||||
return;
|
||||
@@ -46,11 +46,12 @@ while (true)
|
||||
var printerTarget = ShowMenu("Select printer target:", new[]
|
||||
{
|
||||
"HTML Printer (local file)",
|
||||
"Epson Printer (real hardware)"
|
||||
"Epson Printer (real hardware)",
|
||||
"POS895 (real hardware, via IPrinter adapter)"
|
||||
});
|
||||
|
||||
Console.Clear();
|
||||
Console.WriteLine($"Running {GetReceiptTypeName(receiptType)} on {(printerTarget == 0 ? "HTML" : "Epson")} printer...\n");
|
||||
Console.WriteLine($"Running {GetReceiptTypeName(receiptType)} on {GetPrinterTargetName(printerTarget)} printer...\n");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -109,6 +110,21 @@ static int ShowMenu(string prompt, string[] options)
|
||||
}
|
||||
}
|
||||
|
||||
static string GetPrinterTargetName(int index) => index switch
|
||||
{
|
||||
0 => "HTML",
|
||||
1 => "Epson",
|
||||
2 => "POS895",
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
static int ParsePrinterTargetArg(string arg) => arg.ToLowerInvariant() switch
|
||||
{
|
||||
"1" or "epson" => 1,
|
||||
"2" or "pos895" or "pos" => 2,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
static string GetReceiptTypeName(int index) => index switch
|
||||
{
|
||||
0 => "KitchenReceipt",
|
||||
@@ -163,11 +179,19 @@ static async Task RunKitchenReceiptTest(int printerTarget)
|
||||
await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands);
|
||||
Console.WriteLine("Output written to: kitchen_test.html");
|
||||
}
|
||||
else
|
||||
else if (printerTarget == 1)
|
||||
{
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync(paperWidth: TestHelpers.KitchenPaperWidth);
|
||||
await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands, useDelay: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Routes through PrinterWrapperFactory and the model adapter, the way
|
||||
// EpsonPrintService does for a real job. The Epson branch above drives
|
||||
// EpsonPrinter directly and feeds with ESC d, which the POS895 ignores.
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunFinalReceiptTest(int printerTarget)
|
||||
@@ -189,11 +213,20 @@ static async Task RunFinalReceiptTest(int printerTarget)
|
||||
logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
|
||||
Console.WriteLine("Output written to: test.html");
|
||||
}
|
||||
else
|
||||
else if (printerTarget == 1)
|
||||
{
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Routes through PrinterWrapperFactory and the model adapter, the way
|
||||
// EpsonPrintService does for a real job. The Epson branch above drives
|
||||
// EpsonPrinter directly and feeds with ESC d, which the POS895 ignores.
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands,
|
||||
logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunInvoiceReceiptTest(int printerTarget)
|
||||
@@ -215,11 +248,20 @@ static async Task RunInvoiceReceiptTest(int printerTarget)
|
||||
logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
|
||||
Console.WriteLine("Output written to: invoice_test.html");
|
||||
}
|
||||
else
|
||||
else if (printerTarget == 1)
|
||||
{
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Routes through PrinterWrapperFactory and the model adapter, the way
|
||||
// EpsonPrintService does for a real job. The Epson branch above drives
|
||||
// EpsonPrinter directly and feeds with ESC d, which the POS895 ignores.
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands,
|
||||
logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunBarReceiptTest(int printerTarget)
|
||||
@@ -239,11 +281,19 @@ static async Task RunBarReceiptTest(int printerTarget)
|
||||
await TestHelpers.PrintKitchenCommandsToHtmlPrinterAsync(printer, printCommands);
|
||||
Console.WriteLine("Output written to: bar_test.html");
|
||||
}
|
||||
else
|
||||
else if (printerTarget == 1)
|
||||
{
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync(paperWidth: TestHelpers.KitchenPaperWidth);
|
||||
await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands, useDelay: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Routes through PrinterWrapperFactory and the model adapter, the way
|
||||
// EpsonPrintService does for a real job. The Epson branch above drives
|
||||
// EpsonPrinter directly and feeds with ESC d, which the POS895 ignores.
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunOrderItemsTest(int printerTarget)
|
||||
@@ -262,11 +312,19 @@ static async Task RunOrderItemsTest(int printerTarget)
|
||||
await TestHelpers.PrintCommandsToHtmlPrinterAsync(printer, printCommands);
|
||||
Console.WriteLine("Output written to: order_items.html");
|
||||
}
|
||||
else
|
||||
else if (printerTarget == 1)
|
||||
{
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Routes through PrinterWrapperFactory and the model adapter, the way
|
||||
// EpsonPrintService does for a real job. The Epson branch above drives
|
||||
// EpsonPrinter directly and feeds with ESC d, which the POS895 ignores.
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunMoneyReturnReceiptTest(int printerTarget)
|
||||
@@ -288,9 +346,18 @@ static async Task RunMoneyReturnReceiptTest(int printerTarget)
|
||||
logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
|
||||
Console.WriteLine("Output written to: money_return_test.html");
|
||||
}
|
||||
else
|
||||
else if (printerTarget == 1)
|
||||
{
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Routes through PrinterWrapperFactory and the model adapter, the way
|
||||
// EpsonPrintService does for a real job. The Epson branch above drives
|
||||
// EpsonPrinter directly and feeds with ESC d, which the POS895 ignores.
|
||||
var printer = await TestHelpers.CreateEpsonPrinterAsync();
|
||||
await TestHelpers.PrintCommandsViaAdapterAsync(printer, printCommands,
|
||||
logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using Inspectron.Epson;
|
||||
using Inspectron.Epson.PrintServer.Printers;
|
||||
using Inspectron.Epson.PrintServer.PrintServices;
|
||||
using Inspectron.Epson.PrintServer.Printers.Utils;
|
||||
using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt;
|
||||
using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe;
|
||||
@@ -577,6 +579,48 @@ public static class TestHelpers
|
||||
await printer.CutAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prints through the production IPrinter adapter chain instead of driving
|
||||
/// EpsonPrinter directly: query the model byte, let PrinterWrapperFactory pick
|
||||
/// the adapter from it, then print the logo and commands through that adapter.
|
||||
/// This is what EpsonPrintService does for a real job, so it exercises the
|
||||
/// model dispatch and the adapter's own ESC/POS dialect.
|
||||
///
|
||||
/// Use this for the POS895. PrintCommandsToEpsonPrinterAsync above predates the
|
||||
/// adapter layer and feeds with ESC d, which that firmware ignores - every cut
|
||||
/// would slice through the last printed line.
|
||||
/// </summary>
|
||||
public static async Task PrintCommandsViaAdapterAsync(
|
||||
EpsonPrinter printer,
|
||||
IEnumerable<PrintCommand> commands,
|
||||
string? logoPath = null)
|
||||
{
|
||||
byte? printerId = await printer.GetPrinterIdAsync();
|
||||
if (printerId is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Printer did not answer GS I 1, so no adapter can be chosen for it.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Printer reported model id 0x{printerId.Value:X2}");
|
||||
|
||||
IPrinter adapter = new PrinterWrapperFactory()
|
||||
.CreatePrinterFromId(printerId.Value, printer);
|
||||
|
||||
Console.WriteLine($"Using adapter {adapter.GetType().Name}");
|
||||
|
||||
await adapter.InitAsync();
|
||||
|
||||
if (logoPath is not null)
|
||||
{
|
||||
Console.WriteLine($"Printing logo {logoPath}");
|
||||
await adapter.PrintImageAsync(logoPath);
|
||||
}
|
||||
|
||||
await adapter.PrintAsync(commands.ToList());
|
||||
await adapter.Cut();
|
||||
}
|
||||
|
||||
public static async Task PrintKitchenCommandsToHtmlPrinterAsync(
|
||||
HtmlPrinter printer,
|
||||
IEnumerable<PrintCommand> commands)
|
||||
|
||||
173
EpsonTest/test.html
Normal file
173
EpsonTest/test.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Epson Printer Output</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f0f0f0;
|
||||
padding: 20px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.receipt {
|
||||
width: 384px;
|
||||
background-color: #fff;
|
||||
padding: 10px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.receipt span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.receipt .image {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.receipt .image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cut {
|
||||
position: relative;
|
||||
border-top: 2px dashed #333;
|
||||
margin: 20px 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cut .scissors {
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: -12px;
|
||||
font-size: 18px;
|
||||
background: #fff;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
/* Simulated paper texture */
|
||||
.receipt::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
rgba(0,0,0,0.02) 1px,
|
||||
rgba(0,0,0,0.02) 2px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="receipt">
|
||||
<div class="image" style="margin-left: 42px;"><img src="test_images/image_0001.png" alt="Printed image"></div>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Klingler Gastro AG</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Münzplatz 3</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> CH-8001 Zürich</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> 043 321 22 22</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Debitorenrechnung</span>
|
||||
<span style="font-size: 12px; font-weight: bold; line-height: 13px;"> 14:58 16.12.2025</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Guests: 2</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;">1x San Pellegrino 50cl 6.50 6.50 A</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">1x Panna 50cl 6.50 6.50 A</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">1x An item with really long 5.50 5.50 A</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> description that should wrap</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">2x Business Lunch Menu Seco 44.00 88.00 A</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> - Tomato Soup</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> - Grilled Salmon</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> - Tiramisu</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">2x Brunello di Montalcino 1 16.00 32.00 A</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">2x Espresso 5.50 11.00 A</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">1x Tip 10.50 10.50 B</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> ---------</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; letter-spacing: 6px; font-weight: bold; line-height: 13px;"> Summe: 160.00 CHF</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> 172.80 EUR</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Fine Dine -50.00 CHF</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Cash: 50.00 CHF</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Card: 110.00 CHF</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; font-weight: bold; line-height: 13px;">MASTER 160.00 CHF</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;">MwSt % Brutto Netto MwSt</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">A: 8.1% 149.50 CHF 138.30 CHF 11.20 CHF</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">B: 0% 10.50 CHF 10.50 CHF 0.00 CHF</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Bedient von: Yves</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Terminal: Hauptkasse ZH</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Tisch: 12</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> CHE-449.635.880 MWST</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> *** Kundenbeleg ***</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Buchung</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> TWINT</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">XXXXXXXXXXXXXXX1494</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">11.11.2025 12:34:49</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trm-Id: 31108834</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">AID: A0000015749E</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trx. Seq-Cnt: 6127</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trx. Ref-No: 99036644599</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Auth. Code: 0d3396</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Acq-Id: 2</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">EFT CHF: 67.00</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trinkgeld CHF: 6.70</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Total-EFT CHF: 73.70</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">------------------------------------------------</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> *** Kundenbeleg ***</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Buchung</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> MASTER</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">XXXXXXXXXXXXXXX2587</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">11.11.2025 12:36:12</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trm-Id: 31108834</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">AID: A0000000041010</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trx. Seq-Cnt: 6128</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trx. Ref-No: 99036644600</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Auth. Code: 1a4b2c</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Acq-Id: 1</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">EFT CHF: 93.00</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Trinkgeld CHF: 0.00</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Total-EFT CHF: 93.00</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">------------------------------------------------</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Das Team bedankt sich herzlich für Ihren</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Besuch.</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"> Auf Wiedersehen.</span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;"></span>
|
||||
<span style="font-size: 12px; line-height: 13px;">------------------------------------------------</span>
|
||||
<span style="font-size: 12px; line-height: 13px;">Unterschrift</span>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
EpsonTest/test_images/image_0001.png
Normal file
BIN
EpsonTest/test_images/image_0001.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
137
Inspectron.Epson.Tests/BitImagePrintingTests.cs
Normal file
137
Inspectron.Epson.Tests/BitImagePrintingTests.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Inspectron.Epson;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace Inspectron.Epson.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level tests for ESC * bit-image printing. EpsonPrinter writes to a TCP
|
||||
/// socket, so these point it at a loopback listener and assert on what actually
|
||||
/// goes on the wire.
|
||||
/// </summary>
|
||||
public class BitImagePrintingTests
|
||||
{
|
||||
private const byte ESC = 0x1B;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an EpsonPrinter against a loopback listener and returns every byte it sent.
|
||||
/// </summary>
|
||||
private static async Task<byte[]> CaptureAsync(Func<EpsonPrinter, Task> work)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var captured = new List<byte>();
|
||||
var pump = Task.Run(async () =>
|
||||
{
|
||||
using var client = await listener.AcceptTcpClientAsync();
|
||||
using var stream = client.GetStream();
|
||||
|
||||
var buffer = new byte[16384];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
captured.AddRange(buffer.AsSpan(0, read).ToArray());
|
||||
}
|
||||
});
|
||||
|
||||
await using (var printer = new EpsonPrinter())
|
||||
{
|
||||
await printer.ConnectAsync("127.0.0.1", port);
|
||||
await work(printer);
|
||||
}
|
||||
|
||||
await pump;
|
||||
listener.Stop();
|
||||
|
||||
return captured.ToArray();
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] haystack, byte[] needle, int start = 0)
|
||||
{
|
||||
for (int i = start; i <= haystack.Length - needle.Length; i++)
|
||||
{
|
||||
bool match = true;
|
||||
for (int j = 0; j < needle.Length; j++)
|
||||
{
|
||||
if (haystack[i + j] != needle[j]) { match = false; break; }
|
||||
}
|
||||
|
||||
if (match) return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int LastIndexOf(byte[] haystack, byte[] needle)
|
||||
{
|
||||
int found = -1, at = 0;
|
||||
while ((at = IndexOf(haystack, needle, at)) >= 0)
|
||||
{
|
||||
found = at;
|
||||
at++;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sets_line_spacing_to_the_slice_height_before_the_first_slice()
|
||||
{
|
||||
// Each ESC * 33 pass paints 24 dot rows, but the LF between passes advances
|
||||
// by the current line spacing - 1/6 inch, about 34 dots at 203 dpi - unless
|
||||
// told otherwise. Left at the default, every band is followed by a ~10 dot
|
||||
// white gap and the image comes out striped. Confirmed on real hardware.
|
||||
byte[] wire = await CaptureAsync(async printer =>
|
||||
{
|
||||
using var image = new Image<Rgba32>(64, 48); // two 24-dot slices
|
||||
await printer.PrintImageBitModeAsync(
|
||||
image, EpsonCommands.BitImageMode.DoubleDensity24Dot, 64);
|
||||
});
|
||||
|
||||
int spacing = IndexOf(wire, new byte[] { ESC, 0x33, 24 }); // ESC 3 24
|
||||
int firstSlice = IndexOf(wire, new byte[] { ESC, 0x2A }); // ESC *
|
||||
|
||||
Assert.True(spacing >= 0, "expected ESC 3 24 to set line spacing to the slice height");
|
||||
Assert.True(firstSlice >= 0, "expected at least one ESC * bit-image slice");
|
||||
Assert.True(spacing < firstSlice,
|
||||
"line spacing must be set BEFORE the first slice, or the first gap still appears");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restores_default_line_spacing_after_the_last_slice()
|
||||
{
|
||||
// Leaving the image's tight spacing set would compress every subsequent
|
||||
// line of text on the receipt.
|
||||
byte[] wire = await CaptureAsync(async printer =>
|
||||
{
|
||||
using var image = new Image<Rgba32>(64, 48);
|
||||
await printer.PrintImageBitModeAsync(
|
||||
image, EpsonCommands.BitImageMode.DoubleDensity24Dot, 64);
|
||||
});
|
||||
|
||||
int lastSlice = LastIndexOf(wire, new byte[] { ESC, 0x2A });
|
||||
int restore = LastIndexOf(wire, new byte[] { ESC, 0x32 }); // ESC 2
|
||||
|
||||
Assert.True(restore >= 0, "expected ESC 2 to restore the default line spacing");
|
||||
Assert.True(restore > lastSlice, "default spacing must be restored after the last slice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uses_the_eight_dot_slice_height_for_eight_dot_modes()
|
||||
{
|
||||
// The spacing has to track the mode, not be hardcoded to 24.
|
||||
byte[] wire = await CaptureAsync(async printer =>
|
||||
{
|
||||
using var image = new Image<Rgba32>(64, 16); // two 8-dot slices
|
||||
await printer.PrintImageBitModeAsync(
|
||||
image, EpsonCommands.BitImageMode.SingleDensity8Dot, 64);
|
||||
});
|
||||
|
||||
Assert.True(IndexOf(wire, new byte[] { ESC, 0x33, 8 }) >= 0,
|
||||
"expected ESC 3 8 for an 8-dot bit-image mode");
|
||||
}
|
||||
}
|
||||
218
Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs
Normal file
218
Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using Inspectron.Epson;
|
||||
|
||||
namespace Inspectron.Epson.Tests;
|
||||
|
||||
[Collection("udp-9100")]
|
||||
public class CompositeDiscoveryServiceTests
|
||||
{
|
||||
private sealed class StubDiscovery : IDiscoveryService
|
||||
{
|
||||
private readonly List<DiscoveredPrinter> _printers;
|
||||
private readonly Exception? _throws;
|
||||
|
||||
public StubDiscovery(params string[] ips)
|
||||
{
|
||||
_printers = ips.Select(ip => new DiscoveredPrinter { IPAddress = ip }).ToList();
|
||||
}
|
||||
|
||||
public StubDiscovery(Exception throws)
|
||||
{
|
||||
_printers = new List<DiscoveredPrinter>();
|
||||
_throws = throws;
|
||||
}
|
||||
|
||||
public StubDiscovery(List<DiscoveredPrinter> printers)
|
||||
{
|
||||
_printers = printers;
|
||||
}
|
||||
|
||||
public Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
|
||||
TimeSpan? timeout = null,
|
||||
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
|
||||
{
|
||||
if (_throws is not null)
|
||||
{
|
||||
throw _throws;
|
||||
}
|
||||
|
||||
foreach (var p in _printers)
|
||||
{
|
||||
onPrinterDiscovered?.Invoke(p);
|
||||
}
|
||||
|
||||
return Task.FromResult(_printers);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SlowStubDiscovery : IDiscoveryService
|
||||
{
|
||||
private readonly TimeSpan _delay;
|
||||
private readonly string _ip;
|
||||
|
||||
public SlowStubDiscovery(TimeSpan delay, string ip)
|
||||
{
|
||||
_delay = delay;
|
||||
_ip = ip;
|
||||
}
|
||||
|
||||
public async Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
|
||||
TimeSpan? timeout = null,
|
||||
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
|
||||
{
|
||||
await Task.Delay(_delay);
|
||||
return new List<DiscoveredPrinter> { new DiscoveredPrinter { IPAddress = _ip } };
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Merges_results_from_every_child()
|
||||
{
|
||||
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
||||
{
|
||||
new StubDiscovery("10.0.0.1"),
|
||||
new StubDiscovery("10.0.0.2", "10.0.0.3")
|
||||
});
|
||||
|
||||
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Contains(result, p => p.IPAddress == "10.0.0.1");
|
||||
Assert.Contains(result, p => p.IPAddress == "10.0.0.2");
|
||||
Assert.Contains(result, p => p.IPAddress == "10.0.0.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deduplicates_by_ip_keeping_the_first_child_that_reported_it()
|
||||
{
|
||||
// The two stubs report the same IP but are distinguishable, so this
|
||||
// proves first-wins rather than merely proving that one of them survived.
|
||||
var first = new StubDiscovery(new List<DiscoveredPrinter>
|
||||
{
|
||||
new DiscoveredPrinter { IPAddress = "10.0.0.1", ModelName = "TM-T30III", ServiceType = "ENPC" }
|
||||
});
|
||||
var second = new StubDiscovery(new List<DiscoveredPrinter>
|
||||
{
|
||||
new DiscoveredPrinter { IPAddress = "10.0.0.1", ModelName = "POS895", ServiceType = "SPRT" }
|
||||
});
|
||||
|
||||
var composite = new CompositeDiscoveryService(new IDiscoveryService[] { first, second });
|
||||
|
||||
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var only = Assert.Single(result);
|
||||
Assert.Equal("TM-T30III", only.ModelName);
|
||||
Assert.Equal("ENPC", only.ServiceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runs_children_concurrently_not_sequentially()
|
||||
{
|
||||
// Three children that each take ~300ms. Sequential execution needs >=900ms;
|
||||
// concurrent should land near 300ms. The 700ms bound leaves generous headroom
|
||||
// for a loaded machine while still failing a sequential regression widely.
|
||||
var children = new IDiscoveryService[]
|
||||
{
|
||||
new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.1"),
|
||||
new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.2"),
|
||||
new SlowStubDiscovery(TimeSpan.FromMilliseconds(300), "10.0.0.3")
|
||||
};
|
||||
|
||||
var composite = new CompositeDiscoveryService(children);
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromSeconds(5));
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.True(stopwatch.ElapsedMilliseconds < 700,
|
||||
$"expected concurrent execution near 300ms but took {stopwatch.ElapsedMilliseconds}ms, " +
|
||||
"which indicates the children ran sequentially");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_child_throwing_does_not_suppress_the_others()
|
||||
{
|
||||
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
||||
{
|
||||
new StubDiscovery(new InvalidOperationException("boom")),
|
||||
new StubDiscovery("10.0.0.7")
|
||||
});
|
||||
|
||||
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("10.0.0.7", result[0].IPAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_child_throwing_propagates_instead_of_reporting_no_printers()
|
||||
{
|
||||
// If every discovery protocol failed (e.g. a routeless interface), that is
|
||||
// "discovery is broken", not "no printers on the network". The caller
|
||||
// (PrinterDiscoveryBackgroundTask) relies on this to skip the pass instead
|
||||
// of expiring and unregistering every printer it already knows about.
|
||||
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
||||
{
|
||||
new StubDiscovery(new InvalidOperationException("boom 1")),
|
||||
new StubDiscovery(new InvalidOperationException("boom 2"))
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forwards_the_discovered_callback()
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
||||
{
|
||||
new StubDiscovery("10.0.0.1", "10.0.0.2")
|
||||
});
|
||||
|
||||
await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50),
|
||||
p => seen.Add(p.IPAddress));
|
||||
|
||||
Assert.Equal(2, seen.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Callback_fires_once_per_ip_even_when_two_children_report_it()
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
||||
{
|
||||
new StubDiscovery("10.0.0.1"),
|
||||
new StubDiscovery("10.0.0.1")
|
||||
});
|
||||
|
||||
await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50),
|
||||
p => seen.Add(p.IPAddress));
|
||||
|
||||
Assert.Single(seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_real_sprt_bind_failure_degrades_instead_of_losing_the_other_child()
|
||||
{
|
||||
// End-to-end proof of the fix: SprtDiscoveryService now throws when it
|
||||
// cannot bind UDP 9100 (instead of swallowing the fault and returning
|
||||
// empty), but the composite still absorbs that single child's failure and
|
||||
// returns whatever the other child found - it does not propagate. Total
|
||||
// failure only happens when every child fails, which is asserted
|
||||
// separately by Every_child_throwing_propagates_instead_of_reporting_no_printers.
|
||||
using var blocker = new System.Net.Sockets.UdpClient();
|
||||
blocker.Client.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 9100));
|
||||
|
||||
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
|
||||
{
|
||||
new SprtDiscoveryService(),
|
||||
new StubDiscovery("10.0.0.9")
|
||||
});
|
||||
|
||||
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var only = Assert.Single(result);
|
||||
Assert.Equal("10.0.0.9", only.IPAddress);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using EpsonPrintService;
|
||||
|
||||
namespace Inspectron.Epson.Tests.Configuration;
|
||||
|
||||
public class ConfigurationPathsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetConfigPath_uses_opt_dir_on_linux_when_env_unset_and_no_local_file()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return;
|
||||
var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH");
|
||||
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", null);
|
||||
try
|
||||
{
|
||||
var path = ConfigurationPaths.GetConfigPath();
|
||||
Assert.Equal("/opt/epson-print-service/config.txt", path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EPSON_CONFIG_PATH_override_still_wins()
|
||||
{
|
||||
var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH");
|
||||
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", "/tmp/override.txt");
|
||||
try
|
||||
{
|
||||
var path = ConfigurationPaths.GetConfigPath();
|
||||
Assert.Equal("/tmp/override.txt", path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
|
||||
<ProjectReference Include="..\EpsonPrintService\EpsonPrintService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
113
Inspectron.Epson.Tests/POS895Tests.cs
Normal file
113
Inspectron.Epson.Tests/POS895Tests.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Inspectron.Epson.PrintServer.Printers;
|
||||
using Inspectron.Epson.PrintServer.Printers.Utils;
|
||||
|
||||
namespace Inspectron.Epson.Tests;
|
||||
|
||||
public class POS895Tests
|
||||
{
|
||||
private static POS895 Make(out RecordingEpsonPrinter fake)
|
||||
{
|
||||
fake = new RecordingEpsonPrinter();
|
||||
return new POS895(fake) { SkipDelays = true };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrintAsync_never_uses_ESC_d_to_feed()
|
||||
{
|
||||
// This firmware ignores ESC d. Feeding with it would cut the last line.
|
||||
var printer = Make(out var fake);
|
||||
|
||||
await printer.PrintAsync(new List<PrintCommand>
|
||||
{
|
||||
new PrintCommand("hello")
|
||||
});
|
||||
|
||||
Assert.Empty(fake.FeedLineCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrintAsync_feeds_with_literal_line_feeds_before_finishing()
|
||||
{
|
||||
var printer = Make(out var fake);
|
||||
|
||||
await printer.PrintAsync(new List<PrintCommand>
|
||||
{
|
||||
new PrintCommand("hello")
|
||||
});
|
||||
|
||||
Assert.Equal(10, fake.LineFeedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrintAsync_writes_the_command_text()
|
||||
{
|
||||
var printer = Make(out var fake);
|
||||
|
||||
await printer.PrintAsync(new List<PrintCommand>
|
||||
{
|
||||
new PrintCommand("hello")
|
||||
});
|
||||
|
||||
Assert.Contains(fake.Text, t => t.Contains("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrintAsync_handles_an_IsCut_command_with_line_feeds_then_a_cut()
|
||||
{
|
||||
var printer = Make(out var fake);
|
||||
|
||||
await printer.PrintAsync(new List<PrintCommand>
|
||||
{
|
||||
new PrintCommand("a"),
|
||||
new PrintCommand("") { IsCut = true }
|
||||
});
|
||||
|
||||
Assert.Empty(fake.FeedLineCalls);
|
||||
Assert.Equal(1, fake.CutCount);
|
||||
|
||||
// 10 LF for the IsCut branch plus 10 for the trailing feed.
|
||||
Assert.Equal(20, fake.LineFeedBytes);
|
||||
|
||||
// The cut must be preceded by a feed, or the receipt is cut through its
|
||||
// last printed line - the exact failure ESC d would have caused.
|
||||
int cutIndex = fake.CallOrder.IndexOf("cut");
|
||||
Assert.True(cutIndex > 0, "expected a feed before the cut");
|
||||
Assert.StartsWith("raw:", fake.CallOrder[cutIndex - 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrintImageAsync_uses_bit_image_mode_at_576_dots()
|
||||
{
|
||||
// GS ( L prints nothing on this firmware, so LoadImageAsync must not be used.
|
||||
var printer = Make(out var fake);
|
||||
|
||||
await printer.PrintImageAsync("logo.png");
|
||||
|
||||
Assert.Empty(fake.RasterImages);
|
||||
var image = Assert.Single(fake.BitModeImages);
|
||||
Assert.Equal("logo.png", image.Path);
|
||||
Assert.Equal(EpsonCommands.BitImageMode.DoubleDensity24Dot, image.Mode);
|
||||
Assert.Equal(576, image.MaxWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrintImageAsync_does_not_use_absolute_positioning()
|
||||
{
|
||||
// ESC $ is unverified on this firmware; the logo spans the full width.
|
||||
var printer = Make(out var fake);
|
||||
|
||||
await printer.PrintImageAsync("logo.png");
|
||||
|
||||
Assert.Empty(fake.AbsolutePositions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cut_cuts_once()
|
||||
{
|
||||
var printer = Make(out var fake);
|
||||
|
||||
await printer.Cut();
|
||||
|
||||
Assert.Equal(1, fake.CutCount);
|
||||
}
|
||||
}
|
||||
122
Inspectron.Epson.Tests/PrinterRegistrationTests.cs
Normal file
122
Inspectron.Epson.Tests/PrinterRegistrationTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using Inspectron.Epson;
|
||||
using Inspectron.Epson.PrintServer.Printers;
|
||||
using Inspectron.Epson.PrintServer.Printers.Utils;
|
||||
|
||||
namespace Inspectron.Epson.Tests;
|
||||
|
||||
public class PrinterRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void WrapperFactory_returns_a_POS895_for_id_0x20()
|
||||
{
|
||||
var factory = new PrinterWrapperFactory();
|
||||
|
||||
var printer = factory.CreatePrinterFromId(0x20, new RecordingEpsonPrinter());
|
||||
|
||||
Assert.IsType<POS895>(printer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapperFactory_still_returns_a_TM_T30III_for_id_0x01()
|
||||
{
|
||||
var factory = new PrinterWrapperFactory();
|
||||
|
||||
var printer = factory.CreatePrinterFromId(0x01, new RecordingEpsonPrinter());
|
||||
|
||||
Assert.IsType<TM_T30IIITranslated>(printer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkareaTicket_uses_48_columns_for_the_POS895()
|
||||
{
|
||||
// The switch defaults to the 33-column TM-U220 layout. Without an
|
||||
// explicit 0x20 case a POS895 prints kitchen tickets far too narrow -
|
||||
// wrong, but not obviously broken.
|
||||
//
|
||||
// Both branches return the same converter type and only the constructor
|
||||
// argument differs, so comparing types would pass even unfixed. Reading
|
||||
// the private field is the only way to observe it.
|
||||
var factory = new ReceiptConverterFactory();
|
||||
|
||||
var pos895 = factory.Create((int)ReceiptConverterFactory.EReceiptType.WorkareaTicket, 0x20);
|
||||
|
||||
Assert.Equal(48, LineWidthOf(pos895));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkareaTicket_still_uses_33_columns_for_the_narrow_printer()
|
||||
{
|
||||
var factory = new ReceiptConverterFactory();
|
||||
|
||||
var u220 = factory.Create((int)ReceiptConverterFactory.EReceiptType.WorkareaTicket, 0x13);
|
||||
|
||||
Assert.Equal(33, LineWidthOf(u220));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads KitchenReceiptConverterAdapter._lineWidth. Brittle by design: if the
|
||||
/// field is renamed this fails loudly rather than silently passing.
|
||||
/// </summary>
|
||||
private static int LineWidthOf(IReceiptConverter converter)
|
||||
{
|
||||
var field = converter.GetType().GetField("_lineWidth",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(field);
|
||||
return (int)field!.GetValue(converter)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receipt_types_resolve_for_the_POS895()
|
||||
{
|
||||
var factory = new ReceiptConverterFactory();
|
||||
|
||||
foreach (var type in new[]
|
||||
{
|
||||
ReceiptConverterFactory.EReceiptType.Receipt,
|
||||
ReceiptConverterFactory.EReceiptType.WorkareaTicket,
|
||||
ReceiptConverterFactory.EReceiptType.OrdersOverview,
|
||||
ReceiptConverterFactory.EReceiptType.Invoice,
|
||||
ReceiptConverterFactory.EReceiptType.MoneyReturn
|
||||
})
|
||||
{
|
||||
Assert.NotNull(factory.Create((int)type, 0x20));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetModelNameFromId_maps_0x20_to_POS895()
|
||||
{
|
||||
Assert.Equal("POS895", ModelNameFor(0x20));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetModelNameFromId_still_maps_the_existing_epson_ids()
|
||||
{
|
||||
// Guards against a change that makes every id report as POS895.
|
||||
Assert.Equal("TM-T30III", ModelNameFor(0x01));
|
||||
Assert.Equal("TM-U220II", ModelNameFor(0x0D));
|
||||
Assert.Equal("TM-U220II", ModelNameFor(0x13));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetModelNameFromId_falls_back_for_an_unknown_id()
|
||||
{
|
||||
Assert.Equal("Unknown (0x99)", ModelNameFor(0x99));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes DiscoveryService.GetModelNameFromId, which is private static and
|
||||
/// otherwise reachable only through a live TCP connection. Reflection keeps the
|
||||
/// production code untouched; if the method is renamed this fails loudly rather
|
||||
/// than silently passing.
|
||||
/// </summary>
|
||||
private static string ModelNameFor(byte printerId)
|
||||
{
|
||||
var method = typeof(DiscoveryService).GetMethod("GetModelNameFromId",
|
||||
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
return (string)method!.Invoke(null, new object[] { printerId })!;
|
||||
}
|
||||
}
|
||||
104
Inspectron.Epson.Tests/RecordingEpsonPrinter.cs
Normal file
104
Inspectron.Epson.Tests/RecordingEpsonPrinter.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using Inspectron.Epson;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace Inspectron.Epson.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Records what an IPrinter adapter sends. Only the calls the POS895 adapter
|
||||
/// makes are recorded; the rest satisfy the interface and do nothing.
|
||||
/// </summary>
|
||||
public sealed class RecordingEpsonPrinter : IEpsonPrinter
|
||||
{
|
||||
public List<byte[]> RawCommands { get; } = new();
|
||||
public List<int> FeedLineCalls { get; } = new();
|
||||
public List<string> Text { get; } = new();
|
||||
public List<(string Path, EpsonCommands.BitImageMode Mode, int MaxWidth)> BitModeImages { get; } = new();
|
||||
public List<(string Path, int MaxWidth)> RasterImages { get; } = new();
|
||||
public List<int> AbsolutePositions { get; } = new();
|
||||
public int CutCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered log of the calls that affect receipt layout, so a test can assert
|
||||
/// that feeds happen BEFORE a cut rather than merely that both occurred.
|
||||
/// </summary>
|
||||
public List<string> CallOrder { get; } = new();
|
||||
|
||||
/// <summary>Total LF bytes across every raw command sent.</summary>
|
||||
public int LineFeedBytes => RawCommands.Sum(c => c.Count(b => b == 0x0A));
|
||||
|
||||
public bool IsConnected => true;
|
||||
|
||||
public Task SendRawCommandAsync(byte[] command)
|
||||
{
|
||||
RawCommands.Add(command);
|
||||
CallOrder.Add($"raw:{command.Length}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task FeedLinesAsync(int lines)
|
||||
{
|
||||
FeedLineCalls.Add(lines);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PrintTextAsync(string text)
|
||||
{
|
||||
Text.Add(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PrintImageBitModeAsync(string imagePath, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.SingleDensity8Dot, int maxWidth = 10)
|
||||
{
|
||||
BitModeImages.Add((imagePath, mode, maxWidth));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task LoadImageAsync(string imagePath, int maxWidth = 384)
|
||||
{
|
||||
RasterImages.Add((imagePath, maxWidth));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetAbsolutePrintPosition(int x)
|
||||
{
|
||||
AbsolutePositions.Add(x);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CutAsync(bool fullCut = true)
|
||||
{
|
||||
CutCount++;
|
||||
CallOrder.Add("cut");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Remaining interface members: no-ops.
|
||||
public Task ConnectAsync(string ip, int port = 9100, int timeoutSeconds = 10) => Task.CompletedTask;
|
||||
public void Disconnect() { }
|
||||
// 0x16 / 0x12 are what the POS895 actually returns when idle and ready.
|
||||
public Task<PrinterStatus> GetPrinterStatusAsync() => Task.FromResult(PrinterStatus.Parse(0x16));
|
||||
public Task<OfflineStatus> GetOfflineStatusAsync() => Task.FromResult(OfflineStatus.Parse(0x12));
|
||||
public Task<ErrorStatus> GetErrorStatusAsync() => Task.FromResult(ErrorStatus.Parse(0x12));
|
||||
public Task<PaperSensorStatus> GetPaperSensorStatusAsync() => Task.FromResult(PaperSensorStatus.Parse(0x12));
|
||||
public Task<byte> GetTM220StatusAsync() => Task.FromResult((byte)0);
|
||||
public Task<byte?> GetPrinterIdAsync() => Task.FromResult<byte?>(0x20);
|
||||
public Task<OverallStatus> GetOverallStatusAsync() => Task.FromResult(new OverallStatus());
|
||||
public Task InitAsync() => Task.CompletedTask;
|
||||
public Task SetQuadrupleMode(bool enable) => Task.CompletedTask;
|
||||
public Task SetRedColor(bool enable) => Task.CompletedTask;
|
||||
public Task SetEmphasized(bool enable) => Task.CompletedTask;
|
||||
public Task SelectFont(EpsonCommands.PrinterFont font) => Task.CompletedTask;
|
||||
public Task SetBiggerFontTM220(bool biggerWidth, bool biggerHeight = false, bool secondaryFont = true) => Task.CompletedTask;
|
||||
public Task SetFontSizeAsync(int width, int height) => Task.CompletedTask;
|
||||
public Task PrintTextAndCutAsync(string text, int feedLines = 5) => Task.CompletedTask;
|
||||
public Task PrintBuffer() => Task.CompletedTask;
|
||||
public Task LoadImageAsync(Stream imageStream, int maxWidth = 384) => Task.CompletedTask;
|
||||
public Task LoadImageAsync(Image<Rgba32> image, int maxWidth = 384) => Task.CompletedTask;
|
||||
public Task PrintLoadedImage() => Task.CompletedTask;
|
||||
public Task PrintImageBitModeAsync(Stream imageStream, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.DoubleDensity24Dot, int maxWidth = 384) => Task.CompletedTask;
|
||||
public Task PrintImageBitModeAsync(Image<Rgba32> image, EpsonCommands.BitImageMode mode, int maxWidth) => Task.CompletedTask;
|
||||
public Task SetDefaultLineSpacing() => Task.CompletedTask;
|
||||
public Task SetCustomLineSpacing(int spacing = 24) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Inspectron.Epson.Tests.Sanity;
|
||||
|
||||
public class SanityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Truth_is_true() => Assert.True(true);
|
||||
}
|
||||
102
Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs
Normal file
102
Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Text;
|
||||
using Inspectron.Epson;
|
||||
|
||||
namespace Inspectron.Epson.Tests;
|
||||
|
||||
[Collection("udp-9100")]
|
||||
public class SprtDiscoveryServiceTests
|
||||
{
|
||||
// The exact 39 bytes the printer returned on the bench:
|
||||
// 17 chars of dashed MAC, 12 spaces, 10 chars of serial.
|
||||
private static byte[] RealReply() =>
|
||||
Encoding.ASCII.GetBytes("28-0e-8b-3d-ca-f1 2608120478");
|
||||
|
||||
[Fact]
|
||||
public void BuildProbe_matches_the_captured_easyset_query()
|
||||
{
|
||||
byte[] expected =
|
||||
{
|
||||
0x53, 0x50, 0x52, 0x54, 0x2D, 0x49, 0x6E, 0x66, 0x6F, 0x72,
|
||||
0x00, 0x00, 0x0D
|
||||
};
|
||||
|
||||
Assert.Equal(expected, SprtDiscoveryService.BuildProbe());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseReply_extracts_mac_and_serial()
|
||||
{
|
||||
bool ok = SprtDiscoveryService.TryParseReply(RealReply(), "10.1.252.219", out var printer);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(printer);
|
||||
Assert.Equal("10.1.252.219", printer!.IPAddress);
|
||||
Assert.Equal("28-0e-8b-3d-ca-f1", printer.MACAddress);
|
||||
Assert.Equal("2608120478", printer.Serial);
|
||||
Assert.Equal("POS895", printer.ModelName);
|
||||
Assert.Equal(9100, printer.Port);
|
||||
Assert.Equal("SPRT", printer.ServiceType);
|
||||
Assert.Equal(RealReply(), printer.RawResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseReply_rejects_a_payload_shorter_than_the_mac()
|
||||
{
|
||||
bool ok = SprtDiscoveryService.TryParseReply(
|
||||
Encoding.ASCII.GetBytes("28-0e-8b"), "10.1.252.219", out var printer);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(printer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseReply_rejects_a_payload_whose_head_is_not_a_mac()
|
||||
{
|
||||
// Right length, wrong shape - an unrelated broadcast landing on our port.
|
||||
bool ok = SprtDiscoveryService.TryParseReply(
|
||||
Encoding.ASCII.GetBytes("hello there, not a mac at all, nope!!!!!"),
|
||||
"10.1.252.219", out var printer);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(printer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseReply_rejects_a_mac_with_non_hex_digits()
|
||||
{
|
||||
bool ok = SprtDiscoveryService.TryParseReply(
|
||||
Encoding.ASCII.GetBytes("zz-0e-8b-3d-ca-f1 2608120478"),
|
||||
"10.1.252.219", out var printer);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(printer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseReply_handles_a_reply_with_no_serial()
|
||||
{
|
||||
bool ok = SprtDiscoveryService.TryParseReply(
|
||||
Encoding.ASCII.GetBytes("28-0e-8b-3d-ca-f1"), "10.1.252.219", out var printer);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("", printer!.Serial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverPrintersAsync_throws_when_it_cannot_bind_port_9100()
|
||||
{
|
||||
// Hold UDP 9100 so the service cannot bind it. It must throw rather than
|
||||
// swallow the failure and return empty: CompositeDiscoveryService needs to
|
||||
// tell "this pass is broken" apart from "the network genuinely has no SPRT
|
||||
// printers on it". Swallowing it here would let a network fault look like
|
||||
// a clean empty result, which is exactly what let live Epson printers get
|
||||
// silently deregistered when discovery quietly "succeeded" with nothing.
|
||||
using var blocker = new System.Net.Sockets.UdpClient();
|
||||
blocker.Client.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 9100));
|
||||
|
||||
var service = new SprtDiscoveryService();
|
||||
|
||||
await Assert.ThrowsAsync<System.Net.Sockets.SocketException>(
|
||||
() => service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200)));
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Inspectron.Epson.PrintServer.Telemetry;
|
||||
using Inspectron.Epson.Queue;
|
||||
|
||||
namespace Inspectron.Epson.Tests.Telemetry;
|
||||
|
||||
public class InsinEventKindsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(PrintErrorType.OutOfPaper, "job.failed.paper_out")]
|
||||
[InlineData(PrintErrorType.Offline, "job.failed.connection")]
|
||||
[InlineData(PrintErrorType.ConversionError, "job.failed.other")]
|
||||
[InlineData(PrintErrorType.Other, "job.failed.other")]
|
||||
[InlineData(PrintErrorType.None, "job.failed.other")]
|
||||
public void MapErrorTypeToKind(PrintErrorType input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, InsinEventKinds.MapErrorTypeToKind(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constants_have_expected_values()
|
||||
{
|
||||
Assert.Equal("service.started", InsinEventKinds.ServiceStarted);
|
||||
Assert.Equal("job.printed", InsinEventKinds.JobPrinted);
|
||||
Assert.Equal("printer.discovered", InsinEventKinds.PrinterDiscovered);
|
||||
Assert.Equal("printer.online", InsinEventKinds.PrinterOnline);
|
||||
Assert.Equal("printer.offline", InsinEventKinds.PrinterOffline);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using Inspectron.Epson.PrintServer.Telemetry;
|
||||
|
||||
namespace Inspectron.Epson.Tests.Telemetry;
|
||||
|
||||
public class InsinMessageFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Format_single_pair_no_spaces()
|
||||
{
|
||||
var result = InsinMessageFormatter.Format(("printer", "192.168.1.10"));
|
||||
Assert.Equal("printer=192.168.1.10", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_multiple_pairs_joined_by_space()
|
||||
{
|
||||
var result = InsinMessageFormatter.Format(
|
||||
("printer", "192.168.1.10"),
|
||||
("jobId", "42"));
|
||||
Assert.Equal("printer=192.168.1.10 jobId=42", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_quotes_value_containing_space()
|
||||
{
|
||||
var result = InsinMessageFormatter.Format(("error", "paper end detected"));
|
||||
Assert.Equal("error=\"paper end detected\"", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_escapes_embedded_double_quote()
|
||||
{
|
||||
var result = InsinMessageFormatter.Format(("error", "he said \"nope\""));
|
||||
Assert.Equal("error=\"he said \\\"nope\\\"\"", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_treats_null_or_empty_value_as_empty_string()
|
||||
{
|
||||
var result = InsinMessageFormatter.Format(("model", (string?)null), ("name", ""));
|
||||
Assert.Equal("model= name=", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_omits_pairs_with_null_or_empty_key()
|
||||
{
|
||||
var result = InsinMessageFormatter.Format(
|
||||
("", "ignored"),
|
||||
("printer", "192.168.1.10"));
|
||||
Assert.Equal("printer=192.168.1.10", result);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using EpsonPrintService;
|
||||
using Inspectron.Epson;
|
||||
using Inspectron.Epson.PrintServer.Telemetry;
|
||||
|
||||
namespace Inspectron.Epson.Tests.Telemetry;
|
||||
|
||||
public class InsinTelemetryDiscoveredPrintersReceiverTests
|
||||
{
|
||||
private sealed class RecordingTelemetry : IInsinTelemetry
|
||||
{
|
||||
public List<(string Kind, string Message)> Events { get; } = new();
|
||||
public void Emit(string kind, string message) => Events.Add((kind, message));
|
||||
}
|
||||
|
||||
private sealed class CountingInner : IDiscoveredPrintersReceiver
|
||||
{
|
||||
public int Calls;
|
||||
public Task OnPrintersDiscoveredAsync(IReadOnlyList<DiscoveredPrinter> printers)
|
||||
{
|
||||
Calls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static DiscoveredPrinter Printer(string ip, string model = "TM-T30III") =>
|
||||
new() { IPAddress = ip, ModelName = model };
|
||||
|
||||
[Fact]
|
||||
public async Task First_sighting_emits_discovered_and_online()
|
||||
{
|
||||
var telemetry = new RecordingTelemetry();
|
||||
var inner = new CountingInner();
|
||||
var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry);
|
||||
|
||||
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
|
||||
|
||||
Assert.Equal(1, inner.Calls);
|
||||
Assert.Collection(telemetry.Events,
|
||||
e => Assert.Equal("printer.discovered", e.Kind),
|
||||
e => Assert.Equal("printer.online", e.Kind));
|
||||
Assert.Contains("printer=192.168.1.10", telemetry.Events[0].Message);
|
||||
Assert.Contains("model=TM-T30III", telemetry.Events[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_sighting_emits_nothing()
|
||||
{
|
||||
var telemetry = new RecordingTelemetry();
|
||||
var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry);
|
||||
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
|
||||
telemetry.Events.Clear();
|
||||
|
||||
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
|
||||
|
||||
Assert.Empty(telemetry.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disappearance_emits_offline()
|
||||
{
|
||||
var telemetry = new RecordingTelemetry();
|
||||
var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry);
|
||||
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
|
||||
telemetry.Events.Clear();
|
||||
|
||||
await sut.OnPrintersDiscoveredAsync(Array.Empty<DiscoveredPrinter>());
|
||||
|
||||
Assert.Single(telemetry.Events);
|
||||
Assert.Equal("printer.offline", telemetry.Events[0].Kind);
|
||||
Assert.Equal("printer=192.168.1.10", telemetry.Events[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reappearance_emits_online_only_not_discovered()
|
||||
{
|
||||
var telemetry = new RecordingTelemetry();
|
||||
var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry);
|
||||
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
|
||||
await sut.OnPrintersDiscoveredAsync(Array.Empty<DiscoveredPrinter>());
|
||||
telemetry.Events.Clear();
|
||||
|
||||
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
|
||||
|
||||
Assert.Single(telemetry.Events);
|
||||
Assert.Equal("printer.online", telemetry.Events[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Telemetry_failures_do_not_break_inner()
|
||||
{
|
||||
var telemetry = new ThrowingTelemetry();
|
||||
var inner = new CountingInner();
|
||||
var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry);
|
||||
|
||||
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
|
||||
|
||||
Assert.Equal(1, inner.Calls);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTelemetry : IInsinTelemetry
|
||||
{
|
||||
public void Emit(string kind, string message) => throw new InvalidOperationException("boom");
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Inspectron.Epson.PrintServer.Telemetry;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Inspectron.Epson.Tests.Telemetry;
|
||||
|
||||
public class LoopbackInsinTelemetryTests
|
||||
{
|
||||
private const int TestPort = 47823;
|
||||
|
||||
[Fact]
|
||||
public async Task Emit_posts_json_payload_to_loopback()
|
||||
{
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/");
|
||||
listener.Start();
|
||||
|
||||
using var http = new HttpClient();
|
||||
var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance);
|
||||
telemetry.Emit("service.started", "version=1.0.14");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
var ctxTask = listener.GetContextAsync();
|
||||
var completed = await Task.WhenAny(ctxTask, Task.Delay(Timeout.Infinite, cts.Token));
|
||||
Assert.Same(ctxTask, completed);
|
||||
var ctx = ctxTask.Result;
|
||||
|
||||
Assert.Equal("POST", ctx.Request.HttpMethod);
|
||||
Assert.Equal("/events", ctx.Request.Url!.AbsolutePath);
|
||||
using var sr = new StreamReader(ctx.Request.InputStream, Encoding.UTF8);
|
||||
var body = await sr.ReadToEndAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.Equal("service.started", doc.RootElement.GetProperty("kind").GetString());
|
||||
Assert.Equal("version=1.0.14", doc.RootElement.GetProperty("message").GetString());
|
||||
var at = doc.RootElement.GetProperty("at").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(at));
|
||||
Assert.EndsWith("Z", at);
|
||||
|
||||
ctx.Response.StatusCode = 200;
|
||||
ctx.Response.Close();
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emit_swallows_connection_refused_and_does_not_throw()
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance);
|
||||
var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.14"));
|
||||
Assert.Null(ex);
|
||||
await Task.Delay(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emit_swallows_500_response_and_does_not_throw()
|
||||
{
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/");
|
||||
listener.Start();
|
||||
|
||||
using var http = new HttpClient();
|
||||
var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance);
|
||||
telemetry.Emit("service.started", "version=1.0.14");
|
||||
|
||||
var ctx = await listener.GetContextAsync();
|
||||
ctx.Response.StatusCode = 500;
|
||||
ctx.Response.Close();
|
||||
|
||||
await Task.Delay(200);
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using Inspectron.Epson.PrintServer.Telemetry;
|
||||
|
||||
namespace Inspectron.Epson.Tests.Telemetry;
|
||||
|
||||
public class NullInsinTelemetryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Emit_does_nothing_and_does_not_throw()
|
||||
{
|
||||
var telemetry = new NullInsinTelemetry();
|
||||
var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.0"));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
<Folder Name="/Utils/">
|
||||
<Project Path="EpsonTest.Templates/EpsonTest.Templates.csproj" />
|
||||
<Project Path="EpsonTest/EpsonTest.csproj" />
|
||||
<Project Path="Pos895Test/Pos895Test.csproj" />
|
||||
<Project Path="Inspectron.PrintServer.TemplateEditor/Inspectron.PrintServer.TemplateEditor.csproj" Id="253ec869-47ac-41d4-985a-5923006ef449" />
|
||||
</Folder>
|
||||
<Project Path="EpsonPrintService/EpsonPrintService.csproj" />
|
||||
|
||||
80
Inspectron.Epson/CompositeDiscoveryService.cs
Normal file
80
Inspectron.Epson/CompositeDiscoveryService.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Inspectron.Epson;
|
||||
|
||||
/// <summary>
|
||||
/// Runs several discovery services concurrently and merges their results by IP.
|
||||
///
|
||||
/// Epson printers answer ENPC (DiscoveryService) and SPRT-family printers answer
|
||||
/// SPRT (SprtDiscoveryService); both broadcast on UDP 3289. A child that fails is
|
||||
/// logged and skipped so one broken protocol cannot blind the others - but if
|
||||
/// EVERY child fails, that is not "no printers found", it is "discovery is
|
||||
/// broken", and this rethrows so callers (e.g. PrinterDiscoveryBackgroundTask)
|
||||
/// can tell the difference and skip the pass instead of expiring every printer
|
||||
/// they already know about.
|
||||
/// </summary>
|
||||
public class CompositeDiscoveryService : IDiscoveryService
|
||||
{
|
||||
private readonly IReadOnlyList<IDiscoveryService> _services;
|
||||
private readonly ILogger? _logger;
|
||||
|
||||
public CompositeDiscoveryService(IEnumerable<IDiscoveryService> services, ILogger? logger = null)
|
||||
{
|
||||
_services = services.ToList();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
|
||||
TimeSpan? timeout = null,
|
||||
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
|
||||
{
|
||||
var merged = new List<DiscoveredPrinter>();
|
||||
var seenIps = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Captures one exception per failed child so, if every child fails, the
|
||||
// rethrow below can carry a real InnerException instead of a bare message.
|
||||
var childExceptions = new System.Collections.Concurrent.ConcurrentBag<Exception>();
|
||||
|
||||
// The children run concurrently, so the pass takes about as long as the
|
||||
// slowest child rather than the sum of all of them. Task.WhenAll applies
|
||||
// no timeout of its own - each child is responsible for honouring the
|
||||
// timeout it is given.
|
||||
var tasks = _services.Select(async service =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await service.DiscoverPrintersAsync(timeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Discovery service {Service} failed",
|
||||
service.GetType().Name);
|
||||
childExceptions.Add(ex);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
List<DiscoveredPrinter>?[] results = await Task.WhenAll(tasks);
|
||||
|
||||
if (_services.Count > 0 && results.All(r => r is null))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Every discovery service failed; cannot tell whether any printers are on the network.",
|
||||
childExceptions.FirstOrDefault());
|
||||
}
|
||||
|
||||
// Task.WhenAll has completed, so this merge is single-threaded.
|
||||
foreach (var printer in results.Where(r => r is not null).SelectMany(r => r!))
|
||||
{
|
||||
if (printer.IPAddress is null || !seenIps.Add(printer.IPAddress))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.Add(printer);
|
||||
onPrinterDiscovered?.Invoke(printer);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,14 @@ public class DiscoveredPrinter
|
||||
public string ModelName { get; set; }
|
||||
public string IPAddress { get; set; }
|
||||
public string MACAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Printer serial number. Populated by SPRT discovery; null for ENPC.
|
||||
/// Matches what GS I 68 returns over TCP, so it correlates a discovered
|
||||
/// printer with a connected one.
|
||||
/// </summary>
|
||||
public string? Serial { get; set; }
|
||||
|
||||
public int Port { get; set; }
|
||||
public DateTime DiscoveredAt { get; set; }
|
||||
public byte[] RawResponse { get; set; }
|
||||
|
||||
@@ -144,6 +144,7 @@ public class DiscoveryService : IDiscoveryService
|
||||
0x01 => "TM-T30III",
|
||||
0x0D => "TM-U220II",
|
||||
0x13 => "TM-U220II",
|
||||
0x20 => "POS895",
|
||||
_ => $"Unknown (0x{printerId:X2})"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -608,6 +608,16 @@ public class EpsonPrinter : IEpsonPrinter
|
||||
|
||||
_logger?.LogDebug("Printing in {SliceCount} slices of {BitsPerSlice} dots each", totalSlices, bitsPerSlice);
|
||||
|
||||
// Each ESC * pass paints bitsPerSlice dot rows, but the LF between passes
|
||||
// advances by the CURRENT line spacing, which ESC @ leaves at 1/6 inch -
|
||||
// about 34 dots at 203 dpi. Left alone, every 24-dot band is followed by a
|
||||
// ~10 dot white gap and the image prints striped. Match the spacing to the
|
||||
// slice height for the duration of the image, then put it back: leaving it
|
||||
// tight would compress every following line of text on the receipt.
|
||||
await SetCustomLineSpacing(bitsPerSlice);
|
||||
|
||||
try
|
||||
{
|
||||
// Print each horizontal slice
|
||||
for (int slice = 0; slice < totalSlices; slice++)
|
||||
{
|
||||
@@ -628,6 +638,11 @@ public class EpsonPrinter : IEpsonPrinter
|
||||
await SendRawAsync(EpsonCommands.LineFeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetDefaultLineSpacing();
|
||||
}
|
||||
|
||||
_logger?.LogInformation("Image printed successfully using bit-image mode");
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public class PreconfiguredDiscoveryService : IDiscoveryService
|
||||
ModelName = p.ModelName,
|
||||
Port = p.Port,
|
||||
MACAddress = p.MACAddress,
|
||||
Serial = p.Serial,
|
||||
ServiceUrl = p.ServiceUrl,
|
||||
ServiceType = p.ServiceType,
|
||||
Lifetime = p.Lifetime,
|
||||
|
||||
@@ -39,6 +39,14 @@ public class EpsonPrintService: IPrintService
|
||||
|
||||
_logger.LogInformation("Printer ID({ip}): {PrinterId}", printerIp, printerId);
|
||||
|
||||
if (printerId is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Printer {PrinterIp} did not answer GS I 1, so its model is unknown; " +
|
||||
"cannot choose a printer adapter", printerIp);
|
||||
return PrintResult.Fail(PrintErrorType.Other);
|
||||
}
|
||||
|
||||
var status = await epsonPrinter.GetPrinterStatusAsync();
|
||||
if (!status.IsOnline)
|
||||
{
|
||||
|
||||
96
Inspectron.Epson/PrintServer/Printers/POS895.cs
Normal file
96
Inspectron.Epson/PrintServer/Printers/POS895.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Inspectron.Epson.PrintServer.Printers.Utils;
|
||||
using Inspectron.Epson.PrintServer.PrintServices;
|
||||
|
||||
namespace Inspectron.Epson.PrintServer.Printers;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter for the POS895 / POS896, SPRT-family ESC/POS clones on an 80 mm,
|
||||
/// 576-dot line (Font A = 48 columns).
|
||||
///
|
||||
/// Cloned from TM_T30IIITranslated with three differences, each confirmed on
|
||||
/// hardware and recorded in POS895_POS896_integration_notes.md:
|
||||
///
|
||||
/// 1. ESC d n is ignored by this firmware, so feeding uses literal LF bytes.
|
||||
/// Using ESC d would cut the receipt through its last line.
|
||||
/// 2. GS ( L prints nothing, so the logo goes out as ESC * bit-image.
|
||||
/// 3. The line is 576 dots, not 384, and the logo spans it from position 0,
|
||||
/// which avoids ESC $ (still unverified here).
|
||||
/// </summary>
|
||||
public class POS895 : IPrinter
|
||||
{
|
||||
/// <summary>Dots per line. Font A is 12 dots wide, giving 48 columns.</summary>
|
||||
public const int PaperWidthDots = 576;
|
||||
|
||||
private const byte LineFeed = 0x0A;
|
||||
private const int FeedLinesBeforeCut = 10;
|
||||
|
||||
private readonly IEpsonPrinter _printer;
|
||||
|
||||
public bool SkipDelays { get; set; }
|
||||
|
||||
public POS895(IEpsonPrinter printer)
|
||||
{
|
||||
_printer = printer;
|
||||
}
|
||||
|
||||
public Task InitAsync()
|
||||
{
|
||||
return _printer.InitAsync();
|
||||
}
|
||||
|
||||
public async Task PrintImageAsync(string path)
|
||||
{
|
||||
await FeedAsync(1);
|
||||
await _printer.PrintImageBitModeAsync(
|
||||
path, EpsonCommands.BitImageMode.DoubleDensity24Dot, PaperWidthDots);
|
||||
if (!SkipDelays) await Task.Delay(100);
|
||||
await FeedAsync(1);
|
||||
}
|
||||
|
||||
public async Task PrintAsync(List<PrintCommand> commands)
|
||||
{
|
||||
await _printer.SetCustomLineSpacing(22);
|
||||
|
||||
foreach (var command in commands)
|
||||
{
|
||||
if (command.IsCut)
|
||||
{
|
||||
await FeedAsync(FeedLinesBeforeCut);
|
||||
await _printer.CutAsync(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (command.SetLineSpacing.HasValue)
|
||||
{
|
||||
await _printer.SetCustomLineSpacing(command.SetLineSpacing.Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _printer.SetFontSizeAsync(command.IsBig ? 2 : 1, command.IsTall ? 2 : 1);
|
||||
await _printer.SetEmphasized(command.IsBold);
|
||||
await _printer.PrintTextAsync(command.Text + "\n");
|
||||
}
|
||||
|
||||
await _printer.SetDefaultLineSpacing();
|
||||
await FeedAsync(FeedLinesBeforeCut);
|
||||
if (!SkipDelays) await Task.Delay(200);
|
||||
}
|
||||
|
||||
public async Task Cut()
|
||||
{
|
||||
await _printer.CutAsync();
|
||||
if (!SkipDelays) await Task.Delay(200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds with literal LF bytes. IEpsonPrinter.FeedLinesAsync emits ESC d,
|
||||
/// which this firmware ignores, and the Epson models depend on that method
|
||||
/// staying as it is.
|
||||
/// </summary>
|
||||
private Task FeedAsync(int lines)
|
||||
{
|
||||
var bytes = new byte[lines];
|
||||
Array.Fill(bytes, LineFeed);
|
||||
return _printer.SendRawCommandAsync(bytes);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public class PrinterWrapperFactory:IWrapperPrinterFactory
|
||||
return new TM_U220IITranslated(epsonPrinter);
|
||||
case 0x01:
|
||||
return new TM_T30IIITranslated(epsonPrinter);
|
||||
case 0x20:
|
||||
return new POS895(epsonPrinter);
|
||||
default:
|
||||
throw new NotSupportedException($"Printer with ID {printerId:X2} is not supported.");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory
|
||||
{
|
||||
//0x01=>new BarReceiptConverterAdapter(48, 24),
|
||||
0x01 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24),
|
||||
0x20 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24),
|
||||
_ => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20),
|
||||
},
|
||||
EReceiptType.NextCourse => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20),
|
||||
|
||||
176
Inspectron.Epson/SprtDiscoveryService.cs
Normal file
176
Inspectron.Epson/SprtDiscoveryService.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Inspectron.Epson;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery for SPRT-family ESC/POS printers (POS895 / POS896), which do not
|
||||
/// answer Epson's ENPC broadcast.
|
||||
///
|
||||
/// Protocol, reverse engineered from a packet capture of the vendor's EasySet
|
||||
/// tool and documented in POS895_POS896_integration_notes.md:
|
||||
///
|
||||
/// query UDP broadcast 255.255.255.255:3289, sent FROM source port 9100
|
||||
/// 53 50 52 54 2D 49 6E 66 6F 72 00 00 0D "SPRT-Infor" 00 00 0D
|
||||
///
|
||||
/// reply from <printer>:3289 back to <querier>:9100, 39 ASCII bytes
|
||||
/// "28-0e-8b-3d-ca-f1 2608120478"
|
||||
/// |<-- MAC, 17 -->||<- 12 sp ->||<- serial ->|
|
||||
///
|
||||
/// Error contract: a failure to bind UDP 9100 or to send the broadcast probe
|
||||
/// propagates as a <see cref="SocketException"/> instead of being swallowed
|
||||
/// here. That lets <see cref="CompositeDiscoveryService"/>, which absorbs a
|
||||
/// single child's failure but rethrows when every child fails, tell a broken
|
||||
/// discovery pass (this service throwing) apart from a healthy pass on a
|
||||
/// network with no SPRT printers on it (this service returning an empty
|
||||
/// list). A single bad datagram in the receive loop is not treated as a pass
|
||||
/// failure and does not throw.
|
||||
/// </summary>
|
||||
public class SprtDiscoveryService : IDiscoveryService
|
||||
{
|
||||
private const string ProbeMagic = "SPRT-Infor";
|
||||
private const int MacLength = 17;
|
||||
private const int DiscoveryPort = 3289;
|
||||
private const int ReplyPort = 9100;
|
||||
|
||||
/// <summary>Model reported for anything answering SPRT. The reply carries no
|
||||
/// model name, and a POS896 is indistinguishable from a POS895 here.</summary>
|
||||
public const string SprtModelName = "POS895";
|
||||
|
||||
private readonly ILogger? _logger;
|
||||
|
||||
public SprtDiscoveryService(ILogger? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>The 13-byte discovery query, byte-identical to EasySet's.</summary>
|
||||
public static byte[] BuildProbe()
|
||||
{
|
||||
var probe = new List<byte>(Encoding.ASCII.GetBytes(ProbeMagic));
|
||||
probe.AddRange(new byte[] { 0x00, 0x00, 0x0D });
|
||||
return probe.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a discovery reply. Returns false for anything that is not a
|
||||
/// dashed 17-character MAC followed by an optional serial, so unrelated
|
||||
/// broadcasts landing on our port are ignored rather than thrown on.
|
||||
/// </summary>
|
||||
public static bool TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)
|
||||
{
|
||||
printer = null;
|
||||
|
||||
if (payload.Length < MacLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string text = Encoding.ASCII.GetString(payload);
|
||||
string mac = text[..MacLength];
|
||||
|
||||
string[] octets = mac.Split('-');
|
||||
if (octets.Length != 6 || !octets.All(o => o.Length == 2 && o.All(Uri.IsHexDigit)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
printer = new DiscoveredPrinter
|
||||
{
|
||||
IPAddress = ipAddress,
|
||||
MACAddress = mac,
|
||||
Serial = text[MacLength..].Trim(),
|
||||
ModelName = SprtModelName,
|
||||
Port = 9100,
|
||||
ServiceType = "SPRT",
|
||||
RawResponse = payload,
|
||||
DiscoveredAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
|
||||
TimeSpan? timeout = null,
|
||||
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
|
||||
{
|
||||
timeout ??= TimeSpan.FromSeconds(5);
|
||||
var discovered = new List<DiscoveredPrinter>();
|
||||
|
||||
using var udpClient = new UdpClient(AddressFamily.InterNetwork);
|
||||
udpClient.EnableBroadcast = true;
|
||||
|
||||
try
|
||||
{
|
||||
// EasySet sends from 9100 and the printer replies to 9100. Whether the
|
||||
// firmware honours the sender's port or hard-codes 9100 is unknown, so
|
||||
// bind the port that is proven to work.
|
||||
udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, ReplyPort));
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
_logger?.LogWarning(ex,
|
||||
"SPRT discovery could not bind UDP {Port}; skipping this pass", ReplyPort);
|
||||
throw;
|
||||
}
|
||||
|
||||
var probe = BuildProbe();
|
||||
|
||||
try
|
||||
{
|
||||
await udpClient.SendAsync(probe, probe.Length,
|
||||
new IPEndPoint(IPAddress.Broadcast, DiscoveryPort));
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "SPRT discovery broadcast failed");
|
||||
throw;
|
||||
}
|
||||
|
||||
DateTime deadline = DateTime.UtcNow.Add(timeout.Value);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(deadline - DateTime.UtcNow);
|
||||
UdpReceiveResult result;
|
||||
|
||||
try
|
||||
{
|
||||
result = await udpClient.ReceiveAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
_logger?.LogDebug(ex, "SPRT discovery receive error");
|
||||
continue;
|
||||
}
|
||||
|
||||
string ip = result.RemoteEndPoint.Address.ToString();
|
||||
|
||||
if (discovered.Any(p => p.IPAddress == ip))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseReply(result.Buffer, ip, out var printer) || printer is null)
|
||||
{
|
||||
_logger?.LogDebug("Ignoring {Length}-byte non-SPRT datagram from {Ip}",
|
||||
result.Buffer.Length, ip);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger?.LogInformation("SPRT discovery found {Model} at {Ip} (MAC {Mac}, serial {Serial})",
|
||||
printer.ModelName, printer.IPAddress, printer.MACAddress, printer.Serial);
|
||||
|
||||
discovered.Add(printer);
|
||||
onPrinterDiscovered?.Invoke(printer);
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
}
|
||||
206
POS895_POS896_integration_notes.md
Normal file
206
POS895_POS896_integration_notes.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# POS895 / POS896 printer support: feasibility notes
|
||||
|
||||
Date: 2026-09-02, updated 2026-09-03 after a hardware session. Status: several
|
||||
of the open questions below are now answered, and the vendor discovery protocol
|
||||
is reverse engineered and reimplemented. See `Pos895Test/`.
|
||||
|
||||
Source material reviewed: `D:\Inspectron\James\epson\POS895\POS895&896 SDK\` (Linux SDK, Windows POS_SDK, EasySet tool, POS895 and POS896 user manuals, POS_SDK Development Manual V1.4).
|
||||
|
||||
## Verdict
|
||||
|
||||
The POS895 and POS896 can be supported by this codebase, but **not by linking the vendor SDK**. The SDK is a thin C wrapper that builds ESC/POS byte strings and writes them to a raw TCP socket on port 9100. `Inspectron.Epson/EpsonPrinter.cs` already does the same thing in C#. The real work is:
|
||||
|
||||
1. a new `IPrinter` adapter for a 576-dot, 80 mm ESC/POS clone,
|
||||
2. a discovery strategy that does not rely on Epson's ENPC broadcast,
|
||||
3. a more robust printer identification than the single `GS I 1` byte,
|
||||
4. a hardware probe session to close the unknowns listed below.
|
||||
|
||||
Rough effort: 3 to 5 developer days once a printer is on the bench. No new deployment dependencies, no native code, no libusb. Restrict to the Ethernet models (POS895 UEd, POS896 UE); USB-attached operation is a separate project because the whole pipeline keys on an IP address.
|
||||
|
||||
## What the vendor SDK is
|
||||
|
||||
Path: `Linux_SDK\Linux_SDK_20190110\Linux_SDK_64_group_ARM\Linux_SDK_64_group\`
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `HWISPRTPrinter.c` / `.h` | ~5,500 lines of C. Transports: libusb, serial tty, TCP socket to port 9100. Every `POS_*` function fills a global 1 KB buffer with an ESC/POS sequence and writes it. |
|
||||
| `sprtGraphic.c` / `.h` | BMP loader and a proprietary line-bitmap encoder (see below). |
|
||||
| `libHWISPRTPrinter.so` | Prebuilt library. **32-bit Intel i386**, despite the folder name saying ARM. |
|
||||
| `libusb/` | Bundled libusb, **x86-64**. |
|
||||
| `testMain.c`, `CPCLtest.c`, `TSPLtest.c`, `CPCLDemo.c`, `GtestMain.c` | Demos. Only `testMain.c` is ESC/POS. |
|
||||
| `makefile` | Cross-compiles with `arm-linux-gcc`. The shipped binaries were not built with it. |
|
||||
| `README.txt` | Chinese, GBK encoded. Decode with `iconv -f GB18030 -t UTF-8`. The `.c` and `.h` files are UTF-8. |
|
||||
|
||||
Reasons not to use it:
|
||||
|
||||
- Global state only. One socket descriptor (`socketFd`), one command buffer (`gCmdBuf`), no printer handle. It cannot drive two printers concurrently; `PrintServer` does.
|
||||
- The shipped `.so` would not load on the Raspberry Pi. It would need cross-compiling from source, then P/Invoke from .NET, for zero functional gain over what `EpsonPrinter` already sends.
|
||||
- Roughly half the API is CPCL and TSPL label-printer commands. Irrelevant for receipts.
|
||||
- `pos-sdk\POS-SDK\POS-SDK_V1219\` is a Windows `POS_SDK.dll` with a WinForms P/Invoke demo (`Testing\C#\TestSDKCSharp\Form1.cs`). Irrelevant for the Linux service. Its manual (`POS_SDK Development Manual V1.4_Neutral.pdf`) is in English and is the best command reference in the package.
|
||||
- Development stopped around 2016 to 2019 for the Linux part.
|
||||
|
||||
The firmware family is "SPRT" (the SDK is named `HWISPRTPrinter`, log prefix `qzf`). Treat the printer as a SPRT-derived ESC/POS clone.
|
||||
|
||||
## Printer facts from the manuals
|
||||
|
||||
| | POS895 | POS896 |
|
||||
|---|---|---|
|
||||
| Paper | 80 mm (79.5 ±0.5), 72 mm printable | same |
|
||||
| Resolution | 203 dpi, 8 dots/mm, 576 dots/line | same |
|
||||
| Fonts | Font A 12x24 (48 columns), Font B 9x17, CJK 24x24 | same |
|
||||
| Interfaces | Ud: USB. UEd: USB + Ethernet | UBTDM/UBTMM: USB + Bluetooth + drawer. UE: USB + serial + Ethernet + drawer |
|
||||
| Command set | ESC/POS | ESC/POS |
|
||||
| Barcodes | UPC/EAN/CODE39/ITF/CODABAR/CODE93/CODE128, PDF417, QR, DataMatrix | same |
|
||||
| Speed | 160 mm/s | 260 mm/s |
|
||||
| DHCP | Off by default, auto-enables if the static IP cannot reach the router | same |
|
||||
| Factory reset | `1B 09 1B 92 FF 01` | |
|
||||
| Hex dump mode | Open cover, press FEED twice within 3 s, close cover. Exit: open cover, press FEED three times. | |
|
||||
| Self-test | Press FEED twice within 3 s. Prints model, firmware, interface, default code page. | |
|
||||
|
||||
The POS896 buffers up to 45 KB of unprinted data across a paper-out and prints it after reloading. Worth knowing when reasoning about retries.
|
||||
|
||||
## ESC/POS commands the SDK emits (so the firmware certainly supports them)
|
||||
|
||||
| Function | Bytes |
|
||||
|---|---|
|
||||
| Initialize | `1B 40` |
|
||||
| Feed n lines | `1B 64 n` |
|
||||
| Cut, full / partial | `1D 56 00` / `1D 56 01` (SDK always feeds 3 lines first) |
|
||||
| Line spacing | `1B 33 n` |
|
||||
| Alignment | `1B 61 n` |
|
||||
| Left margin | `1D 4C nL nH` |
|
||||
| Font mode (font B, bold, double width, double height, underline) | `1B 21 n` (bits 0, 3, 5, 4, 7) |
|
||||
| Bit image | `1B 2A m nL nH data`, m in {0, 1, 32, 33} |
|
||||
| Real-time status | `10 04 n`, n in 1..4, one-byte reply |
|
||||
| Printer ID | `1D 49 42` (GS I 66, returns `"_" + factory name` as a string) |
|
||||
| Cash drawer | `1B 70 m t1 t2` |
|
||||
| Inverse print | `1D 42 n` |
|
||||
| Rotate / upside-down | `1B 56 n` / `1B 7B n` |
|
||||
| 1D barcode | `1D 6B ...` |
|
||||
| 2D barcode | `1D 5A ...` (PDF417 / DataMatrix / QR selector) |
|
||||
| Proprietary line bitmap | `1D 44 01`, then per non-blank row `15 <skip> 16 <bytes> <inverted row data>`, then `1D 44 00` |
|
||||
|
||||
Status byte interpretation in the SDK (matches Epson's DLE EOT layout and our parsers in `EpsonPrinterStatus.cs`):
|
||||
|
||||
| Query | Fixed bits | Meaning of set bit |
|
||||
|---|---|---|
|
||||
| `10 04 01` printer status | `0x12` must be set | `0x04` drawer high |
|
||||
| `10 04 02` offline status | `0x12` | `0x04` cover open |
|
||||
| `10 04 03` error status | `0x12` | `0x08` cutter error, `0x20` unrecoverable, `0x40` auto-recoverable |
|
||||
| `10 04 04` paper status | `0x12` | `0x40` paper end |
|
||||
|
||||
Code pages: the SDK manual's code page table (TSPL section, but the same family) has index 18 = CP852, matching Epson numbering. Our `EpsonCommands.CP852Encoding` sends `1B 74 12`, so Latin-2 text is probably fine. Confirm on the self-test page.
|
||||
|
||||
## Compatibility of what `TM_T30IIITranslated` sends today
|
||||
|
||||
| Command | Our use | Assessment |
|
||||
|---|---|---|
|
||||
| ESC @, GS V, ESC 3, ESC 2 | init, cut, line spacing | Fine, SDK emits them. |
|
||||
| ESC d n | feed | **Appears to be ignored.** Feed with literal LF bytes. |
|
||||
| DLE EOT 1..4 | status | **Confirmed.** Identical layout, `0x12` fixed bits set. |
|
||||
| GS ! n (`SetFontSizeAsync`) | magnification | **Confirmed** at width 1x/2x/3x, height 2x. |
|
||||
| ESC E (`SetEmphasized`) | bold | Standard, likely fine. SDK uses ESC ! bit 3 instead. |
|
||||
| ESC t 18 + CP852 | umlauts | **Confirmed.** ESC t honoured; 18=CP852, 2=CP850, 0=CP437 all render. |
|
||||
| ESC $ (`SetAbsolutePrintPosition`) | logo offset 100 | Unverified. SDK uses GS L margins instead. |
|
||||
| GS ( L fn 112 / fn 50 (`LoadImageAsync` + `PrintLoadedImage`) | logo | **Confirmed unsupported.** Prints nothing. |
|
||||
| ESC * 33 (`PrintImageBitModeAsync`) | logo | **Confirmed.** Full 576-dot width. Use this. |
|
||||
| GS v 0 | logo | **Confirmed** working too, but redundant. Do not implement. |
|
||||
| GS I 1 (`GetPrinterIdAsync`) | model byte | **Confirmed `0x20`.** No collision with 0x01/0x0D/0x13. |
|
||||
| ESC r (`SetRedColor`) | two-colour | Not applicable, monochrome printer. The T30III adapter ignores `IsRed` anyway. |
|
||||
|
||||
## SPRT discovery protocol
|
||||
|
||||
Captured with `pktmon` while running `EasySet Tool.exe` on the printer's segment,
|
||||
then reimplemented in `Pos895Test` and confirmed to return byte-identical results
|
||||
(same MAC, same serial) as the vendor tool.
|
||||
|
||||
**Query** - UDP broadcast to `255.255.255.255:3289`, sent **from source port 9100**, 13 bytes:
|
||||
|
||||
```
|
||||
53 50 52 54 2D 49 6E 66 6F 72 00 00 0D "SPRT-Infor" + 00 00 0D
|
||||
```
|
||||
|
||||
**Reply** - from `<printer>:3289` back to `<querier>:9100`, 39 bytes of ASCII:
|
||||
|
||||
```
|
||||
28-0e-8b-3d-ca-f1 2608120478
|
||||
|<--- MAC, 17 --->||<- 12 sp ->||<- serial, 10 ->|
|
||||
```
|
||||
|
||||
The printer's address is the reply's source IP. The payload carries MAC and
|
||||
serial only, no model name, so model identification remains a separate TCP-side
|
||||
step and cannot be folded into discovery.
|
||||
|
||||
- Port 3289 is the same UDP port Epson ENPC uses, so a composite
|
||||
`IDiscoveryService` can reuse the socket it already has and dispatch on the
|
||||
shape of the reply.
|
||||
- The querying machine was `10.1.250.71` and the printer `10.1.252.219`:
|
||||
different /24s on one flat segment. A /24 sweep would have missed this printer,
|
||||
which is why the capture route beat the port-sweep route here.
|
||||
- Unverified: whether the firmware replies to the sender's source port or
|
||||
hard-codes 9100. `Pos895Test` binds 9100 to match EasySet.
|
||||
- Unverified: reply framing when more than one printer answers.
|
||||
|
||||
## What breaks today if a POS895 is plugged into the network
|
||||
|
||||
1. **Discovery.** `PrintServerBootstrapper` binds `DiscoveryService`, which broadcasts Epson's proprietary `EPSONQ` packet on UDP 3289. The POS895 never answers it (confirmed: an `EPSONQ` broadcast went unanswered in the same capture that the printer did answer SPRT discovery in). `PrintServer.RegisterPrinter` is never called, so `SubmitJob` logs "Printer not found" and reports the job Failed. `JamesDiscoveredPrintersReceiver` never tells the backend about the printer, so it cannot be assigned to a work area.
|
||||
2. **Identification.** `EpsonPrinter.GetPrinterIdAsync` sends `1D 49 01` and reads one byte. Three places dispatch on that byte: `PrinterWrapperFactory.CreatePrinterFromId`, `ReceiptConverterFactory.Create` (48 vs 33 columns), and `DiscoveryService.GetModelNameFromId`. If the printer answers nothing, the read waits the full connection timeout (10 s), returns null, `printerId.Value` throws, the job fails with `PrintErrorType.Other`, and `PrinterQueue` retries three times. If it answers `0x01` it is silently treated as a TM-T30III and mostly works by accident, with a 384 px logo at offset 100 on a 576-dot line.
|
||||
3. **Paper-out logic.** `EpsonPrintService.PrintAsync` maps `!PrinterStatus.IsOnline` (DLE EOT 1 bit 3) to `OutOfPaper`. Whether this firmware sets that bit on paper-out is unverified. DLE EOT 4 bit 6 is what the SDK uses.
|
||||
|
||||
## Work items
|
||||
|
||||
1. **Hardware probe (do first, half a day).** With the printer in hex dump mode and then in normal mode, send and record replies for:
|
||||
- `1D 49 01`, `1D 49 41`, `1D 49 42`, `1D 49 43` (what identifies the model, and how many bytes come back)
|
||||
- `10 04 01..04` in normal, paper-out, cover-open states
|
||||
- `1B 74 12` followed by `ä ö ü ß č` bytes in CP852
|
||||
- `1D 21 00 / 11 / 10 / 01` text sizes
|
||||
- `1B 24 nL nH` then text (does absolute positioning work)
|
||||
- a logo via GS ( L, via ESC * 33, and via `1D 76 30`
|
||||
- `EpsonDiagnostics.RunFullDiagnosticsAsync` output for the record
|
||||
2. **Adapter.** New `Inspectron.Epson/PrintServer/Printers/POS895.cs` implementing `IPrinter`, cloned from `TM_T30IIITranslated`: logo width 576, centred offset `(576 - width) / 2`, image via ESC * or GS v 0, delays tuned. One case each in `PrinterWrapperFactory`, `ReceiptConverterFactory` (48 columns), `DiscoveryService.GetModelNameFromId`.
|
||||
3. **Identification hardening in `EpsonPrinter`.** Shorter read timeout for GS I, fall back to the GS I 67 model-name string, or to a per-IP configured model, when the byte is missing or collides with an Epson ID.
|
||||
4. **Discovery for non-Epson printers.** Solved: the vendor SPRT protocol is documented above and reimplemented in `Pos895Test`, confirmed against the printer. Remaining work is a `SprtDiscoveryService` implementing `IDiscoveryService`, and a composite service that merges its results with ENPC. Both probes go to UDP 3289, so they can share a socket. `PrinterDiscoveryBackgroundTask` needs no change. Now well under the 1 to 2 days originally estimated.
|
||||
5. **Emulation and tests.** Add a 576 px `HtmlPrinter` entry with the new ID to the emulation dictionary in `PrintServerBootstrapper`, and EpsonTest cases, so receipt layouts can be checked without hardware.
|
||||
6. ~~**GS v 0 raster** in `EpsonCommands`~~ Not needed: `GS ( L` is rejected but `ESC *` works and is already implemented.
|
||||
7. **CLAUDE.md updates** once the adapter exists: printer ID byte, image width, discovery method.
|
||||
|
||||
## Answered on hardware, 2026-09-03
|
||||
|
||||
Probed through an AnyDesk TCP tunnel to the printer's port 9100, plus the SPRT
|
||||
discovery probe run on the printer's own segment. Tooling in `Pos895Test/`.
|
||||
|
||||
- **Does the printer answer any UDP discovery?** Yes, SPRT on UDP 3289, above.
|
||||
- **Does DLE EOT work?** Yes. All four queries answered, exact Epson bit layout,
|
||||
`0x12` fixed bits set on every reply. `EpsonPrinterStatus.cs` parses this
|
||||
printer unmodified, so no custom status parser is needed.
|
||||
- **Font A column count?** 48 on the 576-dot line, as the manuals claim. The
|
||||
existing 48-column converters apply unchanged.
|
||||
- **Does `GS !` magnification work?** Yes, verified at width 1x/2x/3x, height 2x,
|
||||
so `SetFontSizeAsync` can pass magnification through without clamping.
|
||||
- **`ESC !`?** Selects Font A with `0x00`.
|
||||
- **`ESC d n`?** Appears to be ignored. Feed with literal LF bytes.
|
||||
- **`GS I` family?** `GS I 1` = `0x20`, unique. `GS I 2` = `0x02`,
|
||||
`GS I 3` = `0x64`, `GS I 65` = `"N-656 1.+0"`, `GS I 69` = `"_English"`.
|
||||
`GS I 66` (maker) and `GS I 67` (model name) are **blank**, so the model-name
|
||||
fallback the work items suggested is useless here. `GS I 68` returns the
|
||||
serial and it matches the SPRT discovery serial exactly, which makes it a
|
||||
usable correlation key between a discovered and a connected printer.
|
||||
- **Raster graphics?** `GS ( L` prints nothing. `ESC * 33` and `GS v 0` both
|
||||
render correctly across the full 576 dots. Use `PrintImageBitModeAsync`.
|
||||
- **Code pages?** `ESC t` is honoured and index 18 really is CP852. Index 2
|
||||
(CP850) and 0 (CP437) also render correctly.
|
||||
|
||||
Note CP852 has no `e`-grave, `a`-grave, `u`-grave, `e`-circumflex, `i`-grave
|
||||
or `o`-grave. `EpsonPrinter.PrintTextAsync` hardcodes CP852 for every printer,
|
||||
so French and Italian accents are already dropped fleet-wide, Epsons included.
|
||||
Now known to be fixable by selecting CP850, but that is a product-wide change
|
||||
with its own blast radius, not part of POS895 support.
|
||||
|
||||
## Open questions that only hardware can answer
|
||||
|
||||
- Does DLE EOT 1 bit 3 go high on paper-out? The status queries answer, but
|
||||
paper-out was never induced.
|
||||
- Font B, and its claimed 64 columns.
|
||||
- Does the SPRT reply go to the sender's port, or is 9100 hard-coded?
|
||||
- Does the POS896 report the same `GS I 1` = `0x20`? Assumed yes, same spec
|
||||
sheet and same command set, but untested.
|
||||
14
Pos895Test/Pos895Test.csproj
Normal file
14
Pos895Test/Pos895Test.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
235
Pos895Test/Program.cs
Normal file
235
Pos895Test/Program.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Inspectron.Epson;
|
||||
|
||||
// POS895 discovery probe.
|
||||
//
|
||||
// The POS895 does not answer Epson's ENPC broadcast, but it does answer the
|
||||
// vendor's own SPRT discovery, which the EasySet tool uses. Protocol, taken from
|
||||
// a packet capture of EasySet on the printer's segment:
|
||||
//
|
||||
// query UDP broadcast 255.255.255.255:3289, sent FROM source port 9100
|
||||
// 53 50 52 54 2D 49 6E 66 6F 72 00 00 0D "SPRT-Infor\0\0\r"
|
||||
//
|
||||
// reply from <printer>:3289 back to <querier>:9100, 39 bytes of ASCII
|
||||
// "28-0e-8b-3d-ca-f1 2608120478"
|
||||
// |<-- MAC, 17 -->||<- 12 sp ->||<- serial, 10 ->|
|
||||
//
|
||||
// The printer's address is the reply's source IP; the payload carries only MAC
|
||||
// and serial, no model name. Note 3289 is the same UDP port Epson ENPC uses, so
|
||||
// a composite IDiscoveryService can reuse the socket it already has.
|
||||
//
|
||||
// All settings are the constants below. Edit them here; there are no arguments.
|
||||
|
||||
const string ProbeMagic = "SPRT-Infor";
|
||||
const int DiscoveryPort = 3289;
|
||||
const int ReplyPort = 9100;
|
||||
const int ListenSeconds = 3;
|
||||
|
||||
// EasySet sends from source port 9100 and the reply comes back to 9100. Whether
|
||||
// the firmware honours the sender's port or hard-codes 9100 is unknown from a
|
||||
// single capture. Set this false to bind an ephemeral port instead: if replies
|
||||
// still arrive, the firmware replies to the sender; if they stop, 9100 is
|
||||
// hard-coded and any implementation must bind it.
|
||||
bool bindSourcePort9100 = true;
|
||||
|
||||
byte[] probe = BuildProbe(ProbeMagic);
|
||||
|
||||
Console.WriteLine("POS895 / SPRT discovery probe");
|
||||
Console.WriteLine($" probe -> 255.255.255.255:{DiscoveryPort} ({probe.Length} bytes)");
|
||||
Console.WriteLine(Describe(probe));
|
||||
Console.WriteLine();
|
||||
|
||||
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
udp.EnableBroadcast = true;
|
||||
|
||||
try
|
||||
{
|
||||
udp.Client.Bind(new IPEndPoint(IPAddress.Any, bindSourcePort9100 ? ReplyPort : 0));
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[FAIL] Could not bind UDP {ReplyPort}: {ex.Message}");
|
||||
Console.Error.WriteLine(" Something else holds it, or this needs elevation.");
|
||||
Console.Error.WriteLine(" Set bindSourcePort9100 = false to try an ephemeral port.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int localPort = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
|
||||
Console.WriteLine($" listening on UDP {localPort} for {ListenSeconds}s");
|
||||
Console.WriteLine();
|
||||
|
||||
await udp.SendAsync(probe, probe.Length, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort));
|
||||
|
||||
var found = new Dictionary<string, string>();
|
||||
DateTime deadline = DateTime.UtcNow.AddSeconds(ListenSeconds);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(deadline - DateTime.UtcNow);
|
||||
UdpReceiveResult res;
|
||||
try
|
||||
{
|
||||
res = await udp.ReceiveAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string ip = res.RemoteEndPoint.Address.ToString();
|
||||
var parsed = ParseReply(res.Buffer);
|
||||
|
||||
if (parsed is null)
|
||||
{
|
||||
Console.WriteLine($" {ip}:{res.RemoteEndPoint.Port} unrecognised, {res.Buffer.Length} bytes");
|
||||
Console.WriteLine(Describe(res.Buffer));
|
||||
continue;
|
||||
}
|
||||
|
||||
(string mac, string serial) = parsed.Value;
|
||||
found[ip] = mac;
|
||||
Console.WriteLine($" FOUND {ip,-15} mac {mac} serial {serial}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
if (found.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[--] No replies.");
|
||||
Console.WriteLine(" - is this machine on the printer's broadcast domain?");
|
||||
Console.WriteLine(" - does the firewall allow inbound UDP on this port?");
|
||||
Console.WriteLine($" - try flipping bindSourcePort9100 (currently {bindSourcePort9100}).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[OK] {found.Count} printer(s) answered SPRT discovery.");
|
||||
return 0;
|
||||
|
||||
// "SPRT-Infor" followed by 00 00 0D. The trailing 0x0D is also the total length
|
||||
// of the datagram, which may or may not be a coincidence.
|
||||
static byte[] BuildProbe(string magic)
|
||||
{
|
||||
var p = new List<byte>(Encoding.ASCII.GetBytes(magic));
|
||||
p.AddRange(new byte[] { 0x00, 0x00, 0x0D });
|
||||
return p.ToArray();
|
||||
}
|
||||
|
||||
// 17 ASCII bytes of dashed lowercase MAC, space padding, then the serial.
|
||||
static (string Mac, string Serial)? ParseReply(byte[] payload)
|
||||
{
|
||||
if (payload.Length < 17)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string text = Encoding.ASCII.GetString(payload);
|
||||
string mac = text[..17];
|
||||
|
||||
bool looksLikeMac = mac.Split('-') is { Length: 6 } parts
|
||||
&& parts.All(p => p.Length == 2 && p.All(Uri.IsHexDigit));
|
||||
|
||||
return looksLikeMac ? (mac, text[17..].Trim()) : null;
|
||||
}
|
||||
|
||||
// Hex plus a printable-ASCII gutter.
|
||||
static string Describe(byte[] bytes)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int offset = 0; offset < bytes.Length; offset += 16)
|
||||
{
|
||||
int count = Math.Min(16, bytes.Length - offset);
|
||||
sb.Append($" {offset:X4} ");
|
||||
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
sb.Append(i < count ? $"{bytes[offset + i]:X2} " : " ");
|
||||
}
|
||||
|
||||
sb.Append(' ');
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
byte b = bytes[offset + i];
|
||||
sb.Append(b >= 0x20 && b < 0x7F ? (char)b : '.');
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
PRINT TEST -- disabled.
|
||||
|
||||
Confirmed working against the printer through the AnyDesk tunnel on
|
||||
127.0.0.1:8888. Font A is 48 columns on the 576-dot line, and GS !
|
||||
magnification works at width 1x/2x/3x with height 2x. ESC d n appears
|
||||
to be ignored, hence the literal LF bytes before the cut.
|
||||
|
||||
To re-enable, move this code above the discovery section.
|
||||
|
||||
const string PrinterIp = "127.0.0.1";
|
||||
const int PrinterPort = 8888;
|
||||
const int ConnectTimeoutSeconds = 5;
|
||||
|
||||
const int FontAColumns = 48;
|
||||
const int HeightMagnification = 2;
|
||||
int[] widths = { 1, 2, 3 };
|
||||
const int BlankLinesAfterText = 10;
|
||||
bool useRawAscii = false;
|
||||
|
||||
byte[] job = BuildJob(FontAColumns, widths, HeightMagnification, BlankLinesAfterText, useRawAscii);
|
||||
|
||||
Console.WriteLine("Byte sequence:");
|
||||
Console.WriteLine(Describe(job));
|
||||
|
||||
await using var printer = new EpsonPrinter();
|
||||
await printer.ConnectAsync(PrinterIp, PrinterPort, ConnectTimeoutSeconds);
|
||||
await printer.SendRawCommandAsync(job);
|
||||
|
||||
static byte[] BuildJob(int fontACols, int[] widths, int heightMag, int blankLines, bool rawAscii)
|
||||
{
|
||||
var job = new List<byte> { 0x1B, 0x40 }; // ESC @ initialize
|
||||
|
||||
if (!rawAscii)
|
||||
{
|
||||
job.AddRange(new byte[] { 0x1B, 0x74, 0x12 }); // ESC t 18 code page 852
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1B, 0x21, 0x00 }); // ESC ! 0 Font A
|
||||
|
||||
foreach (int w in widths)
|
||||
{
|
||||
job.AddRange(new byte[] { 0x1D, 0x21, SizeByte(w, heightMag) });
|
||||
job.AddRange(Encoding.ASCII.GetBytes(Ruler(fontACols / w)));
|
||||
job.Add(0x0A);
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1D, 0x21, 0x00 }); // GS ! 0 back to 1x1
|
||||
|
||||
for (int i = 0; i < blankLines; i++)
|
||||
{
|
||||
job.Add(0x0A);
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1D, 0x56, 0x00 }); // GS V 0 full cut
|
||||
return job.ToArray();
|
||||
}
|
||||
|
||||
// GS ! n: bits 4-6 width magnification - 1, bits 0-2 height - 1.
|
||||
static byte SizeByte(int width, int height) =>
|
||||
(byte)(((height - 1) & 0x07) | (((width - 1) & 0x07) << 4));
|
||||
|
||||
static string Ruler(int columns)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
while (sb.Length < columns)
|
||||
{
|
||||
sb.Append("|---");
|
||||
}
|
||||
|
||||
return sb.ToString(0, columns);
|
||||
}
|
||||
|
||||
===================================================================== */
|
||||
25
docs/captures/README.md
Normal file
25
docs/captures/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Packet captures
|
||||
|
||||
## easyset.pcapng / easyset.etl
|
||||
|
||||
`pktmon` capture taken on 2026-09-03 on a machine on the POS895's network
|
||||
segment, while running the vendor's `EasySet Tool.exe` and clicking its scan.
|
||||
|
||||
Taken to reverse engineer the vendor discovery protocol, since the POS895 does
|
||||
not answer Epson's ENPC broadcast and the EasySet binary yields no readable
|
||||
strings. The resulting wire format is written up under "SPRT discovery protocol"
|
||||
in `POS895_POS896_integration_notes.md` and implemented in `Pos895Test`.
|
||||
|
||||
`easyset.etl` is the raw pktmon output; `easyset.pcapng` is the converted form
|
||||
and the one worth opening.
|
||||
|
||||
What is in it:
|
||||
|
||||
- `10.1.250.71:9100 -> 255.255.255.255:3289` the `SPRT-Infor` discovery query
|
||||
- `10.1.252.219:3289 -> 10.1.250.71:9100` the printer's MAC + serial reply
|
||||
- `10.1.252.148:58165 -> 255.255.255.255:3289` an Epson `EPSONQ` broadcast that
|
||||
the POS895 does not answer, which is the point
|
||||
- unrelated noise: Ubiquiti AP beacons on UDP 10001, SSDP, mDNS, NetBIOS
|
||||
|
||||
Note pktmon records each frame several times, once per stack layer, so every
|
||||
packet appears about six times.
|
||||
BIN
docs/captures/easyset.etl
Normal file
BIN
docs/captures/easyset.etl
Normal file
Binary file not shown.
BIN
docs/captures/easyset.pcapng
Normal file
BIN
docs/captures/easyset.pcapng
Normal file
Binary file not shown.
1279
docs/superpowers/plans/2026-09-03-pos895-integration.md
Normal file
1279
docs/superpowers/plans/2026-09-03-pos895-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
178
docs/superpowers/specs/2026-09-03-pos895-integration-design.md
Normal file
178
docs/superpowers/specs/2026-09-03-pos895-integration-design.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# POS895 integration design
|
||||
|
||||
Date: 2026-09-03. Status: design complete, awaiting review.
|
||||
|
||||
Adds POS895 / POS896 support to the print service. Every decision here rests on
|
||||
hardware probes run against a real POS895 through an AnyDesk TCP tunnel; the raw
|
||||
findings live in `POS895_POS896_integration_notes.md` and the tooling in
|
||||
`Pos895Test/`.
|
||||
|
||||
## Goal
|
||||
|
||||
A POS895 on the network is discovered automatically, reported to the backend so
|
||||
it can be assigned to a work area, and prints every existing receipt type
|
||||
correctly at 48 columns with a logo.
|
||||
|
||||
## What the hardware told us
|
||||
|
||||
These four results shape the whole design.
|
||||
|
||||
| Probe | Result | Consequence |
|
||||
|---|---|---|
|
||||
| `GS I 1` | `0x20`, no collision with `0x01`/`0x0D`/`0x13` | Model identity keeps working as a `byte`. No resolver, no discovery-seeded cache. |
|
||||
| SPRT discovery | `SPRT-Infor` on UDP 3289 answers with MAC + serial | Discovery is a new `IDiscoveryService`, not a TCP port sweep. |
|
||||
| `GS ( L` | prints nothing; `ESC *` and `GS v 0` both work | The adapter must not use `LoadImageAsync`. |
|
||||
| `ESC d n` | ignored | The adapter must feed with `LF`. |
|
||||
|
||||
The `GS I 1` result is what keeps this small. Had it returned nothing or `0x01`,
|
||||
the POS895 would have been indistinguishable from a TM-T30III by probe, and
|
||||
model identity would have had to flow from discovery instead - a much larger
|
||||
change to `EpsonPrintService`.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Discovery
|
||||
|
||||
```
|
||||
IDiscoveryService
|
||||
├── DiscoveryService ENPC / EPSONQ unchanged
|
||||
├── SprtDiscoveryService SPRT-Infor new
|
||||
└── CompositeDiscoveryService runs both, merges new
|
||||
```
|
||||
|
||||
**`SprtDiscoveryService`** - `Inspectron.Epson/SprtDiscoveryService.cs`.
|
||||
Promotes the probe in `Pos895Test` into the SDK. Broadcasts
|
||||
`"SPRT-Infor" 00 00 0D` to `255.255.255.255:3289` from UDP source port 9100,
|
||||
listens for the timeout, and maps each 39-byte reply to a `DiscoveredPrinter`:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `IPAddress` | reply's source IP |
|
||||
| `MACAddress` | payload bytes 0..16, dashed lowercase |
|
||||
| `Serial` | payload bytes 29..38, trimmed |
|
||||
| `ModelName` | `"POS895"` |
|
||||
| `Port` | 9100 |
|
||||
| `ServiceType` | `"SPRT"` |
|
||||
| `RawResponse` | the 39 bytes |
|
||||
|
||||
Replies that do not parse as a 17-character dashed MAC are logged and skipped,
|
||||
never thrown.
|
||||
|
||||
**Binding UDP 9100 can fail** when another process holds it, and the broadcast
|
||||
send can fail too. On `SocketException` the service logs a warning and
|
||||
**rethrows** - it does not swallow the fault into an empty list. A silent empty
|
||||
list is indistinguishable from "no SPRT printers on this network", and
|
||||
`CompositeDiscoveryService` is the layer that needs to tell those apart, not
|
||||
this service.
|
||||
|
||||
**`CompositeDiscoveryService`** - takes `IEnumerable<IDiscoveryService>`, runs
|
||||
them concurrently on the shared timeout, merges results by `IPAddress` with
|
||||
first-wins. It does not pass the `onPrinterDiscovered` callback to its children;
|
||||
instead, it raises the caller's callback itself after all children finish,
|
||||
once per unique IP, so a printer answering two discovery protocols notifies only once.
|
||||
It absorbs a single child's failure (logs it, keeps the other children's
|
||||
results) but rethrows when every child fails, since that means discovery
|
||||
itself is broken rather than that the network simply has no printers on it.
|
||||
Discovery still degrades gracefully overall - it just does so at the composite
|
||||
layer, which is the only layer that can see all children and make that call.
|
||||
|
||||
**Wiring** - `PrintServerBootstrapper.cs:77` changes from
|
||||
`Bind<IDiscoveryService>().To<DiscoveryService>()` to the composite over both
|
||||
children. `PrinterDiscoveryBackgroundTask` is untouched: its 3s timeout and 30s
|
||||
interval still hold because the children run concurrently.
|
||||
|
||||
### 2. `DiscoveredPrinter.Serial`
|
||||
|
||||
A new nullable `string Serial` property. SPRT discovery returns a serial, and
|
||||
`GS I 68` returns the same value over TCP, making it the one field that can
|
||||
correlate a discovered printer with a connected one. ENPC discovery leaves it
|
||||
null. `JamesDiscoveredPrintersReceiver` is unchanged - it does not send the
|
||||
field upstream, and adding it there is out of scope.
|
||||
|
||||
### 3. Identification
|
||||
|
||||
No change to the flow in `EpsonPrintService.PrintAsync`; `GS I 1` returns `0x20`
|
||||
and the existing code path works.
|
||||
|
||||
One defensive fix: `printerId` is a `byte?` dereferenced with `.Value` twice,
|
||||
at `EpsonPrintService.cs:48` and `:91`. A printer that does not answer `GS I 1`
|
||||
throws a `NullReferenceException` into the generic catch, which reports
|
||||
`PrintErrorType.Other` and burns three `PrinterQueue` retries with no usable log
|
||||
line. Add one null check after the call at `:38` that logs the IP and returns
|
||||
`PrintResult.Fail(PrintErrorType.Other)`, which covers both dereferences. This
|
||||
printer came within one probe of triggering it.
|
||||
|
||||
### 4. `POS895` printer adapter
|
||||
|
||||
`Inspectron.Epson/PrintServer/Printers/POS895.cs`, implementing `IPrinter`,
|
||||
cloned from `TM-T30IIITranslated.cs`. Three differences, each backed by a probe:
|
||||
|
||||
| Concern | TM-T30III | POS895 | Why |
|
||||
|---|---|---|---|
|
||||
| Logo | `LoadImageAsync(path, 384)` (`GS ( L`) | `PrintImageBitModeAsync(path, DoubleDensity24Dot, 576)` (`ESC *`) | `GS ( L` prints nothing on this firmware |
|
||||
| Logo position | `SetAbsolutePrintPosition(100)` | none, print at 0 across the full width | avoids `ESC $`, which is still unverified |
|
||||
| Feed before cut | `FeedLinesAsync(10)` (`ESC d`) | 10 × `LF` | `ESC d` is ignored, so the receipt would be cut through its last line |
|
||||
|
||||
Everything else - line spacing, `GS !` sizing, `ESC E` bold - carries over
|
||||
unchanged. `GS !` is confirmed working at width 1x/2x/3x and height 2x, so
|
||||
`SetFontSizeAsync` needs no clamping.
|
||||
|
||||
The adapter needs a small `FeedLinesAsync`-equivalent that emits `LF` bytes,
|
||||
since `IEpsonPrinter.FeedLinesAsync` is hardcoded to `ESC d`. Implement it in
|
||||
the adapter using `SendRawCommandAsync`, not by changing `EpsonPrinter` - the
|
||||
Epson models rely on `ESC d` working.
|
||||
|
||||
### 5. Three registrations
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `PrinterWrapperFactory.CreatePrinterFromId` | `case 0x20: return new POS895(epsonPrinter);` |
|
||||
| `DiscoveryService.GetModelNameFromId` | `0x20 => "POS895"` |
|
||||
| `ReceiptConverterFactory.Create` | add `0x20` to the **WorkareaTicket** switch, 48/24 |
|
||||
|
||||
The third is the easy one to miss. That switch defaults to `33/20`, the narrow
|
||||
TM-U220 layout, so without it a POS895 prints kitchen tickets at 33 columns on a
|
||||
48-column head - wrong, but not obviously broken. Every other receipt type
|
||||
already hardcodes 48/24 and needs no change.
|
||||
|
||||
`NextCourse` stays at `33/20` for all printers. It looks like a pre-existing bug,
|
||||
but changing it alters TM-T30III behaviour and is deliberately out of scope.
|
||||
|
||||
### 6. Emulation
|
||||
|
||||
Add a 576px `HtmlPrinter` entry under ID `0x20` to the emulation dictionary in
|
||||
`PrintServerBootstrapper`, so receipt layouts can be checked without hardware.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit-testable, no hardware:**
|
||||
|
||||
- SPRT reply parsing: valid 39-byte reply, short reply, wrong-shape MAC,
|
||||
trailing whitespace in the serial.
|
||||
- `CompositeDiscoveryService`: merge by IP, first-wins on duplicates, one child
|
||||
throwing does not suppress the other, callbacks forwarded.
|
||||
|
||||
**Hardware, via `Pos895Test`:** discovery against the real printer, and a receipt
|
||||
of each type through the full `PrintAsync` path with a logo.
|
||||
|
||||
**Emulation:** existing `EpsonTest` receipt-layout cases extended to ID `0x20`.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| POS896 may not report `0x20` | Untestable without the hardware. If it differs, it is one more `case` in the same three switches. |
|
||||
| SPRT may hard-code reply port 9100 | We bind 9100, which is proven to work. Only matters if the port is ever contended. |
|
||||
| Logo rendering quality at 576 via `ESC *` | The command is confirmed working at full width; only dithering quality is unverified. |
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **The CP852 code page problem.** `EpsonPrinter.PrintTextAsync` hardcodes CP852
|
||||
fleet-wide, and CP852 cannot encode `e`-grave, `a`-grave, `u`-grave,
|
||||
`e`-circumflex, `i`-grave or `o`-grave. French and Italian accents are dropped
|
||||
on every printer today, Epsons included. Now known fixable via `ESC t 2`
|
||||
(CP850, confirmed working), but it is a product-wide change with its own blast
|
||||
radius and deserves its own spec.
|
||||
- `NextCourse` hardcoded to 33 columns.
|
||||
- USB-attached POS895 operation - the pipeline keys on an IP address throughout.
|
||||
- Sending `Serial` upstream from `JamesDiscoveredPrintersReceiver`.
|
||||
Reference in New Issue
Block a user