reverse engineer POS895 discovery; add SPRT discovery probe
The POS895 does not answer Epson's ENPC broadcast, so PrintServer never
registers it and every job for it fails with "Printer not found". The
feasibility notes listed the vendor's own discovery protocol as unknown
and expected a packet capture to recover it, which is what this does.
Captured pktmon traffic while running the vendor EasySet tool on the
printer's segment. The protocol is:
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 chars 12 spaces serial, 10 chars
The printer's address is the reply's source IP. Pos895Test now sends this
probe and parses the replies; run against the real printer it returns the
same MAC and serial the vendor tool got, so the reimplementation matches.
Two useful side effects. Port 3289 is the same UDP port ENPC uses, so a
composite IDiscoveryService can share one socket. And the querying machine
and the printer sat in different /24s of one flat segment, so the /24 TCP
sweep the notes originally preferred would have missed this printer.
The print test is commented out at the bottom of Program.cs rather than
deleted; it confirmed Font A is 48 columns and that GS ! magnification
works at width 1x/2x/3x with height 2x.
Notes updated: the discovery work item is no longer the largest one, the
compatibility table records DLE EOT and GS ! as confirmed and ESC d as
apparently ignored, and the answered questions are separated from the
ones still open.
Claude-Session: https://claude.ai/code/session_01E6N3P4nSZLtp4BC2LXVref
This commit is contained in:
@@ -1,138 +1,138 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Inspectron.Epson;
|
||||
|
||||
// POS895 smoke test: Font A column count vs. GS ! width magnification.
|
||||
// POS895 discovery probe.
|
||||
//
|
||||
// Prints one ruler row per width magnification, each at 48/width characters, so
|
||||
// every row should end at the same right margin with progressively larger type.
|
||||
// A row that stops short means that magnification was ignored.
|
||||
// The POS895 does not answer Epson's ENPC broadcast, but it does answer the
|
||||
// vendor's own SPRT discovery, which the EasySet tool uses. Protocol, taken from
|
||||
// a packet capture of EasySet on the printer's segment:
|
||||
//
|
||||
// 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\0\0\r"
|
||||
//
|
||||
// reply from <printer>:3289 back to <querier>:9100, 39 bytes of ASCII
|
||||
// "28-0e-8b-3d-ca-f1 2608120478"
|
||||
// |<-- MAC, 17 -->||<- 12 sp ->||<- serial, 10 ->|
|
||||
//
|
||||
// The printer's address is the reply's source IP; the payload carries only MAC
|
||||
// and serial, no model name. Note 3289 is the same UDP port Epson ENPC uses, so
|
||||
// a composite IDiscoveryService can reuse the socket it already has.
|
||||
//
|
||||
// All settings are the constants below. Edit them here; there are no arguments.
|
||||
|
||||
// An AnyDesk TCP tunnel forwards this local endpoint to the remote printer's
|
||||
// port 9100, so the printer is reachable as though it sat on this machine.
|
||||
// A refused connection here usually means the AnyDesk session or its tunnel is
|
||||
// down, not that the printer is off.
|
||||
const string PrinterIp = "127.0.0.1";
|
||||
const int PrinterPort = 8888;
|
||||
const int ConnectTimeoutSeconds = 5;
|
||||
const string ProbeMagic = "SPRT-Infor";
|
||||
const int DiscoveryPort = 3289;
|
||||
const int ReplyPort = 9100;
|
||||
const int ListenSeconds = 3;
|
||||
|
||||
// Font A is 12 dots wide on a 576-dot line. Width magnification divides this.
|
||||
const int FontAColumns = 48;
|
||||
const int HeightMagnification = 2;
|
||||
int[] widths = { 1, 2, 3 };
|
||||
// EasySet sends from source port 9100 and the reply comes back to 9100. Whether
|
||||
// the firmware honours the sender's port or hard-codes 9100 is unknown from a
|
||||
// single capture. Set this false to bind an ephemeral port instead: if replies
|
||||
// still arrive, the firmware replies to the sender; if they stop, 9100 is
|
||||
// hard-coded and any implementation must bind it.
|
||||
bool bindSourcePort9100 = true;
|
||||
|
||||
// Blank lines between the last row and the cut. Literal LF bytes rather than
|
||||
// ESC d n: LF is the most universally implemented feed in ESC/POS, and this
|
||||
// firmware appears to ignore ESC d.
|
||||
const int BlankLinesAfterText = 10;
|
||||
byte[] probe = BuildProbe(ProbeMagic);
|
||||
|
||||
// Set true to drop the ESC t 18 (code page 18 / CP852) selection. The ruler is
|
||||
// pure ASCII either way; this only isolates whether ESC t itself is honoured.
|
||||
bool useRawAscii = false;
|
||||
|
||||
byte[] job = BuildJob(FontAColumns, widths, HeightMagnification, BlankLinesAfterText, useRawAscii);
|
||||
|
||||
Console.WriteLine($"POS895 smoke test -> {PrinterIp}:{PrinterPort}");
|
||||
Console.WriteLine($"Font A, height {HeightMagnification}x, widths {string.Join("x / ", widths)}x");
|
||||
Console.WriteLine();
|
||||
foreach (int w in widths)
|
||||
{
|
||||
int cols = FontAColumns / w;
|
||||
Console.WriteLine($" GS ! 0x{SizeByte(w, HeightMagnification):X2} w={w} h={HeightMagnification} {cols,2} cols {Ruler(cols)}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Byte sequence:");
|
||||
Console.WriteLine(Describe(job));
|
||||
Console.WriteLine("POS895 / SPRT discovery probe");
|
||||
Console.WriteLine($" probe -> 255.255.255.255:{DiscoveryPort} ({probe.Length} bytes)");
|
||||
Console.WriteLine(Describe(probe));
|
||||
Console.WriteLine();
|
||||
|
||||
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
udp.EnableBroadcast = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var printer = new EpsonPrinter();
|
||||
|
||||
Console.Write("Connecting... ");
|
||||
await printer.ConnectAsync(PrinterIp, PrinterPort, ConnectTimeoutSeconds);
|
||||
Console.WriteLine("connected.");
|
||||
|
||||
Console.Write($"Sending {job.Length} bytes... ");
|
||||
await printer.SendRawCommandAsync(job);
|
||||
Console.WriteLine("sent.");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[OK] Bytes sent. All three rows should end at the same right");
|
||||
Console.WriteLine(" margin, each in larger type than the one above it.");
|
||||
return 0;
|
||||
udp.Client.Bind(new IPEndPoint(IPAddress.Any, bindSourcePort9100 ? ReplyPort : 0));
|
||||
}
|
||||
catch (EpsonConnectionException ex)
|
||||
catch (SocketException ex)
|
||||
{
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine($"[FAIL] Could not reach {PrinterIp}:{PrinterPort}.");
|
||||
Console.Error.WriteLine(" Is the port forward to the printer's 9100 up?");
|
||||
Console.Error.WriteLine($" {ex.Message}");
|
||||
if (ex.InnerException is not null)
|
||||
{
|
||||
Console.Error.WriteLine($" {ex.InnerException.Message}");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine($"[FAIL] {ex.GetType().Name}: {ex.Message}");
|
||||
Console.Error.WriteLine($"[FAIL] Could not bind UDP {ReplyPort}: {ex.Message}");
|
||||
Console.Error.WriteLine(" Something else holds it, or this needs elevation.");
|
||||
Console.Error.WriteLine(" Set bindSourcePort9100 = false to try an ephemeral port.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The whole job as one array, so what gets listed above is exactly what goes on
|
||||
// the wire.
|
||||
static byte[] BuildJob(int fontACols, int[] widths, int heightMag, int blankLines, bool rawAscii)
|
||||
int localPort = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
|
||||
Console.WriteLine($" listening on UDP {localPort} for {ListenSeconds}s");
|
||||
Console.WriteLine();
|
||||
|
||||
await udp.SendAsync(probe, probe.Length, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort));
|
||||
|
||||
var found = new Dictionary<string, string>();
|
||||
DateTime deadline = DateTime.UtcNow.AddSeconds(ListenSeconds);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var job = new List<byte> { 0x1B, 0x40 }; // ESC @ initialize
|
||||
|
||||
if (!rawAscii)
|
||||
using var cts = new CancellationTokenSource(deadline - DateTime.UtcNow);
|
||||
UdpReceiveResult res;
|
||||
try
|
||||
{
|
||||
job.AddRange(new byte[] { 0x1B, 0x74, 0x12 }); // ESC t 18 code page 852
|
||||
res = await udp.ReceiveAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1B, 0x21, 0x00 }); // ESC ! 0 Font A, no attributes
|
||||
string ip = res.RemoteEndPoint.Address.ToString();
|
||||
var parsed = ParseReply(res.Buffer);
|
||||
|
||||
foreach (int w in widths)
|
||||
if (parsed is null)
|
||||
{
|
||||
job.AddRange(new byte[] { 0x1D, 0x21, SizeByte(w, heightMag) });
|
||||
job.AddRange(Encoding.ASCII.GetBytes(Ruler(fontACols / w)));
|
||||
job.Add(0x0A);
|
||||
Console.WriteLine($" {ip}:{res.RemoteEndPoint.Port} unrecognised, {res.Buffer.Length} bytes");
|
||||
Console.WriteLine(Describe(res.Buffer));
|
||||
continue;
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1D, 0x21, 0x00 }); // GS ! 0 back to 1x1
|
||||
|
||||
for (int i = 0; i < blankLines; i++)
|
||||
{
|
||||
job.Add(0x0A); // blank line
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1D, 0x56, 0x00 }); // GS V 0 full cut
|
||||
return job.ToArray();
|
||||
(string mac, string serial) = parsed.Value;
|
||||
found[ip] = mac;
|
||||
Console.WriteLine($" FOUND {ip,-15} mac {mac} serial {serial}");
|
||||
}
|
||||
|
||||
// GS ! n: bits 4-6 are width magnification - 1, bits 0-2 height - 1. Same
|
||||
// formula as EpsonPrinter.SetFontSizeAsync.
|
||||
static byte SizeByte(int width, int height) =>
|
||||
(byte)(((height - 1) & 0x07) | (((width - 1) & 0x07) << 4));
|
||||
|
||||
// "|---" repeated and cut to an exact column count, so the pipes fall every
|
||||
// 4 characters and the row ends precisely at the claimed right margin.
|
||||
static string Ruler(int columns)
|
||||
Console.WriteLine();
|
||||
if (found.Count == 0)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
while (sb.Length < columns)
|
||||
{
|
||||
sb.Append("|---");
|
||||
}
|
||||
|
||||
return sb.ToString(0, columns);
|
||||
Console.WriteLine("[--] No replies.");
|
||||
Console.WriteLine(" - is this machine on the printer's broadcast domain?");
|
||||
Console.WriteLine(" - does the firewall allow inbound UDP on this port?");
|
||||
Console.WriteLine($" - try flipping bindSourcePort9100 (currently {bindSourcePort9100}).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Hex plus a printable-ASCII gutter, so the output can be compared against the
|
||||
// printer's hex dump mode (open cover, FEED twice within 3 s, close cover).
|
||||
Console.WriteLine($"[OK] {found.Count} printer(s) answered SPRT discovery.");
|
||||
return 0;
|
||||
|
||||
// "SPRT-Infor" followed by 00 00 0D. The trailing 0x0D is also the total length
|
||||
// of the datagram, which may or may not be a coincidence.
|
||||
static byte[] BuildProbe(string magic)
|
||||
{
|
||||
var p = new List<byte>(Encoding.ASCII.GetBytes(magic));
|
||||
p.AddRange(new byte[] { 0x00, 0x00, 0x0D });
|
||||
return p.ToArray();
|
||||
}
|
||||
|
||||
// 17 ASCII bytes of dashed lowercase MAC, space padding, then the serial.
|
||||
static (string Mac, string Serial)? ParseReply(byte[] payload)
|
||||
{
|
||||
if (payload.Length < 17)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string text = Encoding.ASCII.GetString(payload);
|
||||
string mac = text[..17];
|
||||
|
||||
bool looksLikeMac = mac.Split('-') is { Length: 6 } parts
|
||||
&& parts.All(p => p.Length == 2 && p.All(Uri.IsHexDigit));
|
||||
|
||||
return looksLikeMac ? (mac, text[17..].Trim()) : null;
|
||||
}
|
||||
|
||||
// Hex plus a printable-ASCII gutter.
|
||||
static string Describe(byte[] bytes)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
@@ -158,3 +158,78 @@ static string Describe(byte[] bytes)
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
PRINT TEST -- disabled.
|
||||
|
||||
Confirmed working against the printer through the AnyDesk tunnel on
|
||||
127.0.0.1:8888. Font A is 48 columns on the 576-dot line, and GS !
|
||||
magnification works at width 1x/2x/3x with height 2x. ESC d n appears
|
||||
to be ignored, hence the literal LF bytes before the cut.
|
||||
|
||||
To re-enable, move this code above the discovery section.
|
||||
|
||||
const string PrinterIp = "127.0.0.1";
|
||||
const int PrinterPort = 8888;
|
||||
const int ConnectTimeoutSeconds = 5;
|
||||
|
||||
const int FontAColumns = 48;
|
||||
const int HeightMagnification = 2;
|
||||
int[] widths = { 1, 2, 3 };
|
||||
const int BlankLinesAfterText = 10;
|
||||
bool useRawAscii = false;
|
||||
|
||||
byte[] job = BuildJob(FontAColumns, widths, HeightMagnification, BlankLinesAfterText, useRawAscii);
|
||||
|
||||
Console.WriteLine("Byte sequence:");
|
||||
Console.WriteLine(Describe(job));
|
||||
|
||||
await using var printer = new EpsonPrinter();
|
||||
await printer.ConnectAsync(PrinterIp, PrinterPort, ConnectTimeoutSeconds);
|
||||
await printer.SendRawCommandAsync(job);
|
||||
|
||||
static byte[] BuildJob(int fontACols, int[] widths, int heightMag, int blankLines, bool rawAscii)
|
||||
{
|
||||
var job = new List<byte> { 0x1B, 0x40 }; // ESC @ initialize
|
||||
|
||||
if (!rawAscii)
|
||||
{
|
||||
job.AddRange(new byte[] { 0x1B, 0x74, 0x12 }); // ESC t 18 code page 852
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1B, 0x21, 0x00 }); // ESC ! 0 Font A
|
||||
|
||||
foreach (int w in widths)
|
||||
{
|
||||
job.AddRange(new byte[] { 0x1D, 0x21, SizeByte(w, heightMag) });
|
||||
job.AddRange(Encoding.ASCII.GetBytes(Ruler(fontACols / w)));
|
||||
job.Add(0x0A);
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1D, 0x21, 0x00 }); // GS ! 0 back to 1x1
|
||||
|
||||
for (int i = 0; i < blankLines; i++)
|
||||
{
|
||||
job.Add(0x0A);
|
||||
}
|
||||
|
||||
job.AddRange(new byte[] { 0x1D, 0x56, 0x00 }); // GS V 0 full cut
|
||||
return job.ToArray();
|
||||
}
|
||||
|
||||
// GS ! n: bits 4-6 width magnification - 1, bits 0-2 height - 1.
|
||||
static byte SizeByte(int width, int height) =>
|
||||
(byte)(((height - 1) & 0x07) | (((width - 1) & 0x07) << 4));
|
||||
|
||||
static string Ruler(int columns)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
while (sb.Length < columns)
|
||||
{
|
||||
sb.Append("|---");
|
||||
}
|
||||
|
||||
return sb.ToString(0, columns);
|
||||
}
|
||||
|
||||
===================================================================== */
|
||||
|
||||
Reference in New Issue
Block a user