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;
+ }
+}