Pre-flight scan found two things the plan mandated that a reviewer would rightly flag: Task 1 committed a DiscoverPrintersAsync that threw NotImplementedException until Task 2 replaced it, and a unit test asserted an empty result from a real UDP broadcast, which passes on a laptop and fails on any network with a POS895 on it. Tasks 1 and 2 are now one task delivering the complete service, and the network-dependent test is gone. The deterministic bind-failure test still covers the degradation path. Six tasks now. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
44 KiB
POS895 Integration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A POS895 on the network is discovered automatically, reported to the backend, and prints every existing receipt type correctly at 48 columns with a logo.
Architecture: The POS895 answers GS I 1 with 0x20, so model identity stays a byte and the existing per-job identification flow is untouched. Two things are genuinely new: a SprtDiscoveryService speaking the vendor's UDP broadcast protocol (the printer ignores Epson's ENPC), and a POS895 IPrinter adapter that differs from the TM-T30III one in exactly three respects. Everything else is registering 0x20 in three existing switches.
Tech Stack: C# / .NET 8.0, xunit for tests, Ninject for DI, SixLabors.ImageSharp for images.
Global Constraints
- Target framework
net8.0,Nullableenabled,ImplicitUsingsenabled — match the other projects. - Do not modify
EpsonPrinterorIEpsonPrinter. The Epson models depend on current behaviour, notablyFeedLinesAsyncemittingESC d. - Do not change
NextCourseinReceiptConverterFactory. It is hardcoded to 33/20 for every printer and looks like a bug, but changing it alters TM-T30III behaviour and is out of scope. - Do not change the CP852 selection in
EpsonPrinter.PrintTextAsync:339. It is a real fleet-wide bug (CP852 cannot encodeè à ù ê ì ò) but needs its own spec. - The printer's ESC/POS quirks, all confirmed on hardware:
GS I 1=0x20;ESC d nis ignored (feed withLF);GS ( Lprints nothing (useESC *viaPrintImageBitModeAsync);GS !magnification works; 576 dots per line, Font A = 48 columns. - Build with
dotnet build Inspectron.Epson.slnx. The solution has 153 pre-existing warnings inInspectron.Epson; do not attempt to fix them, but add no new ones.
Task 1: SprtDiscoveryService, test project, and DiscoveredPrinter.Serial
Delivers the complete SPRT discovery service: the test project the later tasks need, the one new DTO field, the protocol's pure functions, and the network path.
Files:
- Create:
Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj - Create:
Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs - Create:
Inspectron.Epson/SprtDiscoveryService.cs - Modify:
Inspectron.Epson/DiscoveredPrinter.cs - Modify:
Inspectron.Epson/PreconfiguredDiscoveryService.cs - Modify:
Inspectron.Epson.slnx
Interfaces:
-
Consumes:
DiscoveredPrinter,IDiscoveryService(existing,Inspectron.Epson). -
Produces:
SprtDiscoveryService(ILogger? logger = null);SprtDiscoveryService.BuildProbe()returningbyte[];SprtDiscoveryService.TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)returningbool; a workingDiscoverPrintersAsync. Task 6 constructs it for DI.DiscoveredPrinter.Serial(string?). -
Step 1: Create the test project
Create Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RollForward>Major</RollForward>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
</ItemGroup>
</Project>
Then register it in Inspectron.Epson.slnx by adding this line after the Inspectron.Epson.TemplateEngine.Tests entry:
<Project Path="Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj" />
- Step 2: Write the failing tests
Create Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs:
using System.Text;
using Inspectron.Epson;
namespace Inspectron.Epson.Tests;
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_returns_empty_when_port_9100_is_taken()
{
// Hold UDP 9100 so the service cannot bind it. It must degrade, not throw:
// ENPC discovery has to keep working when this one cannot start.
using var blocker = new System.Net.Sockets.UdpClient();
blocker.Client.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 9100));
var service = new SprtDiscoveryService();
var result = await service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(200));
Assert.Empty(result);
}
}
Note there is deliberately no "nothing answers" test. Asserting an empty list from a real broadcast would pass on a developer laptop and fail on any network with a POS895 on it, including the print server's own.
- Step 3: Run the tests to verify they fail
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: FAIL to compile — SprtDiscoveryService does not exist, DiscoveredPrinter has no Serial.
- Step 4: Add the
Serialfield
In Inspectron.Epson/DiscoveredPrinter.cs, add after the MACAddress property:
/// <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; }
In Inspectron.Epson/PreconfiguredDiscoveryService.cs, inside the object initialiser in DiscoverPrintersAsync, add alongside the other copied fields so the value survives emulation mode:
Serial = p.Serial,
- Step 5: Write the minimal implementation
Create Inspectron.Epson/SprtDiscoveryService.cs:
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging;
namespace Inspectron.Epson;
/// <summary>
/// Discovery for SPRT-family ESC/POS printers (POS895 / POS896), which do not
/// answer Epson's ENPC broadcast.
///
/// Protocol, reverse engineered from a packet capture of the vendor's EasySet
/// tool and documented in POS895_POS896_integration_notes.md:
///
/// query UDP broadcast 255.255.255.255:3289, sent FROM source port 9100
/// 53 50 52 54 2D 49 6E 66 6F 72 00 00 0D "SPRT-Infor" 00 00 0D
///
/// reply from <printer>:3289 back to <querier>:9100, 39 ASCII bytes
/// "28-0e-8b-3d-ca-f1 2608120478"
/// |<-- MAC, 17 -->||<- 12 sp ->||<- serial ->|
/// </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);
return discovered;
}
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");
return discovered;
}
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;
}
}
- Step 6: Run the tests to verify they pass
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 7 tests.
- Step 7: Commit
git add Inspectron.Epson.Tests Inspectron.Epson.slnx Inspectron.Epson/SprtDiscoveryService.cs Inspectron.Epson/DiscoveredPrinter.cs Inspectron.Epson/PreconfiguredDiscoveryService.cs
git commit -m "add SPRT discovery service for POS895 printers"
Task 2: CompositeDiscoveryService
Runs several discovery services concurrently and merges their results, so ENPC and SPRT printers appear in one list.
Files:
- Create:
Inspectron.Epson/CompositeDiscoveryService.cs - Create:
Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs
Interfaces:
-
Consumes:
IDiscoveryService(existing). -
Produces:
CompositeDiscoveryService(IEnumerable<IDiscoveryService> services, ILogger? logger = null). Task 6 constructs it for DI. -
Step 1: Write the failing tests
Create Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs:
using Inspectron.Epson;
namespace Inspectron.Epson.Tests;
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 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);
}
}
[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_first_wins()
{
// A printer answering both ENPC and SPRT must appear once.
var composite = new CompositeDiscoveryService(new IDiscoveryService[]
{
new StubDiscovery("10.0.0.1"),
new StubDiscovery("10.0.0.1")
});
var result = await composite.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(50));
Assert.Single(result);
}
[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 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);
}
}
- Step 2: Run the tests to verify they fail
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter CompositeDiscoveryServiceTests
Expected: FAIL to compile — CompositeDiscoveryService does not exist.
- Step 3: Write the implementation
Create Inspectron.Epson/CompositeDiscoveryService.cs:
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.
/// </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);
// Children run concurrently, so the caller's timeout still bounds the
// whole pass rather than the sum of the children.
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);
return new List<DiscoveredPrinter>();
}
});
var results = await Task.WhenAll(tasks);
// Task.WhenAll has completed, so this merge is single-threaded.
foreach (var printer in results.SelectMany(r => r))
{
if (printer.IPAddress is null || !seenIps.Add(printer.IPAddress))
{
continue;
}
merged.Add(printer);
onPrinterDiscovered?.Invoke(printer);
}
return merged;
}
}
Note the children are called without the onPrinterDiscovered callback. The composite raises it itself after deduplication, so a printer answering both protocols notifies once.
- Step 4: Run the tests to verify they pass
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 12 tests.
- Step 5: Commit
git add Inspectron.Epson/CompositeDiscoveryService.cs Inspectron.Epson.Tests/CompositeDiscoveryServiceTests.cs
git commit -m "add CompositeDiscoveryService merging ENPC and SPRT results"
Task 3: POS895 printer adapter
The adapter differs from TM_T30IIITranslated in exactly three ways, each confirmed on hardware. A recording fake asserts the two that would otherwise fail silently on paper.
Files:
- Create:
Inspectron.Epson/PrintServer/Printers/POS895.cs - Create:
Inspectron.Epson.Tests/RecordingEpsonPrinter.cs - Create:
Inspectron.Epson.Tests/POS895Tests.cs
Interfaces:
-
Consumes:
IPrinter(Inspectron.Epson.PrintServer.PrintServices),IEpsonPrinter(Inspectron.Epson),PrintCommand(Inspectron.Epson.PrintServer.Printers.Utils). -
Produces:
POS895(IEpsonPrinter printer)with abool SkipDelays { get; set; }. Task 4 constructs it. -
Step 1: Write the recording test double
Create Inspectron.Epson.Tests/RecordingEpsonPrinter.cs:
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>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);
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++;
return Task.CompletedTask;
}
// Remaining interface members: no-ops.
public Task ConnectAsync(string ip, int port = 9100, int timeoutSeconds = 10) => Task.CompletedTask;
// 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;
}
If the compiler reports any missing interface member, add it as a no-op in the same style. Do not change IEpsonPrinter.
- Step 2: Write the failing tests
Create Inspectron.Epson.Tests/POS895Tests.cs:
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);
}
[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);
}
}
- Step 3: Run the tests to verify they fail
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter POS895Tests
Expected: FAIL to compile — POS895 does not exist.
- Step 4: Write the implementation
Create Inspectron.Epson/PrintServer/Printers/POS895.cs:
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);
}
}
Note PrintAsync feeds 10 lines at the end and 10 on an IsCut command, matching TM_T30IIITranslated. The PrintAsync_feeds_with_literal_line_feeds_before_finishing test uses a command list with no IsCut, so it sees exactly 10.
- Step 5: Run the tests to verify they pass
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 19 tests.
- Step 6: Commit
git add Inspectron.Epson/PrintServer/Printers/POS895.cs Inspectron.Epson.Tests/RecordingEpsonPrinter.cs Inspectron.Epson.Tests/POS895Tests.cs
git commit -m "add POS895 printer adapter with LF feed and ESC * logo"
Task 4: Register printer ID 0x20 in the three switches
Three one-line additions in three files. The third is the one that fails silently if missed.
Files:
- Modify:
Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs - Modify:
Inspectron.Epson/DiscoveryService.cs(GetModelNameFromId) - Modify:
Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs - Create:
Inspectron.Epson.Tests/PrinterRegistrationTests.cs
Interfaces:
-
Consumes:
POS895from Task 3. -
Produces: nothing new; wires
0x20into existing dispatch. -
Step 1: Write the failing tests
Create Inspectron.Epson.Tests/PrinterRegistrationTests.cs:
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));
}
}
}
- Step 2: Run the tests to verify they fail
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter PrinterRegistrationTests
Expected: FAIL — WrapperFactory_returns_a_POS895_for_id_0x20 throws NotSupportedException: Printer with ID 20 is not supported.
- Step 3: Register in
PrinterWrapperFactory
In Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs, add before the default: case:
case 0x20:
return new POS895(epsonPrinter);
- Step 4: Register in
GetModelNameFromId
In Inspectron.Epson/DiscoveryService.cs, in the GetModelNameFromId switch expression, add before the _ => arm:
0x20 => "POS895",
- Step 5: Register in
ReceiptConverterFactory
In Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs, change the WorkareaTicket arm so 0x20 gets the wide layout:
EReceiptType.WorkareaTicket => printerId switch
{
//0x01=>new BarReceiptConverterAdapter(48, 24),
0x01 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24),
0x20 => new KitchenReceipt.KitchenReceiptConverterAdapter(48, 24),
_ => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20),
},
Leave EReceiptType.NextCourse at 33, 20. It is hardcoded for every printer and changing it would alter TM-T30III behaviour.
- Step 6: Run the tests to verify they pass
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 24 tests.
- Step 7: Commit
git add Inspectron.Epson/PrintServer/Printers/PrinterWrapperFactory.cs Inspectron.Epson/DiscoveryService.cs Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs Inspectron.Epson.Tests/PrinterRegistrationTests.cs
git commit -m "register printer id 0x20 as POS895 across the three dispatch points"
Task 5: Guard the nullable printer ID
printerId is a byte? dereferenced with .Value twice. A printer that does not answer GS I 1 throws NullReferenceException into the generic catch, reporting PrintErrorType.Other and burning three PrinterQueue retries with no usable log line.
Files:
- Modify:
Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs:38-48
Interfaces:
-
Consumes: nothing new.
-
Produces: nothing new.
-
Step 1: Add the guard
In Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs, directly after the existing line 40 log call and before var status = await epsonPrinter.GetPrinterStatusAsync();, insert:
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);
}
This one check covers both printerId.Value dereferences, at :48 and :91.
- Step 2: Verify the build
Run: dotnet build Inspectron.Epson/Inspectron.Epson.csproj -v minimal
Expected: 0 Error(s), and no new warnings attributable to EpsonPrintService.cs.
- Step 3: Run the full test suite
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 24 tests.
- Step 4: Commit
git add Inspectron.Epson/PrintServer/PrintServices/EpsonPrintService.cs
git commit -m "fail cleanly when a printer does not answer GS I 1"
Task 6: DI wiring and emulation entry
Binds the composite discovery in production and adds a 576px emulated POS895 so receipt layouts can be checked without hardware.
Files:
- Modify:
EpsonPrintService/PrintServerBootstrapper.cs:52-78
Interfaces:
-
Consumes:
SprtDiscoveryService(Task 1),CompositeDiscoveryService(Task 2). -
Produces: nothing new.
-
Step 1: Add the emulated POS895
In EpsonPrintService/PrintServerBootstrapper.cs, extend the emulationConfigs dictionary:
var emulationConfigs = new Dictionary<string, (string OutputPath, byte PrinterId, int PaperWidth)>
{
["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),
};
Register it with the preconfigured discovery, next to the existing two:
discovery.AddPrinter("127.0.0.4", "POS895");
And update the console line so the mode still reports what it started:
Console.WriteLine("Emulation mode enabled: 127.0.0.2 (TM-U220II), 127.0.0.3 (TM-T30III), 127.0.0.4 (POS895)");
- Step 2: Bind the composite discovery in production
In the else branch, replace this line:
_kernel.Bind<IDiscoveryService>().To<DiscoveryService>().InSingletonScope();
with:
// 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();
Leave the commented-out PreconfiguredDiscoveryService block above it alone — it is marked DO NOT REMOVE !!!.
- Step 3: Verify the whole solution builds
Run: dotnet build Inspectron.Epson.slnx -v minimal
Expected: 0 Error(s).
- Step 4: Run the full test suite
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 24 tests.
- Step 5: Verify discovery against the real printer
The Pos895Test probe must still find the printer, proving the extracted SprtDiscoveryService behaves like the code it came from. Run on a machine on the printer's segment:
dotnet run --project Pos895Test/Pos895Test.csproj
Expected: FOUND 10.1.252.219 mac 28-0e-8b-3d-ca-f1 serial 2608120478
- Step 6: Commit
git add EpsonPrintService/PrintServerBootstrapper.cs
git commit -m "bind composite discovery and add emulated POS895"
Manual verification after all tasks
These need the printer and cannot be automated here.
- Start the service in emulation mode and confirm
emulation/pos895.htmlrenders each receipt type at 48 columns. - With a POS895 on the network, confirm the discovery background task registers it and the backend receives it as model
POS895. - Print one of each receipt type to the real printer with a logo. Check: the logo renders at full width, no line is cut through, and kitchen tickets use the full 48 columns rather than 33.
- Confirm an Epson printer on the same network is still discovered — the composite must not have broken ENPC.