diff --git a/Inspectron.Epson.Tests/POS895Tests.cs b/Inspectron.Epson.Tests/POS895Tests.cs new file mode 100644 index 0000000..adeb774 --- /dev/null +++ b/Inspectron.Epson.Tests/POS895Tests.cs @@ -0,0 +1,104 @@ +using Inspectron.Epson.PrintServer.Printers; +using Inspectron.Epson.PrintServer.Printers.Utils; + +namespace Inspectron.Epson.Tests; + +public class POS895Tests +{ + private static POS895 Make(out RecordingEpsonPrinter fake) + { + fake = new RecordingEpsonPrinter(); + return new POS895(fake) { SkipDelays = true }; + } + + [Fact] + public async Task PrintAsync_never_uses_ESC_d_to_feed() + { + // This firmware ignores ESC d. Feeding with it would cut the last line. + var printer = Make(out var fake); + + await printer.PrintAsync(new List + { + new PrintCommand("hello") + }); + + Assert.Empty(fake.FeedLineCalls); + } + + [Fact] + public async Task PrintAsync_feeds_with_literal_line_feeds_before_finishing() + { + var printer = Make(out var fake); + + await printer.PrintAsync(new List + { + new PrintCommand("hello") + }); + + Assert.Equal(10, fake.LineFeedBytes); + } + + [Fact] + public async Task PrintAsync_writes_the_command_text() + { + var printer = Make(out var fake); + + await printer.PrintAsync(new List + { + new PrintCommand("hello") + }); + + Assert.Contains(fake.Text, t => t.Contains("hello")); + } + + [Fact] + public async Task PrintAsync_handles_an_IsCut_command_with_line_feeds_then_a_cut() + { + var printer = Make(out var fake); + + await printer.PrintAsync(new List + { + new PrintCommand("a"), + new PrintCommand("") { IsCut = true } + }); + + Assert.Empty(fake.FeedLineCalls); + Assert.Equal(1, fake.CutCount); + } + + [Fact] + public async Task PrintImageAsync_uses_bit_image_mode_at_576_dots() + { + // GS ( L prints nothing on this firmware, so LoadImageAsync must not be used. + var printer = Make(out var fake); + + await printer.PrintImageAsync("logo.png"); + + Assert.Empty(fake.RasterImages); + var image = Assert.Single(fake.BitModeImages); + Assert.Equal("logo.png", image.Path); + Assert.Equal(EpsonCommands.BitImageMode.DoubleDensity24Dot, image.Mode); + Assert.Equal(576, image.MaxWidth); + } + + [Fact] + public async Task PrintImageAsync_does_not_use_absolute_positioning() + { + // ESC $ is unverified on this firmware; the logo spans the full width. + var printer = Make(out var fake); + + await printer.PrintImageAsync("logo.png"); + + Assert.Empty(fake.AbsolutePositions); + } + + [Fact] + public async Task Cut_cuts_once() + { + var printer = Make(out var fake); + + await printer.Cut(); + + Assert.Equal(1, fake.CutCount); + } +} diff --git a/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs new file mode 100644 index 0000000..259165c --- /dev/null +++ b/Inspectron.Epson.Tests/RecordingEpsonPrinter.cs @@ -0,0 +1,96 @@ +using Inspectron.Epson; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Inspectron.Epson.Tests; + +/// +/// Records what an IPrinter adapter sends. Only the calls the POS895 adapter +/// makes are recorded; the rest satisfy the interface and do nothing. +/// +public sealed class RecordingEpsonPrinter : IEpsonPrinter +{ + public List RawCommands { get; } = new(); + public List FeedLineCalls { get; } = new(); + public List Text { get; } = new(); + public List<(string Path, EpsonCommands.BitImageMode Mode, int MaxWidth)> BitModeImages { get; } = new(); + public List<(string Path, int MaxWidth)> RasterImages { get; } = new(); + public List AbsolutePositions { get; } = new(); + public int CutCount { get; private set; } + + /// Total LF bytes across every raw command sent. + public int LineFeedBytes => RawCommands.Sum(c => c.Count(b => b == 0x0A)); + + public bool IsConnected => true; + + public Task SendRawCommandAsync(byte[] command) + { + RawCommands.Add(command); + return Task.CompletedTask; + } + + public Task FeedLinesAsync(int lines) + { + FeedLineCalls.Add(lines); + return Task.CompletedTask; + } + + public Task PrintTextAsync(string text) + { + Text.Add(text); + return Task.CompletedTask; + } + + public Task PrintImageBitModeAsync(string imagePath, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.SingleDensity8Dot, int maxWidth = 10) + { + BitModeImages.Add((imagePath, mode, maxWidth)); + return Task.CompletedTask; + } + + public Task LoadImageAsync(string imagePath, int maxWidth = 384) + { + RasterImages.Add((imagePath, maxWidth)); + return Task.CompletedTask; + } + + public Task SetAbsolutePrintPosition(int x) + { + AbsolutePositions.Add(x); + return Task.CompletedTask; + } + + public Task CutAsync(bool fullCut = true) + { + CutCount++; + return Task.CompletedTask; + } + + // Remaining interface members: no-ops. + public Task ConnectAsync(string ip, int port = 9100, int timeoutSeconds = 10) => Task.CompletedTask; + public void Disconnect() { } + // 0x16 / 0x12 are what the POS895 actually returns when idle and ready. + public Task GetPrinterStatusAsync() => Task.FromResult(PrinterStatus.Parse(0x16)); + public Task GetOfflineStatusAsync() => Task.FromResult(OfflineStatus.Parse(0x12)); + public Task GetErrorStatusAsync() => Task.FromResult(ErrorStatus.Parse(0x12)); + public Task GetPaperSensorStatusAsync() => Task.FromResult(PaperSensorStatus.Parse(0x12)); + public Task GetTM220StatusAsync() => Task.FromResult((byte)0); + public Task GetPrinterIdAsync() => Task.FromResult(0x20); + public Task GetOverallStatusAsync() => Task.FromResult(new OverallStatus()); + public Task InitAsync() => Task.CompletedTask; + public Task SetQuadrupleMode(bool enable) => Task.CompletedTask; + public Task SetRedColor(bool enable) => Task.CompletedTask; + public Task SetEmphasized(bool enable) => Task.CompletedTask; + public Task SelectFont(EpsonCommands.PrinterFont font) => Task.CompletedTask; + public Task SetBiggerFontTM220(bool biggerWidth, bool biggerHeight = false, bool secondaryFont = true) => Task.CompletedTask; + public Task SetFontSizeAsync(int width, int height) => Task.CompletedTask; + public Task PrintTextAndCutAsync(string text, int feedLines = 5) => Task.CompletedTask; + public Task PrintBuffer() => Task.CompletedTask; + public Task LoadImageAsync(Stream imageStream, int maxWidth = 384) => Task.CompletedTask; + public Task LoadImageAsync(Image image, int maxWidth = 384) => Task.CompletedTask; + public Task PrintLoadedImage() => Task.CompletedTask; + public Task PrintImageBitModeAsync(Stream imageStream, EpsonCommands.BitImageMode mode = EpsonCommands.BitImageMode.DoubleDensity24Dot, int maxWidth = 384) => Task.CompletedTask; + public Task PrintImageBitModeAsync(Image image, EpsonCommands.BitImageMode mode, int maxWidth) => Task.CompletedTask; + public Task SetDefaultLineSpacing() => Task.CompletedTask; + public Task SetCustomLineSpacing(int spacing = 24) => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/Inspectron.Epson/PrintServer/Printers/POS895.cs b/Inspectron.Epson/PrintServer/Printers/POS895.cs new file mode 100644 index 0000000..f3df8b6 --- /dev/null +++ b/Inspectron.Epson/PrintServer/Printers/POS895.cs @@ -0,0 +1,96 @@ +using Inspectron.Epson.PrintServer.Printers.Utils; +using Inspectron.Epson.PrintServer.PrintServices; + +namespace Inspectron.Epson.PrintServer.Printers; + +/// +/// Adapter for the POS895 / POS896, SPRT-family ESC/POS clones on an 80 mm, +/// 576-dot line (Font A = 48 columns). +/// +/// Cloned from TM_T30IIITranslated with three differences, each confirmed on +/// hardware and recorded in POS895_POS896_integration_notes.md: +/// +/// 1. ESC d n is ignored by this firmware, so feeding uses literal LF bytes. +/// Using ESC d would cut the receipt through its last line. +/// 2. GS ( L prints nothing, so the logo goes out as ESC * bit-image. +/// 3. The line is 576 dots, not 384, and the logo spans it from position 0, +/// which avoids ESC $ (still unverified here). +/// +public class POS895 : IPrinter +{ + /// Dots per line. Font A is 12 dots wide, giving 48 columns. + public const int PaperWidthDots = 576; + + private const byte LineFeed = 0x0A; + private const int FeedLinesBeforeCut = 10; + + private readonly IEpsonPrinter _printer; + + public bool SkipDelays { get; set; } + + public POS895(IEpsonPrinter printer) + { + _printer = printer; + } + + public Task InitAsync() + { + return _printer.InitAsync(); + } + + public async Task PrintImageAsync(string path) + { + await FeedAsync(1); + await _printer.PrintImageBitModeAsync( + path, EpsonCommands.BitImageMode.DoubleDensity24Dot, PaperWidthDots); + if (!SkipDelays) await Task.Delay(100); + await FeedAsync(1); + } + + public async Task PrintAsync(List commands) + { + await _printer.SetCustomLineSpacing(22); + + foreach (var command in commands) + { + if (command.IsCut) + { + await FeedAsync(FeedLinesBeforeCut); + await _printer.CutAsync(false); + continue; + } + + if (command.SetLineSpacing.HasValue) + { + await _printer.SetCustomLineSpacing(command.SetLineSpacing.Value); + continue; + } + + await _printer.SetFontSizeAsync(command.IsBig ? 2 : 1, command.IsTall ? 2 : 1); + await _printer.SetEmphasized(command.IsBold); + await _printer.PrintTextAsync(command.Text + "\n"); + } + + await _printer.SetDefaultLineSpacing(); + await FeedAsync(FeedLinesBeforeCut); + if (!SkipDelays) await Task.Delay(200); + } + + public async Task Cut() + { + await _printer.CutAsync(); + if (!SkipDelays) await Task.Delay(200); + } + + /// + /// Feeds with literal LF bytes. IEpsonPrinter.FeedLinesAsync emits ESC d, + /// which this firmware ignores, and the Epson models depend on that method + /// staying as it is. + /// + private Task FeedAsync(int lines) + { + var bytes = new byte[lines]; + Array.Fill(bytes, LineFeed); + return _printer.SendRawCommandAsync(bytes); + } +}