Hippy

Hippy is an Internet Printing Protocol (IPP) client implementation in Elixir for performing distributed printing over HTTP. In addition to printing documents, IPP also supports a number of other operations such as retrieving print job status, pausing jobs, resuming jobs, etc.

CUPS provides a standard IPP server out of the box, though some higher-end networked printers are also capable of providing their own server. That said, testing at this early stage has been 100% against CUPS, which is likely more tolerant of any outstanding abuses of the protocol in Hippy alpha versions. ;)

Obligatory Warning

This is currently very alpha-stage software and will be evolving quickly. Please only consider it for production use at your own risk.

Installation

Add hippy to your dependencies in mix.exs:

def deps do
  [
    {:hippy, "~> 0.6.0"}
  ]
end

Configuration

Hippy uses Req for HTTP and requires Elixir 1.15 or later. Extra Req options (authentication, TLS settings, timeouts, etc.) can be passed per request, or for every request through application 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"}])

Enum attributes are decoded to atoms for common attributes such as job-state and orientation-requested, and left as integers otherwise. To decode other enum attributes (e.g. 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
}

Errors

An IPP error (a client-error or server-error status) is returned as {:error, %Hippy.Error{}}, with the status, the server's status message and the whole response:

{:error, %Hippy.Error{status: :client_error_document_format_not_supported} = error} =
  Hippy.Operation.PrintJob.new(printer_uri, doc, document_format: "application/x-nope")
  |> Hippy.send_operation()

Hippy.Response.unsupported(error.response)
%{"document-format" => "application/x-nope"}

HTTP errors are {:error, {:http_error, status, body}}.

{:ok, response} doesn't mean every setting was applied. A printer can accept a job but ignore or change attributes it doesn't support, for example printing one-sided when asked for two-sided-long-edge. It then returns :successful_ok_ignored_or_substituted_attributes, which is still {:ok, response}, and lists the attributes in the response's unsupported group. When the settings matter, check it:

{:ok, res} = Hippy.send_operation(op)

case Hippy.Response.unsupported(res) do
  empty when map_size(empty) == 0 -> :printed_as_asked
  ignored -> {:printed_without, ignored}   # e.g. %{"sides" => "two-sided-long-edge"}
end

Authentication, TLS and timeouts

Authentication

Printers and CUPS servers that require authentication return an HTTP error instead of an IPP response, as {:error, {:http_error, status, body}}. 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, {:http_error, 426, _}} (Upgrade Required: use HTTPS)
Cancel-Job over HTTPS without credentials {:error, {:http_error, 401, _}}
Cancel-Job for another user's job {:error, {:http_error, 403, _}}
Cancel-Job for your own job, authenticated {:ok, %Hippy.Response{status_code: :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:

[{:Certificate, pinned, _}] = :public_key.pem_decode(File.read!("cups.crt"))

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.

Timeouts and retries

Req waits 15 seconds for a response by default. Sending a large document to a slow printer can take longer, so raise it with req_options: [receive_timeout: 60_000].

A timeout returns {:error, %Req.TransportError{reason: :timeout}}, but the printer may still have accepted the job. Hippy never retries a request, so a Print-Job is never submitted twice by accident. Don't blindly resend after a timeout either: check whether the job exists first, e.g. with Hippy.Operation.GetJobs.new(uri, which_jobs: :not_completed) and a unique job_name.

Usage Examples

Print a PDF document

doc = File.read!("tps_report.pdf") 
printer_uri = "http://localhost:631/printers/HP_Color_LaserJet"

Hippy.Operation.PrintJob.new(printer_uri, doc,
  job_name: "TPS Report",
  document_format: "application/pdf",
  orientation: Hippy.IPP.Orientation.portrait()
)
|> Hippy.send_operation()

{:ok,
 %Hippy.Response{
   job_attributes: [
     [
       {:uri, "job-uri", "ipp://localhost:631/jobs/47"},
       {:integer, "job-id", 47},
       {:enum, "job-state", :pending},
       {:text_without_language, "job-state-message", ""},
       {:keyword, "job-state-reasons", "none"}
     ]
   ],
   operation_attributes: [
     [
       {:charset, "attributes-charset", "utf-8"}, 
       {:natural_language, "attributes-natural-language", "en"}
     ]
   ],
   request_id: 1,
   status_code: :successful_ok,
   version: %Hippy.IPPVersion{major: 1, minor: 1}
 }}

Stream a large document

A document can also 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).
Hippy.Operation.PrintJob.new(printer_uri, File.stream!("big.pdf", 64_000),
  document_format: "application/pdf"
)
|> Hippy.send_operation()

Some embedded printers may not accept chunked requests; for those, read the file into a binary first.

Send other attributes

Every operation accepts attributes:, a map of attribute names to values. Hippy looks each one up in the IANA IPP registry to work out its syntax and which group it goes in, so copies and sides go in the job attributes and job-name in the operation attributes:

Hippy.Operation.PrintJob.new(printer_uri, doc,
  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. {syntax, value} gives the syntax explicitly, and is needed for attributes the registry doesn't know, which go in the operation's main group (job attributes for Print-Job). See Hippy.AttributeInference.

To control the syntax and group yourself, every operation also accepts operation_attributes:, and Print-Job job_attributes:, as {syntax, name, value} tuples, in the same form responses are decoded to. Other attribute groups go in groups:, as {tag, attributes}. These replace an attribute of the same name from attributes: or Hippy's defaults. See Hippy.Encoder.encode/1 for the supported syntaxes.

Hippy.Operation.PrintJob.new(printer_uri, doc,
  job_attributes: [{:integer, "copies", 2}, {:keyword, "x-acme-finish", "matte"}],
  groups: [
    {:subscription_attributes,
     [{:keyword, "notify-pull-method", "ippget"}, {:keyword, "notify-events", "job-completed"}]}
  ]
)

Send any 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_uri, :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"}]
)

Get recently completed jobs

Hippy.Operation.GetJobs.new("http://localhost:631/printers/HP_Color_LaserJet")
|> Hippy.send_operation()

{:ok,
 %Hippy.Response{
   job_attributes: [
     [
       {:name_without_language, "job-name", "TPS Report"},
       {:name_without_language, "job-originating-user-name", "anonymous"},
       {:integer, "job-id", 47},
       {:enum, "job-state", :completed},
       {:keyword, "job-state-reasons", "processing-to-stop-point"},
       {:integer, "job-media-sheets-completed", 8}
     ],
     [
       {:name_without_language, "job-name", "Untitled Job"},
       {:name_without_language, "job-originating-user-name", "anonymous"},
       {:integer, "job-id", 46},
       {:enum, "job-state", :completed},
       {:keyword, "job-state-reasons", "processing-to-stop-point"},
       {:integer, "job-media-sheets-completed", 2}
     ],
     # ...
   ],
   operation_attributes: [
     [
       {:charset, "attributes-charset", "utf-8"},
       {:natural_language, "attributes-natural-language", "en"},
       {:integer, "limit", 500}
     ]
   ],
   request_id: 1,
   status_code: :successful_ok,
   version: %Hippy.IPPVersion{major: 1, minor: 1}
 }}

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} =
  Hippy.Operation.CupsGetPrinters.new("ipp://localhost:631/",
    requested_attributes: ["printer-name", "printer-state"]
  )
  |> Hippy.send_operation()

Hippy.Response.printers(res)
[
  %{"printer-name" => "HP_Color_LaserJet", "printer-state" => :idle},
  %{"printer-name" => "Label_Printer", "printer-state" => :stopped}
]

Convert the response to maps for easier access by key

Hippy.Response has functions that return attributes as maps of names to values:

{:ok, res} = Hippy.Operation.PrintJob.new(printer_uri, doc) |> Hippy.send_operation()

Hippy.Response.job(res)
%{
  "job-id" => 47,
  "job-state" => :pending,
  "job-state-message" => "",
  "job-state-reasons" => ["none"],
  "job-uri" => %URI{scheme: "ipp", host: "localhost", port: 631, path: "/jobs/47", ...}
}

Hippy.Response.job(res)["job-id"]
47

Attributes and collection members the IANA registry defines as sets, such as job-state-reasons, are always lists, even with one value. Other attributes are a single value, or a list if a printer sends several anyway. Hippy.AttributeGroup.to_map/1 converts any list of attribute tuples the same way.

Development

Run the unit tests with mix test.

Integration tests run Hippy against a real CUPS server 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 (c) 2018-2026 Jeff Smith

Hippy source code is licensed under the MIT License.