Files
Print_server/docs/superpowers/plans/2026-09-03-pos895-integration.md
EugeneTes 2f26a933cb amend POS895 plan: merge discovery tasks, drop network-dependent test
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
2026-09-03 11:46:25 +02:00

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, 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:

<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 Serial field

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 &lt;printer&gt;:3289 back to &lt;querier&gt;:9100, 39 ASCII bytes
///          "28-0e-8b-3d-ca-f1            2608120478"
///           |&lt;-- MAC, 17 --&gt;||&lt;- 12 sp -&gt;||&lt;- serial -&gt;|
/// </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 a bool 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: POS895 from Task 3.

  • Produces: nothing new; wires 0x20 into 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.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.