add SPRT discovery service for POS895 printers
Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
This commit is contained in:
1
Inspectron.Epson.Tests/GlobalUsings.cs
Normal file
1
Inspectron.Epson.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
25
Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Normal file
25
Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Normal 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>
|
||||
97
Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs
Normal file
97
Inspectron.Epson.Tests/SprtDiscoveryServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
</Folder>
|
||||
<Project Path="EpsonPrintService/EpsonPrintService.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/Inspectron.Epson.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -8,6 +8,14 @@ public class DiscoveredPrinter
|
||||
public string ModelName { get; set; }
|
||||
public string IPAddress { 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 DateTime DiscoveredAt { get; set; }
|
||||
public byte[] RawResponse { get; set; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
167
Inspectron.Epson/SprtDiscoveryService.cs
Normal file
167
Inspectron.Epson/SprtDiscoveryService.cs
Normal 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 <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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user