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:
19
CLAUDE.md
19
CLAUDE.md
@@ -13,6 +13,8 @@ This is a C# .NET 8.0 solution for Epson thermal receipt printer integration. Th
|
||||
- **Inspectron.Epson** - Core SDK library providing printer communication, status monitoring, ESC/POS commands, print queue management, and receipt conversion
|
||||
- **EpsonPrintService** - Production console application with SignalR-based remote job source, printer discovery, and systemd service support
|
||||
- **EpsonTest** - Test/example project for SDK functionality and receipt conversion
|
||||
- **Inspectron.Epson.Tests** - xunit unit test project (POS895, CompositeDiscoveryService, SprtDiscoveryService, receipt converters, etc.)
|
||||
- **Pos895Test** - Scratch console app for probing POS895/POS896 hardware directly; not production code
|
||||
|
||||
## Common Build Commands
|
||||
|
||||
@@ -30,6 +32,9 @@ dotnet run --project EpsonPrintService/EpsonPrintService.csproj
|
||||
# Run tests/examples
|
||||
dotnet run --project EpsonTest/EpsonTest.csproj
|
||||
|
||||
# Run the unit test suite
|
||||
dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
|
||||
|
||||
# Clean build artifacts
|
||||
dotnet clean
|
||||
```
|
||||
@@ -45,6 +50,8 @@ dotnet clean
|
||||
- `IDiscoveryService` / `DiscoveryService`: Network printer discovery (SLP, ENPC)
|
||||
- `PreconfiguredDiscoveryService`: Preconfigured printer list (no network scan)
|
||||
- `BonjourDiscovery`: mDNS-based printer discovery
|
||||
- `SprtDiscoveryService`: UDP broadcast discovery for SPRT-family printers (POS895 / POS896), which do not answer Epson ENPC
|
||||
- `CompositeDiscoveryService`: Runs several `IDiscoveryService` implementations concurrently and merges results by IP; absorbs individual child failures but rethrows if every child fails, so a genuinely broken discovery pass is distinguishable from an empty network
|
||||
- `HtmlPrinter`: HTML output implementation for testing without hardware
|
||||
- Status classes: PrinterStatus, OfflineStatus, ErrorStatus, PaperSensorStatus, OverallStatus
|
||||
- Exception hierarchy: EpsonPrinterException -> EpsonConnectionException, EpsonCommandException
|
||||
@@ -86,6 +93,7 @@ Receipt content is converted from JSON to `PrintCommand` lists via `IReceiptConv
|
||||
`IPrinter` implementations wrap `IEpsonPrinter` with model-specific behavior:
|
||||
- `TM-T30III` / `TM-T30IIITranslated` - Thermal receipt printer (384px image width)
|
||||
- `TM-U220II` / `TM-U220IITranslated` - Dot matrix receipt printer (narrower image width)
|
||||
- `POS895` - SPRT-family ESC/POS clone (POS895 / POS896), 576-dot line, Font A at 48 columns. See "Non-Epson printers" below for the firmware quirks it works around.
|
||||
|
||||
Created via `IWrapperPrinterFactory.CreatePrinterFromId(modelIdByte, epsonPrinter)`.
|
||||
|
||||
@@ -173,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user