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
1280 lines
44 KiB
Markdown
1280 lines
44 KiB
Markdown
# 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`, `Nullable` enabled, `ImplicitUsings` enabled — match the other projects.
|
|
- Do not modify `EpsonPrinter` or `IEpsonPrinter`. The Epson models depend on current behaviour, notably `FeedLinesAsync` emitting `ESC d`.
|
|
- Do not change `NextCourse` in `ReceiptConverterFactory`. 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 n` is **ignored** (feed with `LF`); `GS ( L` prints **nothing** (use `ESC *` via `PrintImageBitModeAsync`); `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 in `Inspectron.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()` returning `byte[]`; `SprtDiscoveryService.TryParseReply(byte[] payload, string ipAddress, out DiscoveredPrinter? printer)` returning `bool`; a working `DiscoverPrintersAsync`. Task 6 constructs it for DI. `DiscoveredPrinter.Serial` (`string?`).
|
|
|
|
- [ ] **Step 1: Create the test project**
|
|
|
|
Create `Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj`:
|
|
|
|
```xml
|
|
<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:
|
|
|
|
```xml
|
|
<Project Path="Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj" />
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing tests**
|
|
|
|
Create `Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs`:
|
|
|
|
```csharp
|
|
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 `Serial` field**
|
|
|
|
In `Inspectron.Epson/DiscoveredPrinter.cs`, add after the `MACAddress` property:
|
|
|
|
```csharp
|
|
/// <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:
|
|
|
|
```csharp
|
|
Serial = p.Serial,
|
|
```
|
|
|
|
- [ ] **Step 5: Write the minimal implementation**
|
|
|
|
Create `Inspectron.Epson/SprtDiscoveryService.cs`:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```csharp
|
|
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`:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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 a `bool SkipDelays { get; set; }`. Task 4 constructs it.
|
|
|
|
- [ ] **Step 1: Write the recording test double**
|
|
|
|
Create `Inspectron.Epson.Tests/RecordingEpsonPrinter.cs`:
|
|
|
|
```csharp
|
|
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`:
|
|
|
|
```csharp
|
|
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`:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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: `POS895` from Task 3.
|
|
- Produces: nothing new; wires `0x20` into existing dispatch.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `Inspectron.Epson.Tests/PrinterRegistrationTests.cs`:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
discovery.AddPrinter("127.0.0.4", "POS895");
|
|
```
|
|
|
|
And update the console line so the mode still reports what it started:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
_kernel.Bind<IDiscoveryService>().To<DiscoveryService>().InSingletonScope();
|
|
```
|
|
|
|
with:
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```bash
|
|
dotnet run --project Pos895Test/Pos895Test.csproj
|
|
```
|
|
|
|
Expected: `FOUND 10.1.252.219 mac 28-0e-8b-3d-ca-f1 serial 2608120478`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
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.html` renders 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.
|