add SPRT discovery service for POS895 printers

Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
This commit is contained in:
EugeneTes
2026-09-03 11:51:05 +02:00
parent 2f26a933cb
commit f219129ce4
7 changed files with 300 additions and 0 deletions

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,25 @@
<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>

View File

@@ -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);
}
}

View File

@@ -7,6 +7,7 @@
</Folder> </Folder>
<Project Path="EpsonPrintService/EpsonPrintService.csproj" /> <Project Path="EpsonPrintService/EpsonPrintService.csproj" />
<Project Path="Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj" /> <Project Path="Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj" />
<Project Path="Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj" />
<Project Path="Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj" /> <Project Path="Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj" />
<Project Path="Inspectron.Epson/Inspectron.Epson.csproj" /> <Project Path="Inspectron.Epson/Inspectron.Epson.csproj" />
</Solution> </Solution>

View File

@@ -8,6 +8,14 @@ public class DiscoveredPrinter
public string ModelName { get; set; } public string ModelName { get; set; }
public string IPAddress { get; set; } public string IPAddress { get; set; }
public string MACAddress { get; set; } public string MACAddress { get; set; }
/// <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; }
public int Port { get; set; } public int Port { get; set; }
public DateTime DiscoveredAt { get; set; } public DateTime DiscoveredAt { get; set; }
public byte[] RawResponse { get; set; } public byte[] RawResponse { get; set; }

View File

@@ -38,6 +38,7 @@ public class PreconfiguredDiscoveryService : IDiscoveryService
ModelName = p.ModelName, ModelName = p.ModelName,
Port = p.Port, Port = p.Port,
MACAddress = p.MACAddress, MACAddress = p.MACAddress,
Serial = p.Serial,
ServiceUrl = p.ServiceUrl, ServiceUrl = p.ServiceUrl,
ServiceType = p.ServiceType, ServiceType = p.ServiceType,
Lifetime = p.Lifetime, Lifetime = p.Lifetime,

View File

@@ -0,0 +1,167 @@
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;
}
}