Fix three pre-merge review findings on POS895 branch

1. Strengthen POS895 IsCut test: add RecordingEpsonPrinter.CallOrder so
   the test can assert feeds happen BEFORE the cut, plus a LineFeedBytes
   count assertion. The prior test could not detect the FeedAsync call
   being deleted from the IsCut branch (verified: test now fails when
   that line is removed, passes when restored).

2. CompositeDiscoveryService: distinguish "some children failed" from
   "every child failed". Per-child failures are still absorbed, but if
   every discovery service throws, the composite now rethrows instead
   of silently returning an empty list. Previously a total discovery
   failure (e.g. a routeless interface) looked like "discovery
   succeeded, found nothing" to PrinterDiscoveryBackgroundTask, which
   would expire and unregister every known printer after the retention
   window, discarding their queued jobs. Also corrected a doc comment
   that overstated Task.WhenAll's timeout guarantee.

3. Update CLAUDE.md to describe the POS895/POS896 work as shipped
   rather than future work: added the Inspectron.Epson.Tests and
   Pos895Test projects, a dotnet test command, SprtDiscoveryService/
   CompositeDiscoveryService/POS895 in the architecture sections, the
   0x20 printer-ID note, and the established SPRT hardware facts.

Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
This commit is contained in:
EugeneTes
2026-09-03 12:46:14 +02:00
parent 9680ab0ad3
commit a4e96f3041
5 changed files with 75 additions and 8 deletions

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,7 +181,7 @@ 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
@@ -182,7 +190,14 @@ Printers are registered via `PreconfiguredDiscoveryService` with hardcoded IPs i
### Non-Epson printers (POS895 / POS896)
A feasibility study for the POS895 / POS896 ESC/POS clones lives in `POS895_POS896_integration_notes.md`. Read it before touching discovery, printer identification (`GetPrinterIdAsync`), or `PrinterWrapperFactory` for a non-Epson model. Short version: do not link the vendor SDK; add an `IPrinter` adapter for a 576-dot line, a discovery strategy that is not Epson ENPC, and a more robust printer-ID probe. The document lists the ESC/POS commands the vendor firmware is known to accept and the hardware checks still open.
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

View File

@@ -143,6 +143,23 @@ public class CompositeDiscoveryServiceTests
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()
{

View File

@@ -64,6 +64,15 @@ public class POS895Tests
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]

View File

@@ -18,6 +18,12 @@ public sealed class RecordingEpsonPrinter : IEpsonPrinter
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));
@@ -26,6 +32,7 @@ public sealed class RecordingEpsonPrinter : IEpsonPrinter
public Task SendRawCommandAsync(byte[] command)
{
RawCommands.Add(command);
CallOrder.Add($"raw:{command.Length}");
return Task.CompletedTask;
}
@@ -62,6 +69,7 @@ public sealed class RecordingEpsonPrinter : IEpsonPrinter
public Task CutAsync(bool fullCut = true)
{
CutCount++;
CallOrder.Add("cut");
return Task.CompletedTask;
}

View File

@@ -7,7 +7,11 @@ namespace Inspectron.Epson;
///
/// 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.
/// 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
{
@@ -27,8 +31,14 @@ public class CompositeDiscoveryService : IDiscoveryService
var merged = new List<DiscoveredPrinter>();
var seenIps = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Children run concurrently, so the caller's timeout still bounds the
// whole pass rather than the sum of the children.
// 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
@@ -39,14 +49,22 @@ public class CompositeDiscoveryService : IDiscoveryService
{
_logger?.LogWarning(ex, "Discovery service {Service} failed",
service.GetType().Name);
return new List<DiscoveredPrinter>();
childExceptions.Add(ex);
return null;
}
});
var results = await Task.WhenAll(tasks);
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.SelectMany(r => r))
foreach (var printer in results.Where(r => r is not null).SelectMany(r => r!))
{
if (printer.IPAddress is null || !seenIps.Add(printer.IPAddress))
{