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:
EugeneTes
2026-09-04 08:31:22 +02:00
40 changed files with 3472 additions and 349 deletions

4
.gitattributes vendored
View File

@@ -61,3 +61,7 @@
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
# packet captures
*.pcapng binary
*.etl binary

View File

@@ -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/`

View File

@@ -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();

View File

@@ -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");
}
}

View File

@@ -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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View 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");
}
}

View 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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -20,7 +20,6 @@
<ItemGroup>
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
<ProjectReference Include="..\EpsonPrintService\EpsonPrintService.csproj" />
</ItemGroup>
</Project>

View 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);
}
}

View 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 })!;
}
}

View 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;
}

View File

@@ -1,7 +0,0 @@
namespace Inspectron.Epson.Tests.Sanity;
public class SanityTests
{
[Fact]
public void Truth_is_true() => Assert.True(true);
}

View 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)));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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" />

View 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;
}
}

View File

@@ -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; }

View File

@@ -144,6 +144,7 @@ public class DiscoveryService : IDiscoveryService
0x01 => "TM-T30III",
0x0D => "TM-U220II",
0x13 => "TM-U220II",
0x20 => "POS895",
_ => $"Unknown (0x{printerId:X2})"
};
}

View File

@@ -608,26 +608,41 @@ public class EpsonPrinter : IEpsonPrinter
_logger?.LogDebug("Printing in {SliceCount} slices of {BitsPerSlice} dots each", totalSlices, bitsPerSlice);
// Print each horizontal slice
for (int slice = 0; slice < totalSlices; slice++)
// 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
{
int sliceOffset = slice * finalWidth * bytesPerColumn;
int sliceLength = finalWidth * bytesPerColumn;
var sliceData = new byte[sliceLength];
Array.Copy(columnData, sliceOffset, sliceData, 0, sliceLength);
// Create and send ESC * command for this slice
var command = EpsonCommands.CreateBitImageCommand(mode, sliceData, finalWidth);
await SendRawAsync(command);
// Move to next line after each slice (except the last one)
if (slice < totalSlices - 1)
// Print each horizontal slice
for (int slice = 0; slice < totalSlices; slice++)
{
await SendRawAsync(EpsonCommands.LineFeed);
int sliceOffset = slice * finalWidth * bytesPerColumn;
int sliceLength = finalWidth * bytesPerColumn;
var sliceData = new byte[sliceLength];
Array.Copy(columnData, sliceOffset, sliceData, 0, sliceLength);
// Create and send ESC * command for this slice
var command = EpsonCommands.CreateBitImageCommand(mode, sliceData, finalWidth);
await SendRawAsync(command);
// Move to next line after each slice (except the last one)
if (slice < totalSlices - 1)
{
await SendRawAsync(EpsonCommands.LineFeed);
}
}
}
finally
{
await SetDefaultLineSpacing();
}
_logger?.LogInformation("Image printed successfully using bit-image mode");
}

View File

@@ -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,

View File

@@ -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)
{

View 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);
}
}

View File

@@ -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.");
}

View File

@@ -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),

View 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 &lt;printer&gt;:3289 back to &lt;querier&gt;:9100, 39 ASCII bytes
/// "28-0e-8b-3d-ca-f1 2608120478"
/// |&lt;-- MAC, 17 --&gt;||&lt;- 12 sp -&gt;||&lt;- serial -&gt;|
///
/// 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;
}
}

View 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.

View 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
View 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
View 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

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View 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`.