From d4b304399a78446cfe2d1db584349c92f8471396 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:11:47 +0000 Subject: [PATCH 01/33] feat(printing): read the printer status with ~HS and check it around a job Adds a UI-free decoder for the three ~HS host status strings, a status reader for TCP and (best effort) the Windows spooler, and an opt-in check in PrintService: a blocking fault (head open, paper out, ribbon out, head too hot, diagnostics mode, buffer full, paused) stops the job before any byte is sent, an unknown status never blocks, and a verification after the job reports finished, fault after job or could not verify as a notice. --- src/Rotula.Printing/Abstractions.cs | 48 ++ src/Rotula.Printing/PrintResult.cs | 36 + src/Rotula.Printing/PrintService.cs | 170 ++++- src/Rotula.Printing/PrinterStatus.cs | 298 ++++++++ src/Rotula.Printing/PrinterStatusReader.cs | 141 ++++ src/Rotula.Printing/WinspoolRawPrinter.cs | 7 + tests/Rotula.Tests/PrinterStatusTests.cs | 753 +++++++++++++++++++++ 7 files changed, 1446 insertions(+), 7 deletions(-) create mode 100644 src/Rotula.Printing/PrinterStatus.cs create mode 100644 src/Rotula.Printing/PrinterStatusReader.cs create mode 100644 tests/Rotula.Tests/PrinterStatusTests.cs diff --git a/src/Rotula.Printing/Abstractions.cs b/src/Rotula.Printing/Abstractions.cs index 98b308a..b66a968 100644 --- a/src/Rotula.Printing/Abstractions.cs +++ b/src/Rotula.Printing/Abstractions.cs @@ -55,6 +55,46 @@ public sealed record NetworkPrinterOptions(TimeSpan ConnectTimeout, TimeSpan Sen public static NetworkPrinterOptions Default { get; } = new(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); } +/// +/// Reads the host status (~HS) of a printer. Implemented by ; fake it in tests. Implementations +/// never throw for printer problems and never wait longer than the timeout: an unreachable or silent printer is an unavailable status. +/// +public interface IPrinterStatusReader +{ + /// Sends ~HS to and decodes the reply. + /// The printer (Windows queue or TCP endpoint). + /// Longest wait for the reply, connection included. + /// Cancellation; a cancelled query throws . + Task ReadStatusAsync(PrinterTarget target, TimeSpan timeout, CancellationToken cancellationToken = default); +} + +/// +/// Whether and how the print service asks the printer for its status around a job (ZPL ~HS). Off by default in the library, so +/// existing callers send exactly what they always sent; the app switches it on per transport. +/// +/// Ask before each job and do not send while the printer reports a fault (paper out, head open...). +/// After the last job, ask until the printer reports it finished or a fault, and add the outcome as a notice. +public sealed record PrinterStatusOptions(bool CheckBeforePrinting, bool VerifyAfterPrinting) +{ + /// No status queries (the default of every target). + public static PrinterStatusOptions Off { get; } = new(false, false); + + /// Check before printing and verify afterwards, with the default timeouts. + public static PrinterStatusOptions Default { get; } = new(true, true); + + /// True when any query is made. + public bool Enabled => CheckBeforePrinting || VerifyAfterPrinting; + + /// Longest wait for one status reply. Default 3 seconds. + public TimeSpan QueryTimeout { get; init; } = TimeSpan.FromSeconds(3); + + /// Longest time the verification waits for the printer to finish. Default 30 seconds. + public TimeSpan VerifyTimeout { get; init; } = TimeSpan.FromSeconds(30); + + /// Pause between two status queries while verifying. Default 1 second. + public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(1); +} + /// How a job reaches the printer. public enum PrinterTransport { @@ -82,6 +122,14 @@ private PrinterTarget() { } public int Port { get; private init; } = DefaultTcpPort; /// TCP timeouts. public NetworkPrinterOptions Network { get; private init; } = NetworkPrinterOptions.Default; + /// Status queries around a job; unless set with . + public PrinterStatusOptions Status { get; private init; } = PrinterStatusOptions.Off; + + /// A copy of this target that asks the printer for its status as says. + public PrinterTarget WithStatus(PrinterStatusOptions status) => this with { Status = status ?? PrinterStatusOptions.Off }; + + /// A short name of the printer for messages: the queue name, or host:port. + public string DisplayName => Transport == PrinterTransport.Tcp ? Host + ":" + Port.ToString(System.Globalization.CultureInfo.InvariantCulture) : PrinterName; /// Targets an installed Windows printer (use this for USB printers). public static PrinterTarget Spooler(string printerName) => diff --git a/src/Rotula.Printing/PrintResult.cs b/src/Rotula.Printing/PrintResult.cs index 02fdbdb..998c0d8 100644 --- a/src/Rotula.Printing/PrintResult.cs +++ b/src/Rotula.Printing/PrintResult.cs @@ -27,6 +27,20 @@ public enum PrintErrorKind Unsupported, /// Any other failure; the arguments carry the technical detail. Unknown, + /// The status check found the printhead open; nothing was sent. Arguments: printer, then every reported condition name. + HeadOpen, + /// The status check found the printer out of labels; nothing was sent. Arguments as for . + PaperOut, + /// The status check found the ribbon out; nothing was sent. Arguments as for . + RibbonOut, + /// The status check found the printhead too hot; nothing was sent. Arguments as for . + HeadTooHot, + /// The status check found communications diagnostics mode on (the job would print as a data dump); nothing was sent. + DiagnosticMode, + /// The status check found the printer's receive buffer full; nothing was sent. Arguments as for . + BufferFull, + /// The status check found the printer paused; nothing was sent. Arguments as for . + Paused, } /// @@ -40,6 +54,28 @@ public sealed record PrintError(PrintErrorKind Kind, string Key, IReadOnlyListCreates an error whose key is derived from . public static PrintError Create(PrintErrorKind kind, params string[] args) => new(kind, "Print.Error." + kind, args); + + /// + /// The error for a printer whose status blocks printing: the kind of its most serious condition, then the printer name and the names of + /// every reported condition as arguments, so the UI can mention the others too. Null when nothing blocks. + /// + public static PrintError? FromStatus(PrinterStatus status, string printer) + { + ArgumentNullException.ThrowIfNull(status); + var blocking = status.Blocking; + if (blocking.Count == 0) return null; + var kind = blocking[0] switch + { + PrinterCondition.HeadOpen => PrintErrorKind.HeadOpen, + PrinterCondition.PaperOut => PrintErrorKind.PaperOut, + PrinterCondition.RibbonOut => PrintErrorKind.RibbonOut, + PrinterCondition.HeadTooHot => PrintErrorKind.HeadTooHot, + PrinterCondition.DiagnosticMode => PrintErrorKind.DiagnosticMode, + PrinterCondition.BufferFull => PrintErrorKind.BufferFull, + _ => PrintErrorKind.Paused, + }; + return Create(kind, [printer ?? string.Empty, .. status.Conditions.Select(c => c.ToString())]); + } } /// Outcome of a print operation. Failures are values, never exceptions, so callers need no try/catch. diff --git a/src/Rotula.Printing/PrintService.cs b/src/Rotula.Printing/PrintService.cs index b6eb0dc..063e8f9 100644 --- a/src/Rotula.Printing/PrintService.cs +++ b/src/Rotula.Printing/PrintService.cs @@ -68,6 +68,45 @@ Task PrintZplAsync(PrinterTarget target, string zpl, ZplEncoding en /// Task GetPrinterInfoAsync(PrinterTarget target, CancellationToken ct = default) => Task.FromResult(PrinterCapabilities.Unknown(target?.PrinterName ?? string.Empty)); + + /// + /// Asks the printer for its host status (~HS) and decodes it. Never throws for a printer problem: a printer that cannot answer + /// gives an unavailable status. Cancellation throws . The default implementation reports + /// "not supported" so existing fakes keep compiling. + /// + /// The printer; its bounds the wait. + /// Cancellation. + Task GetStatusAsync(PrinterTarget target, CancellationToken ct = default) => + Task.FromResult(PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported)); + + /// + /// After jobs were sent, asks the printer for its status until it reports it finished, reports a fault, or + /// passes, and returns the outcome as one notice (). + /// Print calls do this themselves when the target asks for it; a caller that sends several jobs calls it once after the last. + /// Cancellation stops the wait and is reported as a notice, never as a failure, because the labels were already sent. + /// The default implementation returns no notice. + /// + Task> VerifyPrintedAsync(PrinterTarget target, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); +} + +/// Message keys of the notices the status check and the verification add to a . +public static class PrinterStatusKeys +{ + /// The status could not be read before printing, so the job was sent unchecked. Argument: a name. + public const string NotChecked = "PrinterStatus.NotChecked"; + /// The printer reported conditions that do not stop printing (head cold...). Arguments: condition names. + public const string Warning = "PrinterStatus.Warning"; + /// The printer reported it finished the job without a fault. + public const string Verified = "PrinterStatus.Verified"; + /// The printer reported a fault after the job was sent; labels may be missing. Arguments: condition names. + public const string FaultAfterJob = "PrinterStatus.FaultAfterJob"; + /// The outcome could not be confirmed. Argument: a name, or . + public const string NotVerified = "PrinterStatus.NotVerified"; + /// Reason of : the printer was still busy when the wait ended. + public const string StillPrinting = "StillPrinting"; + /// Reason of : the operator stopped the wait. + public const string Stopped = "Stopped"; } /// Default : ZPL from Core, bytes from , transport by target. @@ -76,16 +115,25 @@ public sealed class PrintService : IPrintService private readonly IRawPrinter _spooler; private readonly INetworkPrinter _network; private readonly IPrinterDiscovery _discovery; + private readonly IPrinterStatusReader _status; /// Creates the service with the real winspool and TCP implementations. public PrintService() : this(new WinspoolRawPrinter(), new TcpRawPrinter(), new WinspoolRawPrinter()) { } - /// Creates the service with explicit transports (tests pass fakes). + /// + /// Creates the service with explicit transports (tests pass fakes). Status queries use the real ; they + /// only happen for a target that asks for them () or through . + /// public PrintService(IRawPrinter spooler, INetworkPrinter network, IPrinterDiscovery discovery) + : this(spooler, network, discovery, new PrinterStatusReader()) { } + + /// Creates the service with explicit transports and status reader (tests pass fakes). + public PrintService(IRawPrinter spooler, INetworkPrinter network, IPrinterDiscovery discovery, IPrinterStatusReader status) { _spooler = spooler ?? throw new ArgumentNullException(nameof(spooler)); _network = network ?? throw new ArgumentNullException(nameof(network)); _discovery = discovery ?? throw new ArgumentNullException(nameof(discovery)); + _status = status ?? throw new ArgumentNullException(nameof(status)); } /// @@ -114,6 +162,7 @@ public async Task PrintAsync(PrinterTarget target, LabelDocument do int bytesSent = 0; PrintResult? outcome = null; IReadOnlyList closing = Array.Empty(); + var statusNotes = new List(); try { string setup = string.Empty; @@ -137,6 +186,10 @@ public async Task PrintAsync(PrinterTarget target, LabelDocument do outcome = PrintResult.Fail(PrintErrorKind.EncodingFailed, document.Encoding.ToString()); break; } + // Checked before every block, so paper running out in the middle of a long run stops it before the next block. + // A block that is held back sent nothing, so its serial numbers go back with the rest. + var held = await CheckBeforeAsync(target, statusNotes, cancellationToken).ConfigureAwait(false); + if (held is not null) { outcome = held; break; } var sent = await SendAsync(target, bytes, cancellationToken).ConfigureAwait(false); if (!sent.Succeeded) { @@ -156,11 +209,14 @@ public async Task PrintAsync(PrinterTarget target, LabelDocument do closing = run.Complete(printed); } // Warnings of the run plus problems writing the counter file; the errors of the run are reported through the result itself. - var notes = run.Issues.Where(i => i.Severity != ValidationSeverity.Error).Concat(closing).ToList(); + var notes = run.Issues.Where(i => i.Severity != ValidationSeverity.Error).Concat(closing).Concat(statusNotes).ToList(); if (outcome is null && run.StoppedEarly) return PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, run.Issues.Where(i => i.Severity == ValidationSeverity.Error).Select(i => i.MessageKey).ToArray()), bytesSent) - .WithNotices(run.Issues.Concat(closing).ToList()); + .WithNotices(run.Issues.Concat(closing).Concat(statusNotes).ToList()); + // Verified only after the counters were confirmed: a fault reported now does not give numbers back, because the labels may have printed. + if (outcome is null && bytesSent > 0 && target.Status.VerifyAfterPrinting) + notes.AddRange(await VerifyPrintedAsync(target, cancellationToken).ConfigureAwait(false)); return (outcome ?? PrintResult.Ok(bytesSent)).WithNotices(notes); } @@ -172,7 +228,7 @@ public Task PrintZplAsync(PrinterTarget target, string zpl, ZplEnco ArgumentNullException.ThrowIfNull(encoding); if (!TryEncode(zpl, encoding, out var bytes)) return Task.FromResult(PrintResult.Fail(PrintErrorKind.EncodingFailed, encoding.ToString())); - return SendAsync(target, bytes, cancellationToken); + return SendCheckedAsync(target, bytes, cancellationToken); } /// @@ -185,7 +241,7 @@ public Task PrintZplAsync(PrinterTarget target, string zpl, ZplEnco ArgumentNullException.ThrowIfNull(byteExactBlocks); if (!TryEncode(zpl, encoding, byteExactBlocks, out var bytes)) return Task.FromResult(PrintResult.Fail(PrintErrorKind.EncodingFailed, encoding.ToString())); - return SendAsync(target, bytes, cancellationToken); + return SendCheckedAsync(target, bytes, cancellationToken); } /// @@ -194,8 +250,12 @@ public Task ApplyProfileAsync(PrinterTarget target, PrinterProfile ArgumentNullException.ThrowIfNull(profile); var bad = profile.Validate(); if (bad.Count > 0) return Task.FromResult(PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidProfile, bad.ToArray()))); - // Set-up text is pure ASCII, so any encoding yields the same bytes. - return PrintZplAsync(target, PrinterSetup.BuildSetupZpl(profile), ZplEncoding.Utf8, cancellationToken); + ArgumentNullException.ThrowIfNull(target); + // Set-up text is pure ASCII, so any encoding yields the same bytes. No status check: the commands print nothing and a printer + // that is paused or out of labels still takes its new settings. + if (!TryEncode(PrinterSetup.BuildSetupZpl(profile), ZplEncoding.Utf8, out var bytes)) + return Task.FromResult(PrintResult.Fail(PrintErrorKind.EncodingFailed, ZplEncoding.Utf8.ToString())); + return SendAsync(target, bytes, cancellationToken); } /// @@ -226,6 +286,102 @@ private async Task SafeInfoAsync(string name, CancellationT catch { return PrinterCapabilities.Unknown(name); } } + /// + public Task GetStatusAsync(PrinterTarget target, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(target); + return SafeStatusAsync(target, ct); + } + + /// + public async Task> VerifyPrintedAsync(PrinterTarget target, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(target); + var options = target.Status; + var clock = System.Diagnostics.Stopwatch.StartNew(); + PrinterStatus? lastKnown = null; + var lastReason = StatusUnavailableReason.NoReply; + try + { + while (true) + { + // Wait first: right after the send the printer may not have taken the format into its buffer yet, and an empty buffer + // read too early would look like "finished". + await Task.Delay(options.PollInterval, ct).ConfigureAwait(false); + var status = await SafeStatusAsync(target, ct).ConfigureAwait(false); + if (status.IsAvailable) + { + lastKnown = status; + if (status.IsBlocked) return [Notice(PrinterStatusKeys.FaultAfterJob, status.Conditions.Select(c => c.ToString()).ToArray())]; + if (status.IsIdle) return [Notice(PrinterStatusKeys.Verified)]; + } + else + { + lastReason = status.Reason!.Value; + // Asking again cannot help when the transport cannot read replies at all. + if (lastReason == StatusUnavailableReason.NotSupported) break; + } + if (clock.Elapsed >= options.VerifyTimeout) break; + } + } + catch (OperationCanceledException) + { + return [Notice(PrinterStatusKeys.NotVerified, PrinterStatusKeys.Stopped)]; + } + return [Notice(PrinterStatusKeys.NotVerified, lastKnown is not null ? PrinterStatusKeys.StillPrinting : lastReason.ToString())]; + } + + /// + /// The check in front of a job, when the target asks for it. Returns the failure to report instead of sending (a blocking fault, or + /// the caller cancelled), or null to send. An unknown status never blocks; it adds one notice so the operator knows nothing was checked. + /// + private async Task CheckBeforeAsync(PrinterTarget target, List notes, CancellationToken ct) + { + if (!target.Status.CheckBeforePrinting) return null; + PrinterStatus status; + try { status = await SafeStatusAsync(target, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { return PrintResult.Fail(PrintErrorKind.Cancelled); } + if (!status.IsAvailable) + { + AddOnce(notes, Notice(PrinterStatusKeys.NotChecked, status.Reason!.Value.ToString())); + return null; + } + if (PrintError.FromStatus(status, target.DisplayName) is { } blocked) return PrintResult.Fail(blocked); + if (status.Warnings.Count > 0) AddOnce(notes, Notice(PrinterStatusKeys.Warning, status.Warnings.Select(c => c.ToString()).ToArray())); + return null; + } + + /// One job with the optional check before and verification after (the ready-made ZPL paths). + private async Task SendCheckedAsync(PrinterTarget target, byte[] bytes, CancellationToken ct) + { + var notes = new List(); + var held = await CheckBeforeAsync(target, notes, ct).ConfigureAwait(false); + if (held is not null) return held.WithNotices(notes); + var sent = await SendAsync(target, bytes, ct).ConfigureAwait(false); + if (sent.Succeeded && sent.BytesSent > 0 && target.Status.VerifyAfterPrinting) + notes.AddRange(await VerifyPrintedAsync(target, ct).ConfigureAwait(false)); + return sent.WithNotices(notes); + } + + /// Asks the reader; a reader that breaks its contract and throws is treated as "no status", never as a crash of the print. + private async Task SafeStatusAsync(PrinterTarget target, CancellationToken ct) + { + try { return await _status.ReadStatusAsync(target, target.Status.QueryTimeout, ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (OperationCanceledException) { return PrinterStatus.Unavailable(StatusUnavailableReason.NoReply); } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported); + } + } + + private static ValidationIssue Notice(string key, params string[] args) => new(ValidationSeverity.Warning, key, null, args); + + private static void AddOnce(List notes, ValidationIssue notice) + { + if (!notes.Any(n => n.MessageKey == notice.MessageKey)) notes.Add(notice); + } + /// /// Encodes ZPL with the document's code page: UTF-8 for ^CI28, the matching legacy page otherwise. /// Strict (exception) fallback so an unmappable character is reported instead of silently becoming "?". diff --git a/src/Rotula.Printing/PrinterStatus.cs b/src/Rotula.Printing/PrinterStatus.cs new file mode 100644 index 0000000..e7efeaa --- /dev/null +++ b/src/Rotula.Printing/PrinterStatus.cs @@ -0,0 +1,298 @@ +using System.Globalization; +using System.Text; + +namespace Rotula.Printing; + +/// A condition a ZPL compatible printer reports in its ~HS host status reply. +public enum PrinterCondition +{ + /// The printhead is open (string 2, "head up" flag). Blocks printing. + HeadOpen, + /// The printer is out of labels or the media sensor sees no label (string 1, "paper out" flag). Blocks printing. + PaperOut, + /// The ribbon is out while the printer is in thermal transfer mode (string 2). Blocks printing. + RibbonOut, + /// The printhead is too hot (string 1, "over temperature" flag). Blocks printing. + HeadTooHot, + /// Communications diagnostics mode is on: the printer would print the received data as a dump instead of labels. Blocks printing. + DiagnosticMode, + /// The receive buffer is full (string 1). Blocks printing until the printer catches up. + BufferFull, + /// The printer is paused (string 1). Blocks printing until someone presses Pause or Feed. + Paused, + /// The printhead is too cold (string 1, "under temperature" flag). The printer still prints, possibly lighter. + HeadTooCold, + /// The printer lost its configuration (string 1, "corrupt RAM" flag) and runs on defaults. + CorruptRam, + /// A format is only partly received (string 1); another job may be arriving. + PartialFormat, + /// In peel-off mode a printed label waits to be taken (string 2). + LabelWaiting, +} + +/// The print mode a printer reports in string 2 of ~HS. +public enum PrinterPrintMode +{ + /// A value this version does not know. + Unknown, + /// 0: rewind. + Rewind, + /// 1: peel-off. + PeelOff, + /// 2: tear-off (the most common setting). + TearOff, + /// 3: cutter. + Cutter, + /// 4: applicator. + Applicator, + /// 5: delayed cut. + DelayedCut, + /// 6: linerless peel. + LinerlessPeel, + /// 7: linerless rewind. + LinerlessRewind, + /// 8: partial cutter. + PartialCutter, + /// 9: RFID. + Rfid, + /// K: kiosk. + Kiosk, + /// S: stream. + Stream, +} + +/// Why a status could not be read. The UI maps the name to a plain-language reason. +public enum StatusUnavailableReason +{ + /// The transport cannot read replies (most USB drivers, or not running on Windows). + NotSupported, + /// Windows reports the queue as offline; nothing was sent so the query does not wait in the queue forever. + Offline, + /// The printer could not be reached (connection refused, unknown host, queue not found). + Unreachable, + /// The printer did not answer in time. + NoReply, + /// Something answered, but not with a host status reply. + Unreadable, +} + +/// +/// The decoded answer to ~HS, or the reason there is none. Built by ; a value object with no +/// user-facing text. Only conditions the printer positively reported are listed, so a partial reply never invents a fault. +/// +public sealed class PrinterStatus +{ + private PrinterStatus() { } + + /// True when at least string 1 or string 2 of the reply was decoded. + public bool IsAvailable => Reason is null; + + /// Why the status is unknown, or null when . + public StatusUnavailableReason? Reason { get; private init; } + + /// True when both string 1 (media, buffer, temperature) and string 2 (head, ribbon, batch) were decoded. + public bool IsComplete { get; private init; } + + /// Conditions the printer reported, most serious first. + public IReadOnlyList Conditions { get; private init; } = Array.Empty(); + + /// Formats received but not yet printed (string 1), or null when string 1 was not read. + public int? FormatsInBuffer { get; private init; } + + /// Labels still to print in the current batch (string 2), or null when string 2 was not read. + public long? LabelsRemaining { get; private init; } + + /// True when a format is only partly received (string 1), or null when unknown. + public bool? PartialFormat { get; private init; } + + /// Label length the printer measured or was told, in dots (string 1), or null. + public int? LabelLengthDots { get; private init; } + + /// True for thermal transfer (ribbon), false for direct thermal (string 2), or null. + public bool? ThermalTransfer { get; private init; } + + /// Print mode (string 2), or null. + public PrinterPrintMode? PrintMode { get; private init; } + + /// Number of graphic images stored in printer memory (string 2), or null. + public int? GraphicsStored { get; private init; } + + /// The conditions that stop printing: head open, paper out, ribbon out, head too hot, diagnostics mode, buffer full, paused. + public IReadOnlyList Blocking => Conditions.Where(IsBlockingCondition).ToList(); + + /// The reported conditions that do not stop printing (head too cold, lost configuration, partial format, label waiting). + public IReadOnlyList Warnings => Conditions.Where(c => !IsBlockingCondition(c)).ToList(); + + /// True when the printer reported at least one condition that stops printing. + public bool IsBlocked => Conditions.Any(IsBlockingCondition); + + /// + /// True when the printer reports it has nothing left to do: no format in the buffer, no label left in the batch and no partial format. + /// False when any of those is unknown, so an incomplete reply never counts as "finished". + /// + public bool IsIdle => FormatsInBuffer == 0 && LabelsRemaining == 0 && PartialFormat == false; + + /// True for the conditions that stop a printer from printing a new job. + public static bool IsBlockingCondition(PrinterCondition condition) => condition <= PrinterCondition.Paused; + + /// A status that could not be read. + public static PrinterStatus Unavailable(StatusUnavailableReason reason) => new() { Reason = reason }; + + internal static PrinterStatus Decoded(string[]? s1, string[]? s2) + { + var found = new SortedSet(); + int? formats = null, length = null, graphics = null; + long? remaining = null; + bool? partial = null, transfer = null; + PrinterPrintMode? mode = null; + + if (s1 is not null) + { + // String 1: aaa,b,c,dddd,eee,f,g,h,iii,j,k,l + if (Flag(s1[1])) found.Add(PrinterCondition.PaperOut); + if (Flag(s1[2])) found.Add(PrinterCondition.Paused); + length = Int(s1[3]); + formats = Int(s1[4]); + if (Flag(s1[5])) found.Add(PrinterCondition.BufferFull); + if (Flag(s1[6])) found.Add(PrinterCondition.DiagnosticMode); + partial = Flag(s1[7]); + if (partial == true) found.Add(PrinterCondition.PartialFormat); + if (Flag(s1[9])) found.Add(PrinterCondition.CorruptRam); + if (Flag(s1[10])) found.Add(PrinterCondition.HeadTooCold); + if (Flag(s1[11])) found.Add(PrinterCondition.HeadTooHot); + } + if (s2 is not null) + { + // String 2: mmm,n,o,p,q,r,s,t,uuuuuuuu,v,www + if (Flag(s2[2])) found.Add(PrinterCondition.HeadOpen); + transfer = Flag(s2[4]); + // The ribbon sensor means nothing in direct thermal mode, so "ribbon out" only counts when a ribbon is in use. + if (Flag(s2[3]) && transfer == true) found.Add(PrinterCondition.RibbonOut); + mode = Mode(s2[5]); + if (Flag(s2[7])) found.Add(PrinterCondition.LabelWaiting); + remaining = long.TryParse(s2[8], NumberStyles.None, CultureInfo.InvariantCulture, out long r) ? r : null; + graphics = Int(s2[10]); + } + return new PrinterStatus + { + IsComplete = s1 is not null && s2 is not null, + Conditions = found.ToList(), + FormatsInBuffer = formats, + LabelsRemaining = remaining, + PartialFormat = partial, + LabelLengthDots = length, + ThermalTransfer = transfer, + PrintMode = mode, + GraphicsStored = graphics, + }; + } + + private static bool Flag(string field) => field == "1"; + + private static int? Int(string field) => int.TryParse(field, NumberStyles.None, CultureInfo.InvariantCulture, out int v) ? v : null; + + private static PrinterPrintMode Mode(string field) => field.ToUpperInvariant() switch + { + "0" => PrinterPrintMode.Rewind, + "1" => PrinterPrintMode.PeelOff, + "2" => PrinterPrintMode.TearOff, + "3" => PrinterPrintMode.Cutter, + "4" => PrinterPrintMode.Applicator, + "5" => PrinterPrintMode.DelayedCut, + "6" => PrinterPrintMode.LinerlessPeel, + "7" => PrinterPrintMode.LinerlessRewind, + "8" => PrinterPrintMode.PartialCutter, + "9" => PrinterPrintMode.Rfid, + "K" => PrinterPrintMode.Kiosk, + "S" => PrinterPrintMode.Stream, + _ => PrinterPrintMode.Unknown, + }; +} + +/// +/// Decodes the reply to the ZPL ~HS (host status) command. The printer answers with three strings, each framed by STX (0x02) and +/// ETX (0x03) and followed by CR LF: +/// +/// <STX>aaa,b,c,dddd,eee,f,g,h,iii,j,k,l<ETX> string 1: interface, paper out, pause, label length, formats in buffer, +/// buffer full, diagnostics mode, partial format, unused, corrupt RAM, +/// under temperature, over temperature +/// <STX>mmm,n,o,p,q,r,s,t,uuuuuuuu,v,www<ETX> string 2: function settings, unused, head up, ribbon out, thermal transfer, +/// print mode, print width mode, label waiting, labels remaining, +/// format while printing, graphics stored +/// <STX>xxxx,y<ETX> string 3: password, static RAM installed (not used by Rotula) +/// +/// +/// +/// Robustness: frames are recognised by their number of fields, so junk between frames, a missing string or strings in another order +/// do not matter. A frame without its ETX (a cut-off reply) or with a value that is not a number is ignored rather than guessed. +/// When the reply holds no STX at all (a gateway that strips control characters), each line is tried as a frame. When several +/// replies are in the buffer, the last complete one of each string wins. +/// +public static class HostStatusParser +{ + /// The command that asks for the reply, as sent to the printer. + public const string Command = "~HS"; + + private const char Stx = '\u0002'; + private const char Etx = '\u0003'; + + /// Decodes raw reply bytes (ASCII). Never throws. + public static PrinterStatus Parse(ReadOnlySpan reply) => Parse(Encoding.Latin1.GetString(reply)); + + /// Decodes a reply already read as text. Never throws; returns when nothing could be decoded. + public static PrinterStatus Parse(string? reply) + { + if (string.IsNullOrEmpty(reply)) return PrinterStatus.Unavailable(StatusUnavailableReason.NoReply); + string[]? s1 = null, s2 = null; + foreach (var frame in Frames(reply)) + { + var fields = frame.Split(',').Select(f => f.Trim()).ToArray(); + if (fields.Length == 12 && IsString1(fields)) s1 = fields; + else if (fields.Length == 11 && IsString2(fields)) s2 = fields; + } + return s1 is null && s2 is null ? PrinterStatus.Unavailable(StatusUnavailableReason.Unreadable) : PrinterStatus.Decoded(s1, s2); + } + + /// Counts the complete frames (STX..ETX) in a buffer, so a reader knows when all three strings have arrived. + public static int CompleteFrames(ReadOnlySpan buffer) + { + int count = 0; + bool open = false; + foreach (byte b in buffer) + { + if (b == 0x02) open = true; + else if (b == 0x03 && open) { count++; open = false; } + } + return count; + } + + private static IEnumerable Frames(string reply) + { + if (reply.IndexOf(Stx) < 0) + { + // No framing at all: try every line on its own. + foreach (var line in reply.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + yield return new string(line.Where(c => !char.IsControl(c)).ToArray()); + yield break; + } + int start = -1; + for (int i = 0; i < reply.Length; i++) + { + if (reply[i] == Stx) start = i + 1; // a second STX before an ETX restarts the frame: the first one was cut off + else if (reply[i] == Etx && start >= 0) + { + yield return reply[start..i]; + start = -1; + } + } + } + + private static bool IsString1(string[] f) => f.All(IsDigits); + + // The print mode (field 6) may be a letter (K, S); everything else is digits. The labels-remaining field is always eight digits, which + // also keeps a damaged string 1 that lost a field (11 numbers too) from being read as string 2. + private static bool IsString2(string[] f) => + f[8].Length == 8 && f.Select((v, i) => i == 5 ? v.Length == 1 && char.IsAsciiLetterOrDigit(v[0]) : IsDigits(v)).All(ok => ok); + + private static bool IsDigits(string s) => s.Length > 0 && s.All(char.IsAsciiDigit); +} diff --git a/src/Rotula.Printing/PrinterStatusReader.cs b/src/Rotula.Printing/PrinterStatusReader.cs new file mode 100644 index 0000000..1a728d3 --- /dev/null +++ b/src/Rotula.Printing/PrinterStatusReader.cs @@ -0,0 +1,141 @@ +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; + +namespace Rotula.Printing; + +/// +/// Sends ~HS and reads the reply: over TCP on the printer's raw port, or best effort through a Windows queue with the +/// spooler's bidirectional ReadPrinter. Every failure is an unavailable status; nothing here throws for a printer problem and no +/// call waits longer than its timeout. +/// +public sealed class PrinterStatusReader : IPrinterStatusReader +{ + private static readonly byte[] Query = Encoding.ASCII.GetBytes(HostStatusParser.Command); + + /// + public Task ReadStatusAsync(PrinterTarget target, TimeSpan timeout, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + cancellationToken.ThrowIfCancellationRequested(); + return target.Transport switch + { + PrinterTransport.Tcp => ReadTcpAsync(target.Host, target.Port, timeout, cancellationToken), + PrinterTransport.Spooler => ReadSpoolerAsync(target.PrinterName, timeout, cancellationToken), + _ => Task.FromResult(PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported)), + }; + } + + /// Connects, writes ~HS and reads until the three strings arrived, the printer closes, or the timeout. + internal static async Task ReadTcpAsync(string host, int port, TimeSpan timeout, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(host) || port is < 1 or > 65535) return PrinterStatus.Unavailable(StatusUnavailableReason.Unreachable); + // One budget for connect + write + read; a linked source so a timeout can be told apart from the caller's cancel. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var client = new TcpClient { NoDelay = true }; + var received = new MemoryStream(); + try + { + await client.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); + var stream = client.GetStream(); + await stream.WriteAsync(Query, cts.Token).ConfigureAwait(false); + await stream.FlushAsync(cts.Token).ConfigureAwait(false); + var buffer = new byte[512]; + while (HostStatusParser.CompleteFrames(received.GetBuffer().AsSpan(0, (int)received.Length)) < 3 && received.Length < 16 * 1024) + { + int n = await stream.ReadAsync(buffer, cts.Token).ConfigureAwait(false); + if (n == 0) break; // the printer closed the connection + received.Write(buffer, 0, n); + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // Timed out: a reply that already holds strings 1 and 2 is still worth decoding. + return Decode(received, StatusUnavailableReason.NoReply); + } + catch (SocketException) + { + return received.Length > 0 ? Decode(received, StatusUnavailableReason.Unreachable) : PrinterStatus.Unavailable(StatusUnavailableReason.Unreachable); + } + catch (IOException) + { + return Decode(received, StatusUnavailableReason.Unreachable); + } + return Decode(received, StatusUnavailableReason.NoReply); + } + + private static PrinterStatus Decode(MemoryStream received, StatusUnavailableReason whenEmpty) => + received.Length == 0 ? PrinterStatus.Unavailable(whenEmpty) : HostStatusParser.Parse(received.ToArray()); + + /// + /// The spooler path: a tiny RAW job with ~HS, then ReadPrinter on the same handle. Many USB and network port monitors + /// do not support reading, and a read can block inside the driver, so the work runs on its own thread and the caller stops waiting at + /// the timeout; a read that returns later just closes its handle. + /// + private static async Task ReadSpoolerAsync(string printerName, TimeSpan timeout, CancellationToken ct) + { + if (!OperatingSystem.IsWindows()) return PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported); + if (string.IsNullOrWhiteSpace(printerName)) return PrinterStatus.Unavailable(StatusUnavailableReason.Unreachable); + var deadline = DateTime.UtcNow + timeout; + var work = Task.Factory.StartNew(() => SpoolerQuery(printerName, deadline), CancellationToken.None, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + try + { + return await work.WaitAsync(timeout + TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false); + } + catch (TimeoutException) + { + return PrinterStatus.Unavailable(StatusUnavailableReason.NoReply); + } + } + + private static PrinterStatus SpoolerQuery(string printerName, DateTime deadline) + { + try + { + if (!NativeMethods.OpenPrinter(printerName, out var handle, IntPtr.Zero)) + return PrinterStatus.Unavailable(StatusUnavailableReason.Unreachable); + try + { + // Same reason as printing: a job for an unplugged printer would sit in the queue for ever. + if (WinspoolRawPrinter.IsQueueOffline(handle)) return PrinterStatus.Unavailable(StatusUnavailableReason.Offline); + var doc = new NativeMethods.DOC_INFO_1 { pDocName = "Rotula status", pOutputFile = null, pDatatype = "RAW" }; + if (NativeMethods.StartDocPrinter(handle, 1, ref doc) == 0) return PrinterStatus.Unavailable(StatusUnavailableReason.Unreachable); + bool written; + try + { + var pinned = GCHandle.Alloc(Query, GCHandleType.Pinned); + try { written = NativeMethods.WritePrinter(handle, pinned.AddrOfPinnedObject(), Query.Length, out int done) && done == Query.Length; } + finally { pinned.Free(); } + } + finally { NativeMethods.EndDocPrinter(handle); } + if (!written) return PrinterStatus.Unavailable(StatusUnavailableReason.Unreachable); + + var received = new MemoryStream(); + var buffer = new byte[512]; + var pin = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + while (DateTime.UtcNow < deadline && HostStatusParser.CompleteFrames(received.GetBuffer().AsSpan(0, (int)received.Length)) < 3) + { + if (!NativeMethods.ReadPrinter(handle, pin.AddrOfPinnedObject(), buffer.Length, out int read)) + { + // ERROR_NOT_SUPPORTED and friends: the port monitor cannot read back. Whatever arrived is still decoded. + return received.Length == 0 ? PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported) : HostStatusParser.Parse(received.ToArray()); + } + if (read > 0) received.Write(buffer, 0, read); + else Thread.Sleep(100); // nothing yet; the reply may still be on its way + } + } + finally { pin.Free(); } + return received.Length == 0 ? PrinterStatus.Unavailable(StatusUnavailableReason.NoReply) : HostStatusParser.Parse(received.ToArray()); + } + finally { NativeMethods.ClosePrinter(handle); } + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ExternalException) + { + return PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported); + } + } +} diff --git a/src/Rotula.Printing/WinspoolRawPrinter.cs b/src/Rotula.Printing/WinspoolRawPrinter.cs index 3994072..2536502 100644 --- a/src/Rotula.Printing/WinspoolRawPrinter.cs +++ b/src/Rotula.Printing/WinspoolRawPrinter.cs @@ -93,6 +93,9 @@ private static PrintResult SendCore(string printerName, string documentName, byt } } + /// True when Windows reports the open queue as offline; false when it is online or the state cannot be read. + internal static bool IsQueueOffline(IntPtr handle) => TryGetState(handle, out _, out _, out bool offline) && offline; + /// Reads status/attributes of an open printer. Returns false when the query itself fails (state unknown). private static bool TryGetState(IntPtr handle, out uint status, out uint attributes, out bool offline) { @@ -245,6 +248,10 @@ internal struct PRINTER_INFO_2 [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBuf, int cbBuf, out int pcWritten); + [DllImport("winspool.drv", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool ReadPrinter(IntPtr hPrinter, IntPtr pBuf, int cbBuf, out int pNoBytesRead); + [DllImport("winspool.drv", EntryPoint = "GetPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool GetPrinter(IntPtr hPrinter, int level, IntPtr pPrinter, int cbBuf, out int pcbNeeded); diff --git a/tests/Rotula.Tests/PrinterStatusTests.cs b/tests/Rotula.Tests/PrinterStatusTests.cs new file mode 100644 index 0000000..82165a4 --- /dev/null +++ b/tests/Rotula.Tests/PrinterStatusTests.cs @@ -0,0 +1,753 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Core.Variables.Counters; +using Rotula.Core.Zpl; +using Rotula.Printing; + +namespace Rotula.Tests; + +/// Realistic ~HS replies, built field by field so each test says what it changes. +internal static class HsSamples +{ + public const char Stx = '\u0002'; + public const char Etx = '\u0003'; + + /// String 1: aaa,b,c,dddd,eee,f,g,h,iii,j,k,l. + public static string S1(int paperOut = 0, int pause = 0, int length = 786, int formats = 0, int bufferFull = 0, int diag = 0, int partial = 0, + int corruptRam = 0, int under = 0, int over = 0) => + $"{Stx}030,{paperOut},{pause},{length:0000},{formats:000},{bufferFull},{diag},{partial},000,{corruptRam},{under},{over}{Etx}\r\n"; + + /// String 2: mmm,n,o,p,q,r,s,t,uuuuuuuu,v,www. + public static string S2(int headUp = 0, int ribbonOut = 0, int thermalTransfer = 0, char mode = '2', int labelWaiting = 0, long remaining = 0, int graphics = 0) => + $"{Stx}001,0,{headUp},{ribbonOut},{thermalTransfer},{mode},6,{labelWaiting},{remaining:00000000},1,{graphics:000}{Etx}\r\n"; + + /// String 3: password and static RAM flag. + public static string S3 => $"{Stx}1234,0{Etx}\r\n"; + + /// A whole reply of an idle, ready printer (direct thermal, tear-off). + public static string Ready => S1() + S2() + S3; +} + +/// Decoding of the host status reply: every flag, and replies that are cut off, framed oddly or not a reply at all. +public class PrinterStatusParserTests +{ + [Fact] + public void AReadyPrinter_IsAvailableCompleteIdleAndHasNoCondition() + { + var s = HostStatusParser.Parse(HsSamples.Ready); + Assert.True(s.IsAvailable); + Assert.Null(s.Reason); + Assert.True(s.IsComplete); + Assert.Empty(s.Conditions); + Assert.False(s.IsBlocked); + Assert.True(s.IsIdle); + Assert.Equal(0, s.FormatsInBuffer); + Assert.Equal(0, s.LabelsRemaining); + Assert.Equal(786, s.LabelLengthDots); + Assert.Equal(false, s.ThermalTransfer); + Assert.Equal(PrinterPrintMode.TearOff, s.PrintMode); + Assert.Equal(0, s.GraphicsStored); + } + + [Fact] + public void Bytes_DecodeLikeText() + { + var s = HostStatusParser.Parse(Encoding.ASCII.GetBytes(HsSamples.S1(paperOut: 1) + HsSamples.S2() + HsSamples.S3)); + Assert.Equal([PrinterCondition.PaperOut], s.Conditions); + } + + [Fact] + public void PaperOut_UsuallyComesWithPause_AndBothBlock_MostSeriousFirst() + { + var s = HostStatusParser.Parse(HsSamples.S1(paperOut: 1, pause: 1) + HsSamples.S2() + HsSamples.S3); + Assert.Equal([PrinterCondition.PaperOut, PrinterCondition.Paused], s.Conditions); + Assert.True(s.IsBlocked); + Assert.Equal(s.Conditions, s.Blocking); + Assert.Empty(s.Warnings); + } + + [Theory] + [InlineData("headUp", PrinterCondition.HeadOpen)] + [InlineData("over", PrinterCondition.HeadTooHot)] + [InlineData("diag", PrinterCondition.DiagnosticMode)] + [InlineData("bufferFull", PrinterCondition.BufferFull)] + [InlineData("pause", PrinterCondition.Paused)] + [InlineData("paperOut", PrinterCondition.PaperOut)] + public void EachBlockingFlag_IsDecoded(string flag, PrinterCondition expected) + { + string reply = flag switch + { + "headUp" => HsSamples.S1() + HsSamples.S2(headUp: 1), + "over" => HsSamples.S1(over: 1) + HsSamples.S2(), + "diag" => HsSamples.S1(diag: 1) + HsSamples.S2(), + "bufferFull" => HsSamples.S1(bufferFull: 1) + HsSamples.S2(), + "pause" => HsSamples.S1(pause: 1) + HsSamples.S2(), + _ => HsSamples.S1(paperOut: 1) + HsSamples.S2(), + }; + var s = HostStatusParser.Parse(reply + HsSamples.S3); + Assert.Equal([expected], s.Conditions); + Assert.True(s.IsBlocked); + Assert.True(PrinterStatus.IsBlockingCondition(expected)); + } + + [Fact] + public void RibbonOut_CountsOnlyInThermalTransferMode() + { + var transfer = HostStatusParser.Parse(HsSamples.S1() + HsSamples.S2(ribbonOut: 1, thermalTransfer: 1)); + Assert.Equal([PrinterCondition.RibbonOut], transfer.Conditions); + Assert.Equal(true, transfer.ThermalTransfer); + + // Direct thermal needs no ribbon, so the sensor flag must not stop the job. + var direct = HostStatusParser.Parse(HsSamples.S1() + HsSamples.S2(ribbonOut: 1, thermalTransfer: 0)); + Assert.Empty(direct.Conditions); + } + + [Fact] + public void NonBlockingFlags_AreWarnings() + { + var s = HostStatusParser.Parse(HsSamples.S1(under: 1, corruptRam: 1, partial: 1) + HsSamples.S2(labelWaiting: 1, mode: '1') + HsSamples.S3); + Assert.Equal([PrinterCondition.HeadTooCold, PrinterCondition.CorruptRam, PrinterCondition.PartialFormat, PrinterCondition.LabelWaiting], s.Conditions); + Assert.False(s.IsBlocked); + Assert.Equal(s.Conditions, s.Warnings); + Assert.Equal(PrinterPrintMode.PeelOff, s.PrintMode); + Assert.Equal(true, s.PartialFormat); + Assert.False(s.IsIdle); + } + + [Fact] + public void APrinterThatIsStillPrinting_IsNotIdle() + { + var s = HostStatusParser.Parse(HsSamples.S1(formats: 2) + HsSamples.S2(remaining: 37, graphics: 3) + HsSamples.S3); + Assert.Equal(2, s.FormatsInBuffer); + Assert.Equal(37, s.LabelsRemaining); + Assert.Equal(3, s.GraphicsStored); + Assert.False(s.IsIdle); + Assert.False(HostStatusParser.Parse(HsSamples.S1() + HsSamples.S2(remaining: 1)).IsIdle); + Assert.False(HostStatusParser.Parse(HsSamples.S1(formats: 1) + HsSamples.S2()).IsIdle); + } + + [Theory] + [InlineData('0', PrinterPrintMode.Rewind)] + [InlineData('3', PrinterPrintMode.Cutter)] + [InlineData('9', PrinterPrintMode.Rfid)] + [InlineData('K', PrinterPrintMode.Kiosk)] + [InlineData('s', PrinterPrintMode.Stream)] + [InlineData('Z', PrinterPrintMode.Unknown)] + public void PrintMode_IsDecoded_LettersIncluded(char code, PrinterPrintMode expected) => + Assert.Equal(expected, HostStatusParser.Parse(HsSamples.S1() + HsSamples.S2(mode: code)).PrintMode); + + [Fact] + public void JunkAroundAndBetweenFrames_IsIgnored() + { + string reply = "garbage\r\n\u0006" + HsSamples.S1(paperOut: 1) + "xx\u0002not,a,status\u0003" + HsSamples.S2(headUp: 1) + "\0\0" + HsSamples.S3 + "trailing"; + var s = HostStatusParser.Parse(reply); + Assert.True(s.IsComplete); + Assert.Equal([PrinterCondition.HeadOpen, PrinterCondition.PaperOut], s.Conditions); + } + + [Fact] + public void StringsInAnotherOrder_StillDecode() + { + var s = HostStatusParser.Parse(HsSamples.S3 + HsSamples.S2(remaining: 5) + HsSamples.S1(formats: 1)); + Assert.True(s.IsComplete); + Assert.Equal(1, s.FormatsInBuffer); + Assert.Equal(5, s.LabelsRemaining); + } + + [Fact] + public void ACutOffReply_KeepsWhatIsWhole_AndIsNeverIdle() + { + // String 2 lost its end (no ETX): only string 1 counts, so head and batch are unknown. + string reply = HsSamples.S1() + "\u0002001,0,1,0,0,2,6,0,000"; + var s = HostStatusParser.Parse(reply); + Assert.True(s.IsAvailable); + Assert.False(s.IsComplete); + Assert.Null(s.LabelsRemaining); + Assert.Null(s.ThermalTransfer); + Assert.DoesNotContain(PrinterCondition.HeadOpen, s.Conditions); + Assert.False(s.IsIdle); + } + + [Fact] + public void OnlyString2_StillReportsTheHead() + { + var s = HostStatusParser.Parse(HsSamples.S2(headUp: 1)); + Assert.True(s.IsAvailable); + Assert.False(s.IsComplete); + Assert.Equal([PrinterCondition.HeadOpen], s.Conditions); + Assert.Null(s.FormatsInBuffer); + Assert.False(s.IsIdle); + } + + [Fact] + public void AnSTXInsideAnOpenFrame_RestartsTheFrame() + { + string reply = "\u0002030,0,0" + HsSamples.S1(paperOut: 1) + HsSamples.S2(); + Assert.Equal([PrinterCondition.PaperOut], HostStatusParser.Parse(reply).Conditions); + } + + [Fact] + public void ARepliesWithoutControlCharacters_AreReadLineByLine() + { + string stripped = HsSamples.Ready.Replace("\u0002", "").Replace("\u0003", ""); + var s = HostStatusParser.Parse(stripped); + Assert.True(s.IsComplete); + Assert.True(s.IsIdle); + } + + [Fact] + public void TwoReplies_TheLastOneWins() + { + string reply = HsSamples.S1(formats: 1) + HsSamples.S2(remaining: 4) + HsSamples.S3 + HsSamples.Ready; + Assert.True(HostStatusParser.Parse(reply).IsIdle); + } + + [Fact] + public void SpacesAroundFields_AreAccepted() + { + var s = HostStatusParser.Parse("\u0002030, 1,0,0786,000,0,0,0,000,0,0,0\u0003"); + Assert.Equal([PrinterCondition.PaperOut], s.Conditions); + } + + [Theory] + [InlineData("hello printer")] + [InlineData("\u0002ERROR\u0003")] + [InlineData("\u0002030,X,0,0786,000,0,0,0,000,0,0,0\u0003")] // a flag that is not a number + [InlineData("\u0002030,0,0,0786,000,0,0,0,000,0,0\u0003")] // a field short + [InlineData("\u0002001,0,0,0,0,22,6,0,00000000,1,000\u0003")] // print mode of two characters + public void Garbage_IsUnreadable_NeverAFault(string reply) + { + var s = HostStatusParser.Parse(reply); + Assert.False(s.IsAvailable); + Assert.Equal(StatusUnavailableReason.Unreadable, s.Reason); + Assert.Empty(s.Conditions); + Assert.False(s.IsBlocked); + Assert.False(s.IsIdle); + } + + [Fact] + public void NothingAtAll_IsNoReply() + { + Assert.Equal(StatusUnavailableReason.NoReply, HostStatusParser.Parse((string?)null).Reason); + Assert.Equal(StatusUnavailableReason.NoReply, HostStatusParser.Parse("").Reason); + Assert.Equal(StatusUnavailableReason.NoReply, HostStatusParser.Parse(ReadOnlySpan.Empty).Reason); + } + + [Fact] + public void CompleteFrames_CountsOnlyClosedFrames() + { + Assert.Equal(3, HostStatusParser.CompleteFrames(Encoding.ASCII.GetBytes(HsSamples.Ready))); + Assert.Equal(1, HostStatusParser.CompleteFrames(Encoding.ASCII.GetBytes(HsSamples.S1() + "\u0002001,0"))); + Assert.Equal(0, HostStatusParser.CompleteFrames(Encoding.ASCII.GetBytes("\u0003\u0003"))); + } + + [Fact] + public void TheErrorOfABlockedPrinter_NamesTheWorstConditionAndListsAll() + { + var s = HostStatusParser.Parse(HsSamples.S1(paperOut: 1, pause: 1, under: 1) + HsSamples.S2(headUp: 1)); + var e = PrintError.FromStatus(s, "Zebra ZD420")!; + Assert.Equal(PrintErrorKind.HeadOpen, e.Kind); + Assert.Equal("Print.Error.HeadOpen", e.Key); + Assert.Equal(["Zebra ZD420", "HeadOpen", "PaperOut", "Paused", "HeadTooCold"], e.Args); + Assert.Null(PrintError.FromStatus(HostStatusParser.Parse(HsSamples.S1(under: 1)), "p")); + Assert.Null(PrintError.FromStatus(PrinterStatus.Unavailable(StatusUnavailableReason.NoReply), "p")); + } + + [Theory] + [InlineData(PrinterCondition.HeadOpen, PrintErrorKind.HeadOpen)] + [InlineData(PrinterCondition.PaperOut, PrintErrorKind.PaperOut)] + [InlineData(PrinterCondition.RibbonOut, PrintErrorKind.RibbonOut)] + [InlineData(PrinterCondition.HeadTooHot, PrintErrorKind.HeadTooHot)] + [InlineData(PrinterCondition.DiagnosticMode, PrintErrorKind.DiagnosticMode)] + [InlineData(PrinterCondition.BufferFull, PrintErrorKind.BufferFull)] + [InlineData(PrinterCondition.Paused, PrintErrorKind.Paused)] + public void EveryBlockingCondition_HasItsOwnErrorKind(PrinterCondition condition, PrintErrorKind kind) + { + string reply = condition switch + { + PrinterCondition.HeadOpen => HsSamples.S1() + HsSamples.S2(headUp: 1), + PrinterCondition.PaperOut => HsSamples.S1(paperOut: 1) + HsSamples.S2(), + PrinterCondition.RibbonOut => HsSamples.S1() + HsSamples.S2(ribbonOut: 1, thermalTransfer: 1), + PrinterCondition.HeadTooHot => HsSamples.S1(over: 1) + HsSamples.S2(), + PrinterCondition.DiagnosticMode => HsSamples.S1(diag: 1) + HsSamples.S2(), + PrinterCondition.BufferFull => HsSamples.S1(bufferFull: 1) + HsSamples.S2(), + _ => HsSamples.S1(pause: 1) + HsSamples.S2(), + }; + Assert.Equal(kind, PrintError.FromStatus(HostStatusParser.Parse(reply), "p")!.Kind); + } +} + +/// A status reader that answers from a script and records how often it was asked. +internal sealed class ScriptedStatusReader : IPrinterStatusReader +{ + private readonly Func _script; + + public ScriptedStatusReader(Func script) => _script = script; + + public ScriptedStatusReader(params string[] replies) : this(n => HostStatusParser.Parse(replies[Math.Min(n, replies.Length) - 1])) { } + + public int Calls { get; private set; } + + /// Runs on each call, for example to cancel the caller in the middle of a verification. + public Action? OnCall { get; set; } + + public Task ReadStatusAsync(PrinterTarget target, TimeSpan timeout, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Calls++; + OnCall?.Invoke(Calls); + return Task.FromResult(_script(Calls)); + } +} + +/// A spooler and network fake that records every job as text. +internal sealed class StatusTestPrinter : IRawPrinter, INetworkPrinter, IPrinterDiscovery +{ + public List Sent { get; } = new(); + + public Task SendAsync(string printerName, string documentName, byte[] data, CancellationToken cancellationToken = default) + { + Sent.Add(Encoding.UTF8.GetString(data)); + return Task.FromResult(PrintResult.Ok(data.Length)); + } + + public Task SendAsync(string host, int port, byte[] data, NetworkPrinterOptions options, CancellationToken cancellationToken = default) + { + Sent.Add(Encoding.UTF8.GetString(data)); + return Task.FromResult(PrintResult.Ok(data.Length)); + } + + public Task> GetPrintersAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); +} + +/// The print service's check before a job and verification after it, against fakes (nothing is printed). +public class PrinterStatusServiceTests +{ + private static readonly PrinterStatusOptions Fast = PrinterStatusOptions.Default with + { + QueryTimeout = TimeSpan.FromMilliseconds(200), + VerifyTimeout = TimeSpan.FromMilliseconds(300), + PollInterval = TimeSpan.FromMilliseconds(5), + }; + + private static readonly PrinterTarget Tcp = PrinterTarget.TcpIp("192.168.1.50").WithStatus(Fast); + + private static (PrintService Svc, StatusTestPrinter Printer) Make(IPrinterStatusReader reader) + { + var printer = new StatusTestPrinter(); + return (new PrintService(printer, printer, printer, reader), printer); + } + + private static LabelDocument Doc() + { + var d = new LabelDocument { Width = Measure.Millimeters(50), Height = Measure.Millimeters(30) }; + d.Elements.Add(new TextElement { X = 10, Y = 10, Text = "Hola" }); + return d; + } + + private static LabelDocument CounterLabel(int copies) + { + var doc = Doc(); + doc.EnsureId(); + doc.Variables.Add(Vars.Counter("serial", c => { c.Remember = true; c.Padding = 4; c.Prefix = "SN"; })); + doc.Elements.Add(new TextElement { X = 10, Y = 40, Text = "{{serial}}" }); + doc.Print.Quantity = copies; + return doc; + } + + private static string[] Numbers(IEnumerable jobs) => jobs.SelectMany(j => Regex.Matches(j, @"SN\d{4}").Select(m => m.Value)).ToArray(); + + [Fact] + public async Task WithoutStatusOptions_ThePrinterIsNeverAsked() + { + var reader = new ScriptedStatusReader(HsSamples.S1(paperOut: 1)); + var (svc, printer) = Make(reader); + var r = await svc.PrintAsync(PrinterTarget.TcpIp("192.168.1.50"), Doc()); + Assert.True(r.Succeeded); + Assert.Equal(0, reader.Calls); + Assert.Single(printer.Sent); + Assert.Same(PrinterStatusOptions.Off, PrinterTarget.Spooler("p").Status); + } + + [Theory] + [InlineData(PrintErrorKind.PaperOut)] + [InlineData(PrintErrorKind.HeadOpen)] + [InlineData(PrintErrorKind.Paused)] + public async Task ABlockingFault_SendsNothing_AndSaysWhy(PrintErrorKind kind) + { + string reply = kind switch + { + PrintErrorKind.PaperOut => HsSamples.S1(paperOut: 1, pause: 1) + HsSamples.S2(), + PrintErrorKind.HeadOpen => HsSamples.S1() + HsSamples.S2(headUp: 1), + _ => HsSamples.S1(pause: 1) + HsSamples.S2(), + }; + var (svc, printer) = Make(new ScriptedStatusReader(reply)); + var r = await svc.PrintAsync(Tcp, Doc()); + Assert.False(r.Succeeded); + Assert.Equal(kind, r.Error!.Kind); + Assert.Equal("Print.Error." + kind, r.Error.Key); + Assert.Equal("192.168.1.50:9100", r.Error.Args[0]); + Assert.Equal(0, r.BytesSent); + Assert.Empty(printer.Sent); + Assert.False(LabelPrintRun.MayHavePrinted(r)); + } + + [Fact] + public async Task AnUnknownStatus_DoesNotBlock_ButSaysTheCheckWasSkipped() + { + var reader = new ScriptedStatusReader(_ => PrinterStatus.Unavailable(StatusUnavailableReason.NoReply)); + var (svc, printer) = Make(reader); + var r = await svc.PrintAsync(Tcp.WithStatus(Fast with { VerifyAfterPrinting = false }), Doc()); + Assert.True(r.Succeeded); + Assert.Single(printer.Sent); + var n = Assert.Single(r.Notices); + Assert.Equal(PrinterStatusKeys.NotChecked, n.MessageKey); + Assert.Equal(["NoReply"], n.Args); + } + + [Fact] + public async Task AReaderThatThrows_IsTreatedAsNoStatus() + { + var (svc, printer) = Make(new ScriptedStatusReader(_ => throw new InvalidOperationException("broken"))); + var r = await svc.PrintAsync(Tcp.WithStatus(Fast with { VerifyAfterPrinting = false }), Doc()); + Assert.True(r.Succeeded); + Assert.Single(printer.Sent); + Assert.Equal(PrinterStatusKeys.NotChecked, Assert.Single(r.Notices).MessageKey); + } + + [Fact] + public async Task AWarningCondition_IsSentWithANotice() + { + var (svc, printer) = Make(new ScriptedStatusReader(HsSamples.S1(under: 1) + HsSamples.S2())); + var r = await svc.PrintAsync(Tcp.WithStatus(Fast with { VerifyAfterPrinting = false }), Doc()); + Assert.True(r.Succeeded); + Assert.Single(printer.Sent); + var n = Assert.Single(r.Notices); + Assert.Equal(PrinterStatusKeys.Warning, n.MessageKey); + Assert.Equal(["HeadTooCold"], n.Args); + } + + [Fact] + public async Task Verification_WaitsUntilThePrinterFinished() + { + // Ready before, then still printing twice, then idle. + var reader = new ScriptedStatusReader(HsSamples.Ready, HsSamples.S1(formats: 1) + HsSamples.S2(remaining: 3), HsSamples.S1() + HsSamples.S2(remaining: 1), HsSamples.Ready); + var (svc, printer) = Make(reader); + var r = await svc.PrintAsync(Tcp, Doc()); + Assert.True(r.Succeeded); + Assert.Single(printer.Sent); + Assert.Equal(4, reader.Calls); + Assert.Equal(PrinterStatusKeys.Verified, Assert.Single(r.Notices).MessageKey); + } + + [Fact] + public async Task AFaultAfterTheJob_IsANotice_TheResultStaysSent() + { + var reader = new ScriptedStatusReader(HsSamples.Ready, HsSamples.S1(paperOut: 1, pause: 1, formats: 1) + HsSamples.S2(remaining: 2)); + var (svc, _) = Make(reader); + var r = await svc.PrintAsync(Tcp, Doc()); + Assert.True(r.Succeeded); + var n = Assert.Single(r.Notices); + Assert.Equal(PrinterStatusKeys.FaultAfterJob, n.MessageKey); + Assert.Equal(["PaperOut", "Paused"], n.Args); + } + + [Fact] + public async Task APrinterThatNeverFinishes_IsNotVerified_WithinTheTimeout() + { + var reader = new ScriptedStatusReader(HsSamples.Ready, HsSamples.S1(formats: 1) + HsSamples.S2(remaining: 9)); + var (svc, _) = Make(reader); + var watch = System.Diagnostics.Stopwatch.StartNew(); + var r = await svc.PrintAsync(Tcp, Doc()); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5)); + Assert.True(r.Succeeded); + var n = Assert.Single(r.Notices); + Assert.Equal(PrinterStatusKeys.NotVerified, n.MessageKey); + Assert.Equal([PrinterStatusKeys.StillPrinting], n.Args); + } + + [Fact] + public async Task ATransportThatCannotRead_StopsVerifyingAtOnce() + { + var reader = new ScriptedStatusReader(_ => PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported)); + var (svc, _) = Make(reader); + var r = await svc.PrintAsync(PrinterTarget.Spooler("USB printer").WithStatus(Fast with { VerifyTimeout = TimeSpan.FromMinutes(5) }), Doc()); + Assert.True(r.Succeeded); + Assert.Equal(2, reader.Calls); // one before, one after + Assert.Collection(r.Notices, + n => Assert.Equal(PrinterStatusKeys.NotChecked, n.MessageKey), + n => { Assert.Equal(PrinterStatusKeys.NotVerified, n.MessageKey); Assert.Equal(["NotSupported"], n.Args); }); + } + + [Fact] + public async Task StoppingTheVerification_KeepsTheResultSent() + { + using var cts = new CancellationTokenSource(); + var reader = new ScriptedStatusReader(HsSamples.Ready, HsSamples.S1(formats: 1) + HsSamples.S2(remaining: 9)) { OnCall = n => { if (n == 2) cts.Cancel(); } }; + var (svc, printer) = Make(reader); + var r = await svc.PrintAsync(Tcp.WithStatus(Fast with { VerifyTimeout = TimeSpan.FromMinutes(5) }), Doc(), null, null, cts.Token); + Assert.True(r.Succeeded); + Assert.Single(printer.Sent); + var n = Assert.Single(r.Notices); + Assert.Equal(PrinterStatusKeys.NotVerified, n.MessageKey); + Assert.Equal([PrinterStatusKeys.Stopped], n.Args); + } + + [Fact] + public async Task CancellingDuringTheCheck_SendsNothing() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var (svc, printer) = Make(new ScriptedStatusReader(HsSamples.Ready)); + var r = await svc.PrintZplAsync(Tcp, "^XA^XZ", ZplEncoding.Utf8, cts.Token); + Assert.Equal(PrintErrorKind.Cancelled, r.Error!.Kind); + Assert.Empty(printer.Sent); + } + + [Fact] + public async Task ABlockedPrinter_GivesTheReservedSerialNumbersBack() + { + var store = new MemoryCounterStore(); + var doc = CounterLabel(copies: 1); + var reader = new ScriptedStatusReader(HsSamples.S1(paperOut: 1) + HsSamples.S2(), HsSamples.Ready); + var (svc, printer) = Make(reader); + var options = new PrintVariableOptions { CounterStore = store }; + var target = Tcp.WithStatus(Fast with { VerifyAfterPrinting = false }); + + Assert.Equal(PrintErrorKind.PaperOut, (await svc.PrintAsync(target, doc, null, null, options)).Error!.Kind); + Assert.True((await svc.PrintAsync(target, doc, null, null, options)).Succeeded); + + // The held-back label printed nothing, so SN0001 was never used and goes on the next label. + Assert.Equal(["SN0001"], Numbers(printer.Sent)); + } + + [Fact] + public async Task AFaultInTheMiddleOfARun_StopsBeforeTheNextBlock_AndKeepsOnlyWhatWasSent() + { + var store = new MemoryCounterStore(); + var doc = CounterLabel(copies: 5); + // Blocks of two labels: the first is checked and sent, then the printer runs out of labels. + var reader = new ScriptedStatusReader(HsSamples.Ready, HsSamples.S1(paperOut: 1) + HsSamples.S2()); + var (svc, printer) = Make(reader); + var options = new PrintVariableOptions { CounterStore = store, LabelsPerJob = 2 }; + + var r = await svc.PrintAsync(Tcp, doc, null, null, options); + Assert.Equal(PrintErrorKind.PaperOut, r.Error!.Kind); + Assert.Equal(["SN0001", "SN0002"], Numbers(printer.Sent)); + Assert.Equal("SN0002", CounterAdmin.Peek(doc, "serial", store).LastValue); + } + + [Fact] + public async Task AFaultAfterTheJob_StillCountsTheLabelsAsPrinted() + { + var store = new MemoryCounterStore(); + var doc = CounterLabel(copies: 1); + var reader = new ScriptedStatusReader(HsSamples.Ready, HsSamples.S1(paperOut: 1) + HsSamples.S2(), HsSamples.Ready, HsSamples.Ready); + var (svc, printer) = Make(reader); + var options = new PrintVariableOptions { CounterStore = store }; + + var first = await svc.PrintAsync(Tcp, doc, null, null, options); + Assert.Equal(PrinterStatusKeys.FaultAfterJob, Assert.Single(first.Notices).MessageKey); + await svc.PrintAsync(Tcp, doc, null, null, options); + + // The label may have come out before the fault: its number is never printed again. + Assert.Equal(["SN0001", "SN0002"], Numbers(printer.Sent)); + } + + [Fact] + public async Task ReadyMadeZpl_IsCheckedAndVerifiedToo() + { + var blocked = Make(new ScriptedStatusReader(HsSamples.S1() + HsSamples.S2(ribbonOut: 1, thermalTransfer: 1))); + var r = await blocked.Svc.PrintZplAsync(Tcp, "^XA^XZ", ZplEncoding.Utf8, Array.Empty()); + Assert.Equal(PrintErrorKind.RibbonOut, r.Error!.Kind); + Assert.Empty(blocked.Printer.Sent); + + var fine = Make(new ScriptedStatusReader(HsSamples.Ready)); + var ok = await fine.Svc.PrintZplAsync(Tcp, "^XA^XZ", ZplEncoding.Utf8); + Assert.True(ok.Succeeded); + Assert.Equal(PrinterStatusKeys.Verified, Assert.Single(ok.Notices).MessageKey); + } + + [Fact] + public async Task ApplyingTheProfile_DoesNotAskForTheStatus() + { + var reader = new ScriptedStatusReader(HsSamples.S1(paperOut: 1)); + var (svc, printer) = Make(reader); + var r = await svc.ApplyProfileAsync(Tcp, new PrinterProfile()); + Assert.True(r.Succeeded); + Assert.Equal(0, reader.Calls); + Assert.Single(printer.Sent); + } + + [Fact] + public async Task GetStatus_GoesThroughTheReader() + { + var (svc, _) = Make(new ScriptedStatusReader(HsSamples.S1(paperOut: 1) + HsSamples.S2())); + var s = await svc.GetStatusAsync(PrinterTarget.TcpIp("10.0.0.5")); + Assert.Equal([PrinterCondition.PaperOut], s.Conditions); + } + + [Fact] + public async Task VerifyPrinted_CanBeCalledOnceAfterSeveralJobs() + { + var reader = new ScriptedStatusReader(HsSamples.S1(formats: 3) + HsSamples.S2(remaining: 2), HsSamples.Ready); + var (svc, _) = Make(reader); + var notes = await svc.VerifyPrintedAsync(Tcp); + Assert.Equal(PrinterStatusKeys.Verified, Assert.Single(notes).MessageKey); + } + + [Fact] + public void Targets_CarryTheirStatusOptions_AndAName() + { + var t = PrinterTarget.TcpIp("printer.local", 6101).WithStatus(PrinterStatusOptions.Default); + Assert.True(t.Status.CheckBeforePrinting); + Assert.True(t.Status.Enabled); + Assert.Equal("printer.local:6101", t.DisplayName); + Assert.Equal("Zebra ZD420", PrinterTarget.Spooler("Zebra ZD420").DisplayName); + Assert.False(PrinterStatusOptions.Off.Enabled); + Assert.Same(PrinterStatusOptions.Off, t.WithStatus(null!).Status); + } +} + +/// The real status reader against an in-process listener (no printer, no network beyond loopback). +public class PrinterStatusTcpTests +{ + private static async Task<(TcpListener Listener, int Port)> Listen() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + await Task.Yield(); + return (l, ((IPEndPoint)l.LocalEndpoint).Port); + } + + private static async Task ReadQuery(NetworkStream s) + { + var buf = new byte[3]; + int got = 0; + while (got < 3) + { + int n = await s.ReadAsync(buf.AsMemory(got)); + if (n == 0) break; + got += n; + } + return Encoding.ASCII.GetString(buf, 0, got); + } + + [Fact] + public async Task SendsTildeHS_AndDecodesTheReply() + { + var (listener, port) = await Listen(); + using var _ = listener; + var server = Task.Run(async () => + { + using var c = await listener.AcceptTcpClientAsync(); + var s = c.GetStream(); + string query = await ReadQuery(s); + // Answer in two pieces, as a real printer may. + byte[] reply = Encoding.ASCII.GetBytes(HsSamples.S1(formats: 1) + HsSamples.S2(remaining: 4) + HsSamples.S3); + await s.WriteAsync(reply.AsMemory(0, 20)); + await Task.Delay(30); + await s.WriteAsync(reply.AsMemory(20)); + // Keep the connection open: the reader must stop on the third frame, not wait for the close. + await Task.Delay(2000); + return query; + }); + + var status = await new PrinterStatusReader().ReadStatusAsync(PrinterTarget.TcpIp("127.0.0.1", port), TimeSpan.FromSeconds(1.5)); + Assert.True(status.IsComplete); + Assert.Equal(1, status.FormatsInBuffer); + Assert.Equal(4, status.LabelsRemaining); + Assert.Equal("~HS", await server.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Fact] + public async Task ASilentPrinter_IsNoReply_WithinTheTimeout() + { + var (listener, port) = await Listen(); + using var _ = listener; + var accepted = listener.AcceptTcpClientAsync(); + var watch = System.Diagnostics.Stopwatch.StartNew(); + var status = await new PrinterStatusReader().ReadStatusAsync(PrinterTarget.TcpIp("127.0.0.1", port), TimeSpan.FromMilliseconds(300)); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(3)); + Assert.Equal(StatusUnavailableReason.NoReply, status.Reason); + (await accepted).Dispose(); + } + + [Fact] + public async Task APartialReplyBeforeTheTimeout_IsStillDecoded() + { + var (listener, port) = await Listen(); + using var _ = listener; + var server = Task.Run(async () => + { + using var c = await listener.AcceptTcpClientAsync(); + var s = c.GetStream(); + await ReadQuery(s); + await s.WriteAsync(Encoding.ASCII.GetBytes(HsSamples.S1(paperOut: 1))); + await Task.Delay(2000); + }); + var status = await new PrinterStatusReader().ReadStatusAsync(PrinterTarget.TcpIp("127.0.0.1", port), TimeSpan.FromMilliseconds(400)); + Assert.True(status.IsAvailable); + Assert.False(status.IsComplete); + Assert.Equal([PrinterCondition.PaperOut], status.Conditions); + } + + [Fact] + public async Task APrinterThatClosesWithoutAnswering_IsNoReply() + { + var (listener, port) = await Listen(); + using var _ = listener; + var server = Task.Run(async () => + { + using var c = await listener.AcceptTcpClientAsync(); + await ReadQuery(c.GetStream()); + }); + var status = await new PrinterStatusReader().ReadStatusAsync(PrinterTarget.TcpIp("127.0.0.1", port), TimeSpan.FromSeconds(2)); + Assert.False(status.IsAvailable); + Assert.True(status.Reason is StatusUnavailableReason.NoReply or StatusUnavailableReason.Unreachable); + await server.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task NothingListening_IsUnreachable() + { + var (listener, port) = await Listen(); + listener.Stop(); + var status = await new PrinterStatusReader().ReadStatusAsync(PrinterTarget.TcpIp("127.0.0.1", port), TimeSpan.FromSeconds(2)); + Assert.Equal(StatusUnavailableReason.Unreachable, status.Reason); + } + + [Fact] + public async Task ABadAddress_IsUnreachable_WithoutConnecting() + { + var status = await new PrinterStatusReader().ReadStatusAsync(PrinterTarget.TcpIp(" ", 9100), TimeSpan.FromSeconds(1)); + Assert.Equal(StatusUnavailableReason.Unreachable, status.Reason); + } + + [Fact] + public async Task Cancelling_Throws() + { + var (listener, port) = await Listen(); + using var _ = listener; + var accepted = listener.AcceptTcpClientAsync(); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThrowsAnyAsync(() => + new PrinterStatusReader().ReadStatusAsync(PrinterTarget.TcpIp("127.0.0.1", port), TimeSpan.FromSeconds(10), cts.Token)); + (await accepted).Dispose(); + } + + [Fact] + public async Task AQueueThatDoesNotExist_FailsCleanly_AndQuickly() + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + var status = await new PrinterStatusReader().ReadStatusAsync(PrinterTarget.Spooler("Rotula test queue " + Guid.NewGuid().ToString("N")), TimeSpan.FromSeconds(2)); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5)); + Assert.False(status.IsAvailable); + Assert.True(status.Reason is StatusUnavailableReason.NotSupported or StatusUnavailableReason.Unreachable, status.Reason.ToString()); + } +} From cdd509a7e0e4e59eff0ef6ae0be5d19533fcf6de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:16:06 +0000 Subject: [PATCH 02/33] feat(app): show the printer status check in the Print dialog and printer settings Adds a per-transport switch (on for network printers, off for Windows printers), a Check printer status button that shows the decoded ~HS reply, plain-language messages for a held-back job and for the verification after it, and a single verification after the last job of a batch print. All new texts are in English and Spanish. --- src/Rotula.App/Resources/Strings.en.resx | 177 +++++++++++ src/Rotula.App/Resources/Strings.es.resx | 177 +++++++++++ src/Rotula.App/Services/AppSettings.cs | 9 + .../ViewModels/DataMergeViewModel.cs | 17 +- src/Rotula.App/ViewModels/PrintMessages.cs | 58 +++- src/Rotula.App/ViewModels/PrintViewModel.cs | 5 +- .../ViewModels/PrinterSettingsViewModel.cs | 109 ++++++- src/Rotula.App/Views/PrinterSettingsView.xaml | 14 + tests/Rotula.Tests/AppLocalizationTests.cs | 1 + tests/Rotula.Tests/AppPrinterStatusTests.cs | 282 ++++++++++++++++++ tests/Rotula.Tests/AppTestSupport.cs | 20 ++ 11 files changed, 861 insertions(+), 8 deletions(-) create mode 100644 tests/Rotula.Tests/AppPrinterStatusTests.cs diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx index 6e0d2c7..720cf15 100644 --- a/src/Rotula.App/Resources/Strings.en.resx +++ b/src/Rotula.App/Resources/Strings.en.resx @@ -5846,4 +5846,181 @@ Brand and product names belong to their owners; Rotula is not affiliated with th Locked: this object cannot be moved, resized or deleted until you unlock it. + + Nothing was sent: the printer "{0}" reports that its printhead is open. Close the head until it clicks, then print again. + + + Nothing was sent: the printer "{0}" reports that it is out of labels. Load labels, close the head, press Feed once, then print again. + + + Nothing was sent: the printer "{0}" reports that the ribbon is out. Load a new ribbon, close the head, then print again. + + + Nothing was sent: the printer "{0}" reports that its printhead is too hot. Wait a few minutes for it to cool down, then print again. + + + Nothing was sent: the printer "{0}" is in communications diagnostics mode and would print the data as text instead of labels. Switch the mode off on the printer (or restart it), then print again. + + + Nothing was sent: the printer "{0}" reports that its memory for incoming jobs is full. Wait until it has printed what it already has, then print again. + + + Nothing was sent: the printer "{0}" is paused. Press Pause on the printer to resume, then print again. + + + The printer also reports: {0}. + + + The printer status could not be read before printing ({0}), so the labels were sent without that check. + + + The printer reports: {0}. The labels were sent; check the print quality. + + + Printed and verified: the printer reports it finished the job without problems. + + + The labels were sent, but then the printer reported a problem: {0}. Some labels may not have printed. Fix the printer and check the labels before printing again. + + + The labels were sent, but Rotula could not confirm that they were printed ({0}). Check the printer. + + + this printer or its Windows driver does not answer status requests, which is common with USB drivers + + + Windows reports the printer as offline + + + the printer could not be reached + + + the printer did not answer in time + + + the printer's answer was not a ZPL status reply + + + it was still printing when Rotula stopped waiting + + + the check was stopped + + + the reason is unknown + + + printhead open + + + out of labels + + + ribbon out + + + printhead too hot + + + communications diagnostics mode on + + + memory for incoming jobs full + + + paused + + + printhead too cold (labels may print lighter) + + + settings lost (the printer is using its defaults) + + + a job is still arriving + + + a printed label is waiting to be taken + + + unknown + + + rewind + + + peel-off + + + tear-off + + + cutter + + + applicator + + + delayed cut + + + linerless peel + + + linerless rewind + + + partial cutter + + + RFID + + + kiosk + + + stream + + + Check printer status + + + Asks the printer for its status (paper, head, ribbon, pause) with a small ZPL command. Nothing is printed on a ZPL compatible printer. + + + Asking the printer for its status... + + + The printer is ready. + + + The printer cannot print right now: {0}. + + + The printer reports: {0}. + + + The printer status could not be read: {0}. You can still print. + + + Only part of the status was received, so some problems may not be listed. + + + Labels still to print: {0}. Jobs waiting in the printer: {1}. + + + Print mode: {0}. {1}. Label length: {2} dots. + + + Thermal transfer (with ribbon) + + + Direct thermal (no ribbon) + + + Check the printer before and after printing + + + Before each job Rotula asks the printer for its status and does not send while it is out of labels, open, paused or out of ribbon. After the job it waits until the printer reports it finished. For ZPL compatible printers; many USB drivers cannot answer, and then printing goes ahead without the check. + diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx index 653f841..dd6c250 100644 --- a/src/Rotula.App/Resources/Strings.es.resx +++ b/src/Rotula.App/Resources/Strings.es.resx @@ -5846,4 +5846,181 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a Bloqueado: este objeto no se puede mover, cambiar de tamaño ni borrar hasta que lo desbloquee. + + No se envió nada: la impresora "{0}" indica que el cabezal está abierto. Cierre el cabezal hasta que haga clic y vuelva a imprimir. + + + No se envió nada: la impresora "{0}" indica que no tiene etiquetas. Cargue etiquetas, cierre el cabezal, pulse Avance una vez y vuelva a imprimir. + + + No se envió nada: la impresora "{0}" indica que se acabó la cinta. Coloque una cinta nueva, cierre el cabezal y vuelva a imprimir. + + + No se envió nada: la impresora "{0}" indica que el cabezal está demasiado caliente. Espere unos minutos a que se enfríe y vuelva a imprimir. + + + No se envió nada: la impresora "{0}" está en modo de diagnóstico de comunicaciones e imprimiría los datos como texto en lugar de etiquetas. Desactive ese modo en la impresora (o reiníciela) y vuelva a imprimir. + + + No se envió nada: la impresora "{0}" indica que su memoria para trabajos entrantes está llena. Espere a que imprima lo que ya tiene y vuelva a imprimir. + + + No se envió nada: la impresora "{0}" está en pausa. Pulse Pausa en la impresora para reanudar y vuelva a imprimir. + + + La impresora también indica: {0}. + + + No se pudo leer el estado de la impresora antes de imprimir ({0}), así que las etiquetas se enviaron sin esa comprobación. + + + La impresora indica: {0}. Las etiquetas se enviaron; revise la calidad de impresión. + + + Impreso y verificado: la impresora indica que terminó el trabajo sin problemas. + + + Las etiquetas se enviaron, pero luego la impresora indicó un problema: {0}. Puede que algunas etiquetas no se hayan impreso. Solucione el problema y revise las etiquetas antes de volver a imprimir. + + + Las etiquetas se enviaron, pero Rotula no pudo confirmar que se imprimieran ({0}). Revise la impresora. + + + esta impresora o su controlador de Windows no responde a consultas de estado, algo habitual con controladores USB + + + Windows indica que la impresora está fuera de línea + + + no se pudo contactar con la impresora + + + la impresora no respondió a tiempo + + + la respuesta de la impresora no era una respuesta de estado ZPL + + + seguía imprimiendo cuando Rotula dejó de esperar + + + la comprobación se detuvo + + + el motivo es desconocido + + + cabezal abierto + + + sin etiquetas + + + sin cinta + + + cabezal demasiado caliente + + + modo de diagnóstico de comunicaciones activado + + + memoria para trabajos entrantes llena + + + en pausa + + + cabezal demasiado frío (las etiquetas pueden salir más claras) + + + configuración perdida (la impresora usa sus valores de fábrica) + + + todavía está llegando un trabajo + + + una etiqueta impresa espera a que la retiren + + + desconocido + + + rebobinado + + + despegado + + + corte manual + + + cortador + + + aplicador + + + corte diferido + + + despegado sin soporte + + + rebobinado sin soporte + + + cortador parcial + + + RFID + + + quiosco + + + continuo + + + Comprobar estado de la impresora + + + Pregunta a la impresora su estado (papel, cabezal, cinta, pausa) con un pequeño comando ZPL. En una impresora compatible con ZPL no se imprime nada. + + + Consultando el estado de la impresora... + + + La impresora está lista. + + + La impresora no puede imprimir ahora: {0}. + + + La impresora indica: {0}. + + + No se pudo leer el estado de la impresora: {0}. Puede imprimir igualmente. + + + Solo se recibió parte del estado, así que puede que falten problemas en la lista. + + + Etiquetas pendientes: {0}. Trabajos en espera en la impresora: {1}. + + + Modo de impresión: {0}. {1}. Largo de etiqueta: {2} puntos. + + + Transferencia térmica (con cinta) + + + Térmica directa (sin cinta) + + + Comprobar la impresora antes y después de imprimir + + + Antes de cada trabajo Rotula consulta el estado de la impresora y no envía nada si no tiene etiquetas, está abierta, en pausa o sin cinta. Después del trabajo espera a que la impresora indique que terminó. Para impresoras compatibles con ZPL; muchos controladores USB no pueden responder y entonces se imprime sin la comprobación. + diff --git a/src/Rotula.App/Services/AppSettings.cs b/src/Rotula.App/Services/AppSettings.cs index df4971e..a9da076 100644 --- a/src/Rotula.App/Services/AppSettings.cs +++ b/src/Rotula.App/Services/AppSettings.cs @@ -83,6 +83,15 @@ public sealed class PrintSettings /// Print speed 1-14 (^PR) or null to leave the printer as it is. public int? SpeedIps { get; set; } + + /// Ask a network printer for its status (~HS) before and after printing. On by default: a TCP printer answers on the same port. + public bool StatusCheckNetwork { get; set; } = true; + + /// + /// Ask a Windows printer for its status before and after printing. Off by default: many USB drivers cannot answer, and a queue that is + /// not a ZPL compatible printer would print the query as text. + /// + public bool StatusCheckWindows { get; set; } } /// Loads and saves . diff --git a/src/Rotula.App/ViewModels/DataMergeViewModel.cs b/src/Rotula.App/ViewModels/DataMergeViewModel.cs index b99f267..8fa6562 100644 --- a/src/Rotula.App/ViewModels/DataMergeViewModel.cs +++ b/src/Rotula.App/ViewModels/DataMergeViewModel.cs @@ -1100,6 +1100,11 @@ private async Task PrintAllAsync() // Raw ZPL imported from bytes must reach the printer byte for byte; collected once, not per record. var rawBlocks = ByteExactBlocks.From(doc); var progressReporter = new Progress(progress); + // Every job is checked before it goes out (paper running out halfway stops the batch cleanly); the printer is asked whether it + // finished only once, after the last job, instead of waiting for each job to come out. + var batchTarget = target!; + var jobTarget = batchTarget.WithStatus(batchTarget.Status with { VerifyAfterPrinting = false }); + var verification = new List(); bool ok = await run(Loc.Get("Data.Busy.Printing"), async ct => { int inFlight = 0; // last record of the job being sent right now @@ -1111,7 +1116,9 @@ await Task.Run(async () => { ct.ThrowIfCancellationRequested(); inFlight = job.ItemsThrough; - var result = await _service.PrintZplAsync(target!, jobs == 0 ? setup + job.Zpl : job.Zpl, doc.Encoding, rawBlocks, ct); + var result = await _service.PrintZplAsync(jobTarget, jobs == 0 ? setup + job.Zpl : job.Zpl, doc.Encoding, rawBlocks, ct); + foreach (var note in result.Notices) + if (!verification.Any(v => v.MessageKey == note.MessageKey)) verification.Add(note); if (!result.Succeeded) { failure = result.Error; @@ -1137,10 +1144,13 @@ await Task.Run(async () => // Always confirms (also when the run failed or was cancelled), so a reservation is never left hanging. if (serials is not null) closing.AddRange(serials.Complete(confirmedThrough)); } + // After the numbers are confirmed: a fault reported now leaves them used, because the labels may have printed. + if (failure is null && !cancelled && jobs > 0 && batchTarget.Status.VerifyAfterPrinting) + verification.AddRange(await _service.VerifyPrintedAsync(batchTarget, ct)); }); if (!ok) return null; ShowReport(report); - string notes = string.Join(" ", closing.Select(Loc.FormatIssue)); + string notes = string.Join(" ", closing.Select(Loc.FormatIssue).Concat(verification.Select(PrintMessages.DescribeNotice))); if (failure is not null) { return new BatchOutcome(PrintMessages.Describe(failure) + " " + Loc.Get("Data.Batch.Partial", jobs) + (notes.Length > 0 ? " " + notes : string.Empty), @@ -1149,7 +1159,8 @@ await Task.Run(async () => if (cancelled) return new BatchOutcome(Loc.Get("Data.Batch.CancelledPartial", jobs) + (notes.Length > 0 ? " " + notes : string.Empty), false); Printer.Persist(); - return new BatchOutcome(BatchSummary("Data.PrintAll.Done", report, jobs) + (notes.Length > 0 ? " " + notes : string.Empty), report.HasErrors || report.Emitted == 0); + return new BatchOutcome(BatchSummary("Data.PrintAll.Done", report, jobs) + (notes.Length > 0 ? " " + notes : string.Empty), + report.HasErrors || report.Emitted == 0 || verification.Any(v => v.MessageKey == PrinterStatusKeys.FaultAfterJob)); } /// The result line of a batch print or export, and whether it reports a problem. diff --git a/src/Rotula.App/ViewModels/PrintMessages.cs b/src/Rotula.App/ViewModels/PrintMessages.cs index f055c74..c5afb5c 100644 --- a/src/Rotula.App/ViewModels/PrintMessages.cs +++ b/src/Rotula.App/ViewModels/PrintMessages.cs @@ -21,6 +21,14 @@ public static string Describe(PrintError error) // The detail differs per transport (queue, code, host...), so it is joined into one argument. args = new object[] { string.Join(", ", error.Args.Where(a => a.Length > 0)) }; break; + case PrintErrorKind.HeadOpen or PrintErrorKind.PaperOut or PrintErrorKind.RibbonOut or PrintErrorKind.HeadTooHot + or PrintErrorKind.DiagnosticMode or PrintErrorKind.BufferFull or PrintErrorKind.Paused: + { + // Arguments: the printer, then every condition it reported. The message names the worst one; the others follow. + string main = Loc.Get(error.Key, error.Args.Count > 0 ? error.Args[0] : string.Empty, string.Empty); + var others = error.Args.Skip(1).Where(c => !string.Equals(c, error.Kind.ToString(), StringComparison.Ordinal)).ToList(); + return others.Count == 0 ? main : main + " " + Loc.Get("Print.Status.AlsoReports", Conditions(others)); + } } // Messages may have up to two holes; pad so that a short argument list never throws. if (args.Length < 2) args = args.Concat(Enumerable.Repeat((object)string.Empty, 2 - args.Length)).ToArray(); @@ -35,7 +43,55 @@ public static string Describe(PrintError error) /// empty when there are none. /// public static string DescribeNotices(PrintResult result) => - string.Join(Environment.NewLine, result.Notices.Select(Loc.FormatIssue)); + string.Join(Environment.NewLine, result.Notices.Select(DescribeNotice)); + + /// + /// One note of a print result. The printer status notes carry condition and reason names, which are translated here into a phrase; + /// every other note goes through the usual issue text. + /// + public static string DescribeNotice(ValidationIssue notice) + { + ArgumentNullException.ThrowIfNull(notice); + string? arg = notice.MessageKey switch + { + PrinterStatusKeys.Warning or PrinterStatusKeys.FaultAfterJob => Conditions(notice.Args), + PrinterStatusKeys.NotChecked or PrinterStatusKeys.NotVerified => Reason(notice.Args.FirstOrDefault()), + PrinterStatusKeys.Verified => string.Empty, + _ => null, + }; + return arg is null ? Loc.FormatIssue(notice) : Loc.Get("Issue." + notice.MessageKey, arg); + } + + /// True when a print result says the printer reported a fault after the job (the result line is then shown as a problem). + public static bool HasFaultAfterJob(PrintResult result) => result.Notices.Any(n => n.MessageKey == PrinterStatusKeys.FaultAfterJob); + + /// + /// Describes a decoded printer status for the "Check printer status" button: ready, or what stops it, plus what it is doing. Returns + /// whether the status is a problem (blocked, or unknown) so the view can colour it. + /// + public static (string Text, bool IsProblem) DescribeStatus(PrinterStatus status) + { + ArgumentNullException.ThrowIfNull(status); + if (!status.IsAvailable) return (Loc.Get("Print.Status.Unavailable", Reason(status.Reason!.Value.ToString())), true); + var lines = new List(); + lines.Add(status.IsBlocked ? Loc.Get("Print.Status.Blocked", Conditions(status.Blocking.Select(c => c.ToString()))) + : Loc.Get("Print.Status.Ready")); + if (status.Warnings.Count > 0) lines.Add(Loc.Get("Print.Status.Warnings", Conditions(status.Warnings.Select(c => c.ToString())))); + if (status.LabelsRemaining is > 0 || status.FormatsInBuffer is > 0) + lines.Add(Loc.Get("Print.Status.Busy", status.LabelsRemaining ?? 0, status.FormatsInBuffer ?? 0)); + if (status.PrintMode is { } mode && status.ThermalTransfer is { } transfer && status.LabelLengthDots is { } length) + lines.Add(Loc.Get("Print.Status.Details", Loc.Get("Enum.PrinterPrintMode." + mode), + Loc.Get(transfer ? "Print.Status.ThermalTransfer" : "Print.Status.DirectThermal"), length)); + if (!status.IsComplete) lines.Add(Loc.Get("Print.Status.Partial")); + return (string.Join(Environment.NewLine, lines), status.IsBlocked); + } + + /// Joins condition names as a phrase ("out of labels, paused"); a name this version does not know is shown as it is. + private static string Conditions(IEnumerable names) => + string.Join(", ", names.Select(n => Loc.Instance.HasKey("Enum.PrinterCondition." + n) ? Loc.Get("Enum.PrinterCondition." + n) : n)); + + private static string Reason(string? name) => + name is not null && Loc.Instance.HasKey("Print.Status.Reason." + name) ? Loc.Get("Print.Status.Reason." + name) : Loc.Get("Print.Status.Reason.Unknown"); /// Lists the error-level problems of a label as short sentences (at most ). public static string DescribeIssues(IEnumerable issues, int max = 6) => diff --git a/src/Rotula.App/ViewModels/PrintViewModel.cs b/src/Rotula.App/ViewModels/PrintViewModel.cs index f3e4ea7..3207d17 100644 --- a/src/Rotula.App/ViewModels/PrintViewModel.cs +++ b/src/Rotula.App/ViewModels/PrintViewModel.cs @@ -229,8 +229,9 @@ private async Task SendAsync(string busyText, Func NotifyToolbar(); @@ -181,6 +185,94 @@ public bool UseWindowsPrinter [ObservableProperty] private string _printersNote = string.Empty; + // ------------------------------------------------------------------------------- printer status (~HS) + + private bool _statusCheckNetwork = true; + private bool _statusCheckWindows; + private CancellationTokenSource? _statusCts; + + /// + /// True to ask the printer for its status before and after printing. Kept separately for network and Windows printers: on by default + /// for a network printer, off for a Windows printer (many USB drivers cannot answer). + /// + public bool CheckStatus + { + get => UseNetwork ? _statusCheckNetwork : _statusCheckWindows; + set + { + if (CheckStatus == value) return; + if (UseNetwork) _statusCheckNetwork = value; else _statusCheckWindows = value; + OnPropertyChanged(); + } + } + + /// True while "Check printer status" waits for the answer. + [ObservableProperty] + private bool _isCheckingStatus; + + /// The decoded status in plain language, or empty before the first check. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatusText), nameof(HasStatusOk), nameof(HasStatusProblem))] + private string _statusText = string.Empty; + + /// True when reports a problem (the printer cannot print, or its status is unknown). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatusOk), nameof(HasStatusProblem))] + private bool _statusIsProblem; + + /// True when there is a status result to show. + public bool HasStatusText => StatusText.Length > 0; + + /// True when the status result is good news (drives the green note). + public bool HasStatusOk => HasStatusText && !StatusIsProblem; + + /// True when the status result is a problem (drives the amber note). + public bool HasStatusProblem => HasStatusText && StatusIsProblem; + + /// Asks the chosen printer for its status (~HS) and shows the answer. A second click while waiting starts over. + [RelayCommand] + private async Task CheckPrinterStatusAsync() + { + string? error = TryBuildTarget(out var target); + if (error is not null) + { + StatusIsProblem = true; + StatusText = error; + return; + } + var previous = _statusCts; + previous?.Cancel(); + var cts = _statusCts = new CancellationTokenSource(); + IsCheckingStatus = true; + StatusText = string.Empty; + try + { + var status = await _service.GetStatusAsync(target!, cts.Token); + if (cts.IsCancellationRequested) return; + (string text, bool problem) = PrintMessages.DescribeStatus(status); + StatusIsProblem = problem; + StatusText = text; + } + catch (OperationCanceledException) { } + finally + { + if (ReferenceEquals(_statusCts, cts)) + { + _statusCts = null; + IsCheckingStatus = false; + } + cts.Dispose(); + } + } + + /// Forgets a shown status when another printer is chosen, so an old answer is never read as the new printer's. + private void ClearStatus() + { + _statusCts?.Cancel(); + StatusText = string.Empty; + StatusIsProblem = false; + } + // ------------------------------------------------------------------------------- driver detection /// True while the driver is being read (the view shows a small progress bar). @@ -383,6 +475,7 @@ private void LoadFromSettings() WidthText = FormatMm(ToMm(doc.Width, doc.Dpi)); HeightText = FormatMm(ToMm(doc.Height, doc.Dpi)); SelectedMedia = doc.MediaTracking; + LoadStatusChoice(); return; } UseNetwork = string.Equals(s.Transport, "Tcp", StringComparison.OrdinalIgnoreCase); @@ -395,6 +488,14 @@ private void LoadFromSettings() SelectedMedia = Enum.TryParse(s.MediaTracking, out var m) ? m : MediaTracking.Web; DarknessText = s.Darkness?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; SpeedText = s.SpeedIps?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + LoadStatusChoice(); + } + + private void LoadStatusChoice() + { + _statusCheckNetwork = _settings.StatusCheckNetwork; + _statusCheckWindows = _settings.StatusCheckWindows; + OnPropertyChanged(nameof(CheckStatus)); } private static double ToMm(Measure m, PrinterDpi dpi) => UnitConverter.Convert(m.Value, m.Unit, LengthUnit.Millimeters, (int)dpi); @@ -463,14 +564,16 @@ private static bool TryParseDouble(string text, out double value) => if (string.IsNullOrWhiteSpace(Host)) return Loc.Get("Print.Field.NoHost"); if (!int.TryParse(PortText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int port) || port is < 1 or > 65535) return Loc.Get("Print.Field.Port"); - target = PrinterTarget.TcpIp(Host.Trim(), port); + target = PrinterTarget.TcpIp(Host.Trim(), port).WithStatus(StatusOptions); return null; } if (string.IsNullOrWhiteSpace(SelectedPrinterName)) return Loc.Get("Print.Field.NoPrinter"); - target = PrinterTarget.Spooler(SelectedPrinterName); + target = PrinterTarget.Spooler(SelectedPrinterName).WithStatus(StatusOptions); return null; } + private PrinterStatusOptions StatusOptions => CheckStatus ? PrinterStatusOptions.Default : PrinterStatusOptions.Off; + /// Builds and validates the printer profile from the fields. /// The profile when valid. /// Character set used by the test label. @@ -523,6 +626,8 @@ public void Persist() _settings.MediaTracking = SelectedMedia.ToString(); _settings.Darkness = int.TryParse(DarknessText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int d) ? d : null; _settings.SpeedIps = int.TryParse(SpeedText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int sp) ? sp : null; + _settings.StatusCheckNetwork = _statusCheckNetwork; + _settings.StatusCheckWindows = _statusCheckWindows; _save(); } } diff --git a/src/Rotula.App/Views/PrinterSettingsView.xaml b/src/Rotula.App/Views/PrinterSettingsView.xaml index 4cb0392..6501243 100644 --- a/src/Rotula.App/Views/PrinterSettingsView.xaml +++ b/src/Rotula.App/Views/PrinterSettingsView.xaml @@ -70,6 +70,20 @@ + + +