Status and monitoring
Reading what a printer says about itself, and following a job until it ends.
Printer status over IPP
IppPrinterStatusClient reads identity and status with IPP Get-Printer-Attributes, and returns
make and model, state, state reasons and the supply markers. All operations are read-only.
IppPrinterStatusClient client = new();
IppPrinterDetails details = await client.GetDetailsAsync("192.168.1.50", cancellationToken)
.ConfigureAwait(false);
PrinterStatus reports the state reasons three ways, and each one has a purpose:
StateReasonsholds one entry for each reason. Match this one, for examplecups-pki-expired. A printer with no reason gives an empty list: the protocol keywordnonemeans "no reason at all", so it is never an entry.Detailholds the same list joined with"; ", for a person to read.StateMessageandDetailedStatusMessagesholdprinter-state-messageandprinter-detailed-status-messages: free text the printer wrote. Do not parse either one.
PrintJobInfo carries the same four fields for a job, plus PrinterStateMessage, which is the
job-printer-state-message attribute. CUPS puts the text of its own log there, so it is
usually the only field that names why a job stopped. Troubleshooting works
through that case.
The client tries IPPS (TLS) first and falls back to plain IPP, across /ipp/print and
/ipp/port1. The optional resourcePath parameter is tried before those well-known paths: pass
the rp attribute of a DNS-SD TXT record to reach a printer that serves IPP elsewhere.
The default constructor owns an HttpClient and disposes it; a client built from a
caller-supplied HttpClient does not. Neither this client nor SnmpPrinterStatusClient
implements an interface, so a test cannot mock either one — wrap the class behind your own seam
when a test must replace it.
IPP transport policy
Every IPP connection this library opens follows one IppTransportOptions, whichever type opened
it. The connection opens over IPPS (TLS) first, and the plain IPP endpoints are tried next when
no IPPS endpoint answers.
| Option | Default | What it does |
|---|---|---|
AllowPlainIpp |
true |
Many label printers speak plain IPP only, so the fallback is on. Set it to false to talk to IPPS printers only. |
ServerCertificateValidation |
null |
The default accepts every certificate, because network printers use self-signed certificates in nearly every case. |
ConnectTimeout |
5 seconds | Without it, the operating system default applies, which can be minutes. |
The default certificate policy protects the print data against a passive observer only. It
does not prove that the host is the printer you expect. Set ServerCertificateValidation when
the application must know. A printer has no certificate chain to a public root, so pin its
thumbprint:
IppTransportOptions options = new()
{
ServerCertificateValidation = (_, certificate, _, _) =>
certificate is not null &&
String.Equals(certificate.GetCertHashString(), knownThumbprint, StringComparison.OrdinalIgnoreCase),
};
using PrinterFactory factory = new(options);
A validating client never falls back to plain IPP. When ServerCertificateValidation is set,
or when you pass your own HttpClient, a failed TLS handshake throws AuthenticationException,
because clear text would defeat the trust you asked for. With the default policy, a failed
handshake means "this port speaks plain IPP", and the fallback runs.
To share one client between several types, build it once with IppHttpClientFactory.Create(options)
and pass the client together with the same options to each constructor. That factory sets the
connect timeout, turns off redirects (every IPP operation is a POST that carries the document),
and installs the certificate policy. The caller owns that client.
Printer status over SNMP
SnmpPrinterStatusClient reads the Printer MIB (RFC 3805) and the Host Resources MIB from a host
you already know. Many printers supply these and do not answer IPP, and SNMP also reports the
serial number and the page count, which IPP does not carry. All operations are read-only.
SnmpPrinterStatusClient client = new();
SnmpPrinterDetails details = await client.GetDetailsAsync("192.168.1.50", cancellationToken)
.ConfigureAwait(false);
Console.WriteLine($"{details.Info.Name}: {details.Status.SerialNumber}, {details.Status.LifetimePageCount} pages");
SnmpPrinterStatusOptionssets the community, the request timeout and the retry count (two, which gives three attempts, because UDP can lose a datagram). The client throwsInvalidOperationExceptionwhen no attempt is answered. A datagram from another address, or a malformed datagram, is not an answer and is discarded.- A host name that resolves to both address families is queried over IPv4, because most printers answer SNMP on IPv4 only.
- When the agent answers the supply walk with
tooBig, the client asks again once for half as many rows. Every other SNMP error status is anInvalidOperationException. hrPrinterStatusgives the state. The bits ofhrPrinterDetectedErrorStatecan raise it toErrororOffline, and every set bit is named inPrinterStatus.StateReasons. A bit that is only a warning, such aslowToner, does not change the state, because a printer low on toner still prints.- The Printer MIB uses a negative supply level for a value that is not a quantity:
-1is "other",-2is "unknown amount remains" and-3is "some amount remains". For a negative level,PrinterMarker.LevelPercentisnullandLevelRawkeeps the reported number. - This client does not search a network. Find printers with a discovery first.
- Only SNMP version 2c is supported.
Reading a status through the manager
GetStatusAsync resolves the identifier the same way PrintAsync does, and opens a channel a
status can be read from.
It tries every channel of the device, most preferred first, until one answers. A printer
commonly advertises a channel it cannot actually serve — an ipps port whose certificate no
longer negotiates is the usual one — and the device is not unreachable while another of its
channels still answers. Only when none answers is the failure of the first one reported, because
that is the channel the caller asked for.
Job queues
IPrintJobQueue inspects and manages the jobs of a printer. CompositePrintJobQueue routes a
spooler identifier to the operating system spooler and a network identifier to IPP.
The IPP queue states which attributes it wants. A printer left to its own default answers
Get-Jobs with the job identifier alone, and none of the messages that say why a job stopped. Both
GetJobsAsync and GetJobAsync therefore send an explicit requested-attributes list that
holds every attribute the mapper reads.
Watching a job
IPrintJobMonitor.WatchJobAsync yields a reading each time the state or the progress of one job
changes, until the job reaches a terminal state or leaves the queue. PollingPrintJobMonitor
reads the queue again and again, so it works with every printer and needs no notification
channel.
IPrintJobMonitor monitor = new PollingPrintJobMonitor(queue);
PrintJobMonitorOptions watch = new() { IdleTimeout = TimeSpan.FromMinutes(2) };
await foreach (var reading in monitor
.WatchJobAsync(job.PrinterId, job.JobId, watch, cancellationToken)
.ConfigureAwait(false))
{
Console.WriteLine($"{reading.State}: {reading.ImpressionsCompleted} pages");
}
Watch job.PrinterId, and not the identifier you printed with. A job queue resolves nothing,
so it needs an identifier that names a host. An identifier that names a device identity — which
is what mDNS discovery gives every printer that advertises a UUID, and a CUPS queue does — prints
through IPrinterManager, because the manager resolves it, and then fails the watch with
NotSupportedException. PrintJobInfo.PrinterId is the identifier of the channel the job really
went to, so it always names a host.
A device with only a raw channel cannot be watched at all. WatchJobAsync checks for a job
queue first and throws NotSupportedException when there is none. This refusal is correct, not a
limitation: a raw channel gives back a generated job identifier and has no queue to read.
Watching such a job through IPP would ask the wrong protocol about a job it never saw, and a past
defect showed the cost — the empty answer read as "the job is done", so the caller was told the
label was finished before the printer did any work.
When a watch ends
Only the CancellationToken throws. Every limit ends the watch quietly. The watch stops and
the loop simply finishes; a watch whose last reading was not terminal ended early, and that last
reading is how a caller says so.
| Limit | Ends the watch when |
|---|---|
IdleTimeout |
Neither the state nor the page count has moved for that long. |
Timeout |
That long has passed in total, whatever the job is doing. |
Prefer IdleTimeout. Elapsed time does not separate a slow job from a stuck one; change
does. A printer that wakes from sleep can take minutes over the first page and then print
steadily: every page resets IdleTimeout, so the watch survives, while Timeout would end a job
that is working perfectly. Set Timeout only as an outer bound. Both may be set, and whichever
comes first ends the watch.
Never pass a deadline as the cancellation token. The monitor cannot tell a caller's deadline
from a real interruption, so it does what the token says and throws. A past defect showed the
cost: one CancellationTokenSource(2 minutes) covered a file read, the submission and the watch,
and a printer that woke from sleep and printed slowly took the whole run down with an unhandled
TaskCanceledException. The token is for a caller that wants to stop; a limit belongs in the
options.
A sleeping printer needs no waking. Submitting a job wakes it — that is what IPP and the
spooler already do — but the first page may take minutes while it warms up. During that time the
job reports Printing with no page finished, which is exactly the case IdleTimeout is sized
for.