diff --git a/Inspectron.Epson.Tests/GlobalUsings.cs b/Inspectron.Epson.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/Inspectron.Epson.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj new file mode 100644 index 0000000..2371a47 --- /dev/null +++ b/Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + Major + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs new file mode 100644 index 0000000..cc747e9 --- /dev/null +++ b/Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs @@ -0,0 +1,97 @@ +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); + } +} diff --git a/Inspectron.Epson.slnx b/Inspectron.Epson.slnx index 875148b..d271a31 100644 --- a/Inspectron.Epson.slnx +++ b/Inspectron.Epson.slnx @@ -7,6 +7,7 @@ + diff --git a/Inspectron.Epson/DiscoveredPrinter.cs b/Inspectron.Epson/DiscoveredPrinter.cs index 03e13e2..8ecdf36 100644 --- a/Inspectron.Epson/DiscoveredPrinter.cs +++ b/Inspectron.Epson/DiscoveredPrinter.cs @@ -8,6 +8,14 @@ public class DiscoveredPrinter public string ModelName { get; set; } public string IPAddress { get; set; } public string MACAddress { get; set; } + + /// + /// 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. + /// + public string? Serial { get; set; } + public int Port { get; set; } public DateTime DiscoveredAt { get; set; } public byte[] RawResponse { get; set; } diff --git a/Inspectron.Epson/PreconfiguredDiscoveryService.cs b/Inspectron.Epson/PreconfiguredDiscoveryService.cs index c53283d..4dd81a1 100644 --- a/Inspectron.Epson/PreconfiguredDiscoveryService.cs +++ b/Inspectron.Epson/PreconfiguredDiscoveryService.cs @@ -38,6 +38,7 @@ public class PreconfiguredDiscoveryService : IDiscoveryService ModelName = p.ModelName, Port = p.Port, MACAddress = p.MACAddress, + Serial = p.Serial, ServiceUrl = p.ServiceUrl, ServiceType = p.ServiceType, Lifetime = p.Lifetime, diff --git a/Inspectron.Epson/SprtDiscoveryService.cs b/Inspectron.Epson/SprtDiscoveryService.cs new file mode 100644 index 0000000..f4383d9 --- /dev/null +++ b/Inspectron.Epson/SprtDiscoveryService.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Inspectron.Epson; + +/// +/// 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 ->| +/// +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; + + /// Model reported for anything answering SPRT. The reply carries no + /// model name, and a POS896 is indistinguishable from a POS895 here. + public const string SprtModelName = "POS895"; + + private readonly ILogger? _logger; + + public SprtDiscoveryService(ILogger? logger = null) + { + _logger = logger; + } + + /// The 13-byte discovery query, byte-identical to EasySet's. + public static byte[] BuildProbe() + { + var probe = new List(Encoding.ASCII.GetBytes(ProbeMagic)); + probe.AddRange(new byte[] { 0x00, 0x00, 0x0D }); + return probe.ToArray(); + } + + /// + /// 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. + /// + 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> DiscoverPrintersAsync( + TimeSpan? timeout = null, + Action? onPrinterDiscovered = null) + { + timeout ??= TimeSpan.FromSeconds(5); + var discovered = new List(); + + 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; + } +}