Seven TDD tasks derived from the design spec, each ending in a commit: test project and SPRT protocol parsing, the discovery network path, CompositeDiscoveryService, the POS895 adapter, the three switch registrations, the nullable printer-id guard, and DI wiring plus an emulated POS895. Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
46 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: Test project, DiscoveredPrinter.Serial, and SPRT protocol parsing
Creates the test project the later tasks need, adds the one new DTO field, and implements the pure functions of the SPRT protocol so they can be tested without a network.
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(existing class,Inspectron.Epson). -
Produces:
SprtDiscoveryService.BuildProbe()returningbyte[];SprtDiscoveryService.TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)returningbool. Task 2 uses both.DiscoveredPrinter.Serial(string?) used by Tasks 1-2. -
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);
}
}
- 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.Text;
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;
/// <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";
/// <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 Task<List<DiscoveredPrinter>> DiscoverPrintersAsync(
TimeSpan? timeout = null,
Action<DiscoveredPrinter>? onPrinterDiscovered = null)
{
throw new NotImplementedException("Task 2");
}
}
- Step 6: Run the tests to verify they pass
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 6 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 protocol parsing and test project"
Task 2: SprtDiscoveryService network path
Broadcasts the probe and collects replies. Binding UDP 9100 can fail when another process holds it; discovery must degrade to empty rather than crash, so ENPC discovery still runs.
Files:
- Modify:
Inspectron.Epson/SprtDiscoveryService.cs - Modify:
Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs
Interfaces:
-
Consumes:
BuildProbe(),TryParseReply(...)from Task 1. -
Produces:
SprtDiscoveryService(ILogger? logger = null)constructor; a workingDiscoverPrintersAsync. Task 6 constructs it for DI. -
Step 1: Write the failing tests
Append to Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs:
[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);
}
[Fact]
public async Task DiscoverPrintersAsync_returns_empty_when_nothing_answers()
{
var service = new SprtDiscoveryService();
var result = await service.DiscoverPrintersAsync(TimeSpan.FromMilliseconds(300));
Assert.Empty(result);
}
- Step 2: Run the tests to verify they fail
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj --filter DiscoverPrintersAsync
Expected: FAIL with NotImplementedException: Task 2.
- Step 3: Write the implementation
In Inspectron.Epson/SprtDiscoveryService.cs, add these usings at the top:
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
Add a logger field and constructor above BuildProbe:
private const int DiscoveryPort = 3289;
private const int ReplyPort = 9100;
private readonly ILogger? _logger;
public SprtDiscoveryService(ILogger? logger = null)
{
_logger = logger;
}
Replace the NotImplementedException body of DiscoverPrintersAsync with:
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 4: Run the tests to verify they pass
Run: dotnet test Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Expected: PASS, 8 tests.
- Step 5: Commit
git add Inspectron.Epson/SprtDiscoveryService.cs Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs
git commit -m "implement SPRT discovery broadcast and reply collection"
Task 3: 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, 13 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 4: 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 5 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, 20 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 5: 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 4. -
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, 25 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 6: 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, 25 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 7: 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 2),CompositeDiscoveryService(Task 3). -
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, 25 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.