Hippy
Hippy is an Internet Printing Protocol (IPP) client for Elixir. IPP is how computers talk to printers over the network: almost every network printer made in the last decade speaks it, and so does CUPS, the print server on macOS and Linux. With Hippy you can print documents, follow jobs, ask printers how they're doing, and manage queues, whether that's one printer on your Wi-Fi or a fleet of them.
Hippy is pre-1.0, so its API can still change between minor versions (see the changelog). It's tested against a real CUPS server and an emulated driverless printer.
Installation
Add hippy to your dependencies in mix.exs. It needs Elixir 1.15 or later.
def deps do
[
{:hippy, "~> 0.8.0"}
]
end
Quick start
First you need your printer's URI. Driverless printers usually answer at ipp://<printer-address>/ipp/print. Find the address in the printer's network settings, or run dns-sd -B _ipp._tcp on a Mac or avahi-browse -rt _ipp._tcp on Linux. A printer shared by CUPS is at ipp://<cups-host>:631/printers/<queue-name>.
Ask the printer about itself, print a PDF, then check on the job:
alias Hippy.Operation.{GetJobAttributes, GetPrinterAttributes, PrintJob}
printer = "ipp://192.168.1.50/ipp/print"
{:ok, res} = printer |> GetPrinterAttributes.new() |> Hippy.send_operation()
res |> Hippy.Message.printer() |> Map.take(["printer-make-and-model", "printer-state"])
#=> %{"printer-make-and-model" => "Example LaserJet 400", "printer-state" => :idle}
{:ok, res} =
"invoice.pdf"
|> File.read!()
|> then(&PrintJob.new(printer, &1, document_format: "application/pdf", job_name: "Invoice"))
|> Hippy.send_operation()
job_id = res |> Hippy.Message.job() |> Map.fetch!("job-id")
{:ok, res} = printer |> GetJobAttributes.new(job_id) |> Hippy.send_operation()
Hippy.Message.job(res)["job-state"]
#=> :processing
Ask again later and it will be :completed. "Print a document and wait for it to finish" below does that for you.
How it fits together
Every call above has the same three steps: build an operation, send it, read the response.
| Build | Hippy.Operation.PrintJob.new/3, GetPrinterAttributes.new/2, GetJobs.new/2, … are plain data, with no side effects. |
| Send | Hippy.send_operation/2 sends an operation. Functions named send_* are the only ones that touch the network. |
| Read | Hippy.Message.job/1, printer/1, jobs/1, status/1, … turn the response into maps with string keys. |
When something goes wrong you get {:error, exception}: Hippy.Error if the printer refused, Hippy.HTTPError for an HTTP problem, or one of Req's exceptions if it couldn't be reached. Two helpers decide what to do next: Hippy.printer_ready?/1 says whether a printer can take a job, and Hippy.safe_to_resend?/1 whether a failed request can be sent again without printing twice.
The operations Hippy has modules for cover the common tasks. Hippy.send_operation/4 sends any of the 118 IPP and CUPS operations in Hippy.IPP.Operation by name.
Things to build
These examples are run against a CUPS server and an emulated driverless printer in Hippy's integration tests, so they work as written.
Draw your ink levels in the terminal
Printers report each cartridge's level and colour in printer-supply, as strings like "type=toner;level=75;maxcapacity=100;colorantname=black;". Turn them into bars in the ink's own colour:
defmodule MyApp.Ink do
alias Hippy.Operation.GetPrinterAttributes
@colors %{"black" => :light_black, "cyan" => :cyan, "magenta" => :magenta, "yellow" => :yellow}
def gauge(uri) do
with {:ok, res} <-
uri
|> GetPrinterAttributes.new(
requested_attributes: ["printer-supply", "printer-supply-description"]
)
|> Hippy.send_operation(timeout: 5_000) do
printer = Hippy.Message.printer(res)
names = Map.get(printer, "printer-supply-description", [])
printer
|> Map.get("printer-supply", [])
|> Enum.map(&parse/1)
|> Enum.with_index(fn supply, i -> bar(supply, Enum.at(names, i)) end)
|> Enum.join("\n")
|> then(&{:ok, &1})
end
end
# "type=toner;level=75;..." -> %{"type" => "toner", "level" => "75", ...}
defp parse(supply) do
supply
|> String.split(";", trim: true)
|> Map.new(&(&1 |> String.split("=", parts: 2) |> List.to_tuple()))
end
defp bar(supply, name) do
label = String.pad_trailing(name || supply["type"] || "supply", 18)
with {level, ""} when level >= 0 <- Integer.parse(supply["level"] || ""),
{max, ""} when max > 0 <- Integer.parse(supply["maxcapacity"] || "100") do
percent = min(div(level * 100, max), 100)
filled = div(percent, 5)
color = Map.get(@colors, supply["colorantname"], :white)
# A waste tank's level is how full it is, not how much is left.
full = if supply["class"] == "receptacleThatIsFilled", do: " full", else: ""
bar =
IO.ANSI.format(
[color, String.duplicate("█", filled), :reset] ++
[:faint, String.duplicate("░", 20 - filled), :reset]
)
"#{label} #{bar} #{percent}%#{full}"
else
_unknown -> "#{label} level unknown"
end
end
end
{:ok, gauge} = MyApp.Ink.gauge("ipp://192.168.1.50/ipp/print")
IO.puts(gauge)
Black Toner ███████████████░░░░░ 75%
Toner Waste Tank █████░░░░░░░░░░░░░░░ 25% full
Print a full-page Mandelbrot set, using hardly any ink
Hippy sends whatever bytes you give it, so you can write the PDF yourself. This one is a single page of ASCII art: the Mandelbrot set in 6-point Courier, 150 characters by 120 lines. To save ink it's light grey, and only the bands around the edge of the set are drawn, in the lightest characters. Most of the page stays blank.
defmodule MyApp.Mandelbrot do
alias Hippy.Operation.{GetPrinterAttributes, PrintJob}
@columns 150
@rows 120
# Nothing for the set itself or the outer bands, then lighter to heavier characters.
@ramp ~c" .,:;~-=+*"
def print(uri) do
with {:ok, res} <-
uri
|> GetPrinterAttributes.new(requested_attributes: ["document-format-supported"])
|> Hippy.send_operation(),
%{"document-format-supported" => formats} = Hippy.Message.printer(res),
true <- "application/pdf" in formats || {:error, :pdf_not_supported} do
uri
|> PrintJob.new(pdf(), document_format: "application/pdf", job_name: "Mandelbrot")
|> Hippy.send_operation()
end
end
def lines do
for row <- 0..(@rows - 1) do
for col <- 0..(@columns - 1), into: "" do
# The real axis runs down the page, so the set fills a tall sheet.
<<char(escape(0.55 - row * 2.6 / @rows, -0.975 + col * 1.95 / @columns))>>
end
end
end
# How many steps z = z² + c takes to escape, or :inside if it never does.
defp escape(re, im, zr \\ 0.0, zi \\ 0.0, n \\ 0)
defp escape(_re, _im, _zr, _zi, 80), do: :inside
defp escape(_re, _im, zr, zi, n) when zr * zr + zi * zi > 4, do: n
defp escape(re, im, zr, zi, n),
do: escape(re, im, zr * zr - zi * zi + re, 2 * zr * zi + im, n + 1)
defp char(:inside), do: ?\s
defp char(n), do: Enum.at(@ramp, min(n, length(@ramp) - 1))
# A one-page PDF: the lines in 6-point Courier, in light grey, on a Letter page.
def pdf do
text = lines() |> Enum.map_join("\n", &"(#{&1}) '")
content = "BT /F1 6 Tf 6 TL 0.55 g 36 762 Td\n#{text}\nET"
objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " <>
"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
"<< /Length #{byte_size(content)} >>\nstream\n#{content}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>"
]
# The cross-reference table at the end gives each object's byte offset.
header = "%PDF-1.4\n"
{objects, xref_offset} =
objects
|> Enum.with_index(1)
|> Enum.map_reduce(byte_size(header), fn {object, i}, offset ->
object = "#{i} 0 obj\n#{object}\nendobj\n"
{{offset, object}, offset + byte_size(object)}
end)
count = length(objects) + 1
IO.iodata_to_binary([
header,
Enum.map(objects, fn {_offset, object} -> object end),
"xref\n0 #{count}\n0000000000 65535 f \n",
Enum.map(objects, fn {offset, _} ->
String.pad_leading("#{offset}", 10, "0") <> " 00000 n \n"
end),
"trailer\n<< /Size #{count} /Root 1 0 R >>\nstartxref\n#{xref_offset}\n%%EOF\n"
])
end
end
MyApp.Mandelbrot.print("ipp://192.168.1.50/ipp/print")
Here's the whole page in miniature, every third line and every other character (MyApp.Mandelbrot.lines() returns it all):
.......,,,,,,,,................,,,,,,,,.......
.,,~::,,,:;~+**-~=**~~::,,,,,,::~~**=~-**+~;:,,,::~,,.
.,********--**** **~;;~** ****--********,.
...,::~** ** **~::,...
....,,,:~-*** ***-~:,,,....
.....,,:;**** ****;:,,.....
.....,,::;~+* *+~;::,,.....
,,,:--*-~--=* *=--~-*--:,,
*~;~-** * * **-~;~
+*** ***
~~-*** ***-~
:::~****** ** ** ******~::
..,,;*~;~~-=* *=-~~;~*;,,.
.....,,:::;-* *-;:::,,....
....,,,::;-=** **=-;::,,,....
...,,,:;-** **-;:,,,...
...,:~**** *** *** ****~:,...
..,-=~*~~~=* *=~~~*~=-,..
..,,,,::;=**+* ** ** *+**=;::,,,,..
...,,,,,::;;;--=+******** ********+=--;;;::,,,,,...
.....,,,,::;;~~-=** **=-~~;;::,,,,.....
......,,,::;~-** **-~;::,,,......
......,,::;-** **-;::,,......
......,:-=** **=-:,......
.....,:;~-=** **=-~;:,.....
....,::;=** **=;::,....
..,,:;~******* *******~;:,,..
,;-=*-;;~~****** ******~~;;-*=-;,
.,,,,,::::~~+** **+~~::::,,,,,.
.......,:;+*++****++*+;:,.......
.......,::;~=**=~;::,.......
......,,::~==~::,,......
....,,,;--;,,,....
..,,,;--;,,,..
.::--::.
,:--:,
.,;;,.
..::..
.,,.
Print a document and wait for it to finish
defmodule MyApp.Printing do
alias Hippy.Operation.{GetJobAttributes, PrintJob}
def print_and_wait(printer_uri, document) do
{:ok, res} = printer_uri |> PrintJob.new(document) |> Hippy.send_operation()
res
|> Hippy.Message.job()
|> Map.fetch!("job-id")
|> wait_for_job(printer_uri)
end
defp wait_for_job(job_id, printer_uri) do
{:ok, res} = printer_uri |> GetJobAttributes.new(job_id) |> Hippy.send_operation()
case Hippy.Message.job(res) do
%{"job-state" => state} when state in [:completed, :canceled, :aborted] ->
{state, job_id}
_ ->
Process.sleep(1_000)
wait_for_job(job_id, printer_uri)
end
end
end
document = File.read!("invoice.pdf")
MyApp.Printing.print_and_wait("ipps://front-desk.local/ipp/print", document)
# {:completed, 47}
What :completed means depends on who reports it. A printer reporting its own job means the pages have come out. CUPS is a queue in front of the printer, so for a CUPS job it means CUPS has finished sending the job on: for a USB printer, written to the cable. A jam or an empty paper tray after that won't change the job's state, so check the printer's printer-state-reasons too if it matters.
Check a fleet of printers at once
With more than one printer, Elixir's concurrency makes it easy to look after them all. Ask every printer for its state at the same time. Each request gives up after five seconds, so one unplugged printer doesn't hold up the rest:
defmodule MyApp.Fleet do
alias Hippy.Message
alias Hippy.Operation.{GetPrinterAttributes, PrintJob}
@attributes [
"printer-state",
"printer-state-reasons",
"printer-is-accepting-jobs",
"queued-job-count"
]
def status(printer_uris) do
printer_uris
|> Task.async_stream(&{&1, printer_status(&1)}, timeout: :infinity)
|> Enum.map(fn {:ok, result} -> result end)
end
defp printer_status(uri) do
with {:ok, res} <-
uri
|> GetPrinterAttributes.new(requested_attributes: @attributes)
|> Hippy.send_operation(timeout: 5_000) do
{:ok, Message.printer(res)}
end
end
end
Then report the ones that need attention. Any error once a request is sent is an exception, so Exception.message/1 describes it:
fleet = [
"ipps://front-desk.local/ipp/print",
"ipps://warehouse.local/ipp/print",
"ipps://loading-dock.local/ipp/print"
]
fleet
|> MyApp.Fleet.status()
|> Enum.each(fn
{_uri, {:ok, %{"printer-state" => :idle, "printer-state-reasons" => ["none"],
"printer-is-accepting-jobs" => true}}} ->
:ok
{uri, {:ok, printer}} ->
IO.puts("#{uri}: #{printer["printer-state"]} " <>
"(#{Enum.join(printer["printer-state-reasons"], ", ")})")
{uri, {:error, error}} ->
IO.puts("#{uri}: #{Exception.message(error)}")
end)
# ipps://front-desk.local/ipp/print: stopped (media-empty-error)
# ipps://warehouse.local/ipp/print: idle (toner-low-report)
# ipps://loading-dock.local/ipp/print: connection refused
Send each job to the least busy printer
Add a print/2 to MyApp.Fleet that picks the ready printer with the shortest queue (Hippy.printer_ready?/1 checks it's accepting jobs and has no errors such as a paper jam), and moves on to the next one if that printer refuses the job. Hippy.safe_to_resend?/1 decides that for any error, including HTTP problems: it doesn't move on after a timeout, because the printer may have accepted the job anyway, and trying another would print it twice.
def print(printer_uris, document) do
printer_uris
|> status()
|> Enum.flat_map(fn
{uri, {:ok, printer}} -> if Hippy.printer_ready?(printer), do: [{uri, printer}], else: []
{_uri, {:error, _}} -> []
end)
|> Enum.sort_by(fn {_uri, printer} -> printer["queued-job-count"] end)
|> Enum.reduce_while({:error, :no_printer_available}, fn {uri, _printer}, none ->
case uri |> PrintJob.new(document) |> Hippy.send_operation() do
{:ok, res} ->
{:halt, {:ok, uri, Message.job(res)["job-id"]}}
{:error, error} ->
# Move on only if the job definitely wasn't accepted. After a timeout, for example,
# it may have printed: stop and check, rather than print it twice.
if Hippy.safe_to_resend?(error),
do: {:cont, none},
else: {:halt, {:error, {:unknown, uri, error}}}
end
end)
end
MyApp.Fleet.print(fleet, label_pdf)
# {:ok, "ipps://warehouse.local/ipp/print", 1042}
Everyday tasks
Print with settings
Every operation takes attributes:, a map of attribute names to values. Hippy looks each one up in the IANA IPP registry to work out its type and where it goes in the request, so you don't need to know that copies is a job attribute and job-name an operation attribute:
printer
|> PrintJob.new(document,
attributes: %{
"job-name" => "TPS Report",
"document-format" => "application/pdf",
"copies" => 2,
"sides" => "two-sided-long-edge",
"orientation-requested" => :landscape,
"media-col" => %{"media-size" => %{"x-dimension" => 21000, "y-dimension" => 29700}},
# Subscription attributes go in their own group: get notified when the job completes.
"notify-pull-method" => "ippget",
"notify-events" => ["job-completed"]
}
)
|> Hippy.send_operation()
Maps are collections and lists are sets. For attributes that can be a keyword or a name (such as media), a value spelt like a keyword is sent as one, and anything else as a name. Attributes the registry doesn't know, such as vendor extensions, need their type: "x-acme-finish" => {:keyword, "matte"}. See "Attributes as maps" in the Hippy docs for the rules.
A printer can accept a job without applying every setting. If it doesn't support two-sided printing, for example, it may print one-sided and still succeed, with the status :successful_ok_ignored_or_substituted_attributes. When the settings matter, check what it ignored:
{:ok, res} = Hippy.send_operation(op)
case Hippy.Message.unsupported(res) do
empty when map_size(empty) == 0 -> :printed_as_asked
ignored -> {:printed_without, ignored} # e.g. %{"sides" => "two-sided-long-edge"}
end
Stream a large document
A document can be an Enumerable of binaries, which is sent as it's read (with chunked transfer encoding) instead of being loaded into memory:
# Elixir 1.16 and later. On 1.15, File.stream!("big.pdf", [], 64_000).
"big.pdf"
|> File.stream!(64_000)
|> then(&PrintJob.new(printer, &1, document_format: "application/pdf"))
|> Hippy.send_operation()
Some embedded printers don't accept chunked requests. For those, read the file into a binary first.
List jobs
{:ok, res} = printer |> Hippy.Operation.GetJobs.new() |> Hippy.send_operation()
Hippy.Message.jobs(res)
#=> [
#=> %{"job-id" => 47, "job-name" => "TPS Report", "job-state" => :completed, ...},
#=> %{"job-id" => 46, "job-name" => "Untitled Job", "job-state" => :completed, ...}
#=> ]
By default you get completed jobs. which_jobs: :not_completed gives the ones still pending or printing.
Check, hold and pause
Ask whether a printer would accept a job before sending it, without printing anything. It takes the same options as PrintJob, and a printer that wouldn't accept it returns an IPP error saying why:
printer
|> Hippy.Operation.ValidateJob.new(
document_format: "application/pdf",
attributes: %{"sides" => "two-sided-long-edge"}
)
|> Hippy.send_operation()
HoldJob keeps a job in the queue until ReleaseJob, and PausePrinter stops a printer until ResumePrinter. CreateJob and SendDocument build one job from several documents. CUPS only lets a job's owner hold, release or add documents to it, and administrators pause printers (see Authentication).
Watch a printer
Hippy.stream_events/2 is a stream of a printer's events: its current state first, then each change to the printer and its jobs. It works the same with any printer, so the code using it doesn't need to know what's on the other end. Run it in its own process and send the events wherever they're needed, such as a LiveView:
parent = self()
Task.start_link(fn ->
printer
|> Hippy.stream_events(interval: 2_000)
|> Enum.each(&send(parent, {:printer_event, &1}))
end)
# Then, as things happen:
{:printer_event, %{"notify-subscribed-event" => "printer-state-changed", "printer-state" => :idle, ...}}
{:printer_event, %{"notify-subscribed-event" => "job-state-changed", "notify-job-id" => 47, "job-state" => :processing, ...}}
{:printer_event, %{"notify-subscribed-event" => "printer-state-changed", "printer-state" => :stopped, "printer-state-reasons" => ["media-empty-error"], ...}}
It checks the printer every interval. A job that starts and finishes between two checks is still reported, with its final state. If the printer can't be reached, it retries a few times and then raises, so run it under a supervisor.
examples/dashboard.exs is a complete live dashboard built this way, in one file: a card per printer with its state, anything wrong with it and its jobs, updated as they change. It uses Phoenix Playground, so there's no project to set up:
PRINTERS="ipp://192.168.1.50/ipp/print ipp://cups.local:631/printers/labels" \
elixir --no-halt examples/dashboard.exs
Then open http://localhost:4000.
Get notified about events
Printers that support subscriptions (CUPS does; many home printers don't) collect events for you, such as jobs completing or the printer stopping. Subscribe, then fetch new events whenever you like:
alias Hippy.Operation.{CreatePrinterSubscriptions, GetNotifications}
{:ok, res} =
printer
|> CreatePrinterSubscriptions.new(events: ["printer-state-changed", "job-completed"])
|> Hippy.send_operation(req_options: [auth: {:basic, "user:pass"}])
[%{"notify-subscription-id" => id}] = Hippy.Message.subscriptions(res)
{:ok, res} =
printer
|> GetNotifications.new(id, sequence_numbers: 1)
|> Hippy.send_operation(req_options: [auth: {:basic, "user:pass"}])
Hippy.Message.events(res)
#=> [
#=> %{"notify-subscribed-event" => "job-completed", "notify-sequence-number" => 1, "notify-job-id" => 47, ...},
#=> %{"notify-subscribed-event" => "printer-state-changed", "notify-sequence-number" => 2,
#=> "printer-state" => :stopped, "printer-state-reasons" => ["media-empty-error"], ...}
#=> ]
Next time, ask for sequence_numbers: 3. A printer supports subscriptions if its operations-supported includes :create_printer_subscription. CUPS only lets the subscription's owner fetch its events, so send both requests authenticated.
Events only cover what the printer chooses to report. CUPS doesn't tell a printer subscription about jobs being held, released or canceled, and a printer event can carry printer-state-reasons from before the change. To follow everything that happens, use Hippy.stream_events/2.
List the printers on a CUPS server
CUPS-Get-Printers is sent to the server rather than a printer. CUPS includes classes in the results; printer_type: 0, printer_type_mask: 1 leaves them out, and Hippy.Operation.CupsGetClasses returns only classes. Hippy.Operation.CupsGetDefault returns the default printer.
{:ok, res} =
"ipp://localhost:631/"
|> Hippy.Operation.CupsGetPrinters.new(requested_attributes: ["printer-name", "printer-state"])
|> Hippy.send_operation()
Hippy.Message.printers(res)
#=> [
#=> %{"printer-name" => "HP_Color_LaserJet", "printer-state" => :idle},
#=> %{"printer-name" => "Label_Printer", "printer-state" => :stopped}
#=> ]
Send any other operation
Hippy.send_operation/4 sends any IPP or CUPS operation by name (see Hippy.IPP.Operation), with attributes as a map. A job URI is sent as the job the operation is about, and CUPS's server-wide operations go to the server:
Hippy.send_operation(printer, :validate_job, %{"document-format" => "application/pdf", "copies" => 2})
Hippy.send_operation("ipp://localhost:631/jobs/47", :hold_job, %{}, req_options: [auth: {:basic, "user:pass"}])
Hippy.send_operation("ipp://localhost:631/", :cups_get_printers, %{"limit" => 10})
# Administration is sent to /admin/, about a printer.
Hippy.send_operation("ipps://localhost:631/printers/front_desk", :cups_add_modify_printer,
%{
"printer-uri" => "ipp://localhost/printers/front_desk",
"device-uri" => {:uri, "socket://10.0.0.5"},
"printer-info" => "Front desk"
},
endpoint: "https://localhost:631/admin/",
req_options: [auth: {:basic, "admin:pass"}]
)
For complete control over a request, every operation also takes operation_attributes:, job_attributes: and groups:, with each attribute's syntax given explicitly as {syntax, name, value}. These replace an attribute of the same name from attributes: or Hippy's defaults (see Hippy.Message.encode/1 for the syntaxes):
PrintJob.new(printer, document,
job_attributes: [{:integer, "copies", 2}, {:keyword, "x-acme-finish", "matte"}],
groups: [
{:subscription,
[{:keyword, "notify-pull-method", "ippget"}, {:keyword, "notify-events", "job-completed"}]}
]
)
Or build a %Hippy.Message{} yourself (see Hippy.Message.build_request/4) and send it with Hippy.send_message/3.
When things go wrong
Sending returns {:ok, %Hippy.Message{}} or {:error, exception}, so Exception.message/1 describes any error:
| Error | When |
|---|---|
%Hippy.Error{status: ..., message: ..., response: ...} |
The printer answered with an IPP error, such as :client_error_not_found or :server_error_not_accepting_jobs. The whole response is kept. |
%Hippy.HTTPError{status: ...} |
The server answered with an HTTP error instead, such as 401 Unauthorized. |
%Req.TransportError{reason: ...} |
It couldn't be reached (:econnrefused), or didn't answer in time (:timeout). |
{:error, %Hippy.Error{status: :client_error_document_format_not_supported} = error} =
printer
|> PrintJob.new(document, document_format: "application/x-nope")
|> Hippy.send_operation()
Hippy.Message.unsupported(error.response)
#=> %{"document-format" => "application/x-nope"}
Timeouts, and never printing twice
Req waits 15 seconds for a response by default. Sending a large document to a slow printer can take longer, so raise it with timeout: 60_000, which sets both the connect and the response timeout.
A timeout returns {:error, %Req.TransportError{reason: :timeout}}, but the printer may still have accepted the job. Hippy never retries a request, so a job is never submitted twice by accident. Before you resend one yourself, ask Hippy.safe_to_resend?/1:
| Safe to resend | Not safe: the job may have printed |
|---|---|
IPP errors (Hippy.Error) |
Timeouts |
| HTTP 4xx errors | Closed connections |
| Refused connections, unknown hosts | HTTP 5xx errors |
| Failed TLS handshakes |
When it isn't safe, check whether the job exists first, e.g. with GetJobs.new(printer, which_jobs: :not_completed) and a unique job_name. That's why the fleet example above stops, rather than trying the next printer, after an error that isn't safe to resend.
Connecting
Hippy uses Req for HTTP. Pass Req options for one request with req_options:, or for every request in your config. Options given per request are merged over the configured ones:
config :hippy, req_options: [receive_timeout: 30_000]
Hippy.send_operation(op, req_options: [auth: {:basic, "user:pass"}])
IPP version
Requests are sent as IPP 1.1 unless you say otherwise. Some older servers only accept 1.0, and some operations need 2.0 or later. A printer lists the versions it accepts in ipp-versions-supported. Set a default in config, and override it for any request, for example one to a different server:
config :hippy, ipp_version: {1, 0}
Hippy.send_operation(op, ipp_version: {2, 0})
Authentication
Printers and CUPS servers that require authentication answer with an HTTP error, {:error, %Hippy.HTTPError{status: 401}}. Pass credentials with req_options: [auth: {:basic, "user:pass"}], over HTTPS (an https:// or ipps:// printer URI).
With its default policy, CUPS lets anyone print and query, but job management such as Cancel-Job needs authentication when the request comes from another machine:
| Request | Result |
|---|---|
| Cancel-Job over plain HTTP | {:error, %Hippy.HTTPError{status: 426}} (Upgrade Required: use HTTPS) |
| Cancel-Job over HTTPS without credentials | {:error, %Hippy.HTTPError{status: 401}} |
| Cancel-Job for another user's job | {:error, %Hippy.HTTPError{status: 403}} |
| Cancel-Job for your own job, authenticated | {:ok, %Hippy.Message{}}, with Hippy.Message.status/1 :successful_ok |
Self-signed certificates
Certificates are verified by default. CUPS and many printers use a self-signed certificate, which fails verification with {:error, %Req.TransportError{reason: {:tls_alert, {:bad_certificate, _}}}}. Erlang doesn't accept a self-signed server certificate as trusted through cacertfile or cacerts, so either:
- give the server a certificate signed by a CA you trust, and pass the CA with
connect_options: [transport_opts: [cacertfile: "ca.pem"]], or - pin the server's own certificate (for CUPS,
/etc/cups/ssl/<hostname>.crt):
[{:Certificate, pinned, _}] = "cups.crt" |> File.read!() |> :public_key.pem_decode()
verify_fun = fn
cert, {:bad_cert, :selfsigned_peer}, state ->
if :public_key.pkix_encode(:OTPCertificate, cert, :otp) == pinned,
do: {:valid, state},
else: {:fail, :selfsigned_peer}
_cert, {:bad_cert, _} = reason, _state -> {:fail, reason}
_cert, {:extension, _}, state -> {:unknown, state}
_cert, _valid, state -> {:valid, state}
end
Hippy.send_operation(op,
req_options: [connect_options: [transport_opts: [verify_fun: {verify_fun, nil}]]]
)
transport_opts: [verify: :verify_none] turns verification off entirely. Only use it for testing, since it lets anyone on the network impersonate the printer and read the documents you send.
Reading responses
A response is a Hippy.Message. Read its attributes as maps of names to values:
Hippy.Message.job/1: the job, for responses about one job, such as Print-Job and Get-Job-Attributes.Hippy.Message.jobs/1: a map per job, for Get-Jobs.Hippy.Message.printer/1: the printer, for Get-Printer-Attributes.Hippy.Message.printers/1: a map per printer, for CUPS-Get-Printers.Hippy.Message.operation_attributes/1andHippy.Message.unsupported/1: operation attributes (such asstatus-message) and attributes the printer didn't support.Hippy.Message.group/2andHippy.Message.groups/2: any group, by tag.Hippy.Message.status/1: the status, e.g.:successful_ok.
In maps, values are converted for convenience: enums are atoms, URIs are URIs, dates are DateTimes, booleans are true/false and collections are maps. Attributes and collection members the IANA registry defines as sets, such as job-state-reasons, are always lists, even with one value.
Underneath, the message is exactly what was sent over the wire: groups in order, each attribute as {name, [{syntax, value}, ...]} with values as sent. Nothing is lost or reinterpreted, and encoding it again gives the same bytes:
%Hippy.Message{
version: {1, 1},
code: 0x0000,
request_id: 1,
groups: [
{:operation, [{"attributes-charset", [charset: "utf-8"]}, ...]},
{:job, [{"job-id", [integer: 47]}, {"job-state", [enum: 3]}, {"job-state-reasons", [keyword: "none"]}, ...]}
],
data: ""
}
bin |> Hippy.Message.decode() |> Hippy.Message.encode() == bin
#=> true
Vendor enums
Enum attributes are decoded to atoms for common attributes such as job-state and orientation-requested, and left as integers otherwise. To decode others, such as vendor extensions, define an enum and register it by attribute name. Registered enums take precedence over the built-in ones, and xxx-default and xxx-supported use the enum registered for xxx:
defmodule MyApp.VendorState do
use Hippy.IPP.Enum, %{idle: 0x01, busy: 0x02}
end
config :hippy, enums: %{
"x-vendor-state" => MyApp.VendorState,
"input-orientation-requested" => Hippy.IPP.Orientation
}
Development
Run the unit tests with mix test.
Integration tests run Hippy against a real CUPS server and ippeveprinter, CUPS's emulated driverless printer, in a throwaway Docker container (see test/integration/cups/). They are excluded by default and need a running Docker daemon:
mix test --only cups
Hippy.IPP.Attributes is generated from the IANA IPP registry into lib/ipp/attributes.eterm, and the enum values in Hippy.IPP are checked against a copy of it in test/fixtures/ipp_registry.json. To pick up new registrations, regenerate both and run the tests to see what's missing:
mix run scripts/update_ipp_registry.exs
mix test test/ipp_registry_test.exs
Copyright and License
Copyright (c) 2018-2026 Jeff Smith
Hippy source code is licensed under the MIT License.