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.5.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
}

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}
 }}

Send other attributes

Every operation accepts operation_attributes:, and Print-Job also accepts job_attributes:, for attributes Hippy doesn't have an option for. They're {syntax, name, value} tuples, in the same form responses are decoded to, and replace an attribute of the same name that Hippy would otherwise send. See Hippy.Encoder.encode/1 for the supported syntaxes.

a4 = %{"x-dimension" => {:integer, 21000}, "y-dimension" => {:integer, 29700}}

Hippy.Operation.PrintJob.new(printer_uri, doc,
  document_format: "application/pdf",
  job_attributes: [
    {:integer, "copies", 2},
    {:keyword, "sides", "two-sided-long-edge"},
    {:collection, "media-col", %{"media-size" => {:collection, a4}}}
  ]
)
|> Hippy.send_operation()

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}
 }}

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 with several values are lists. 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

The enum values in Hippy.IPP are checked against a copy of the IANA IPP registry in test/fixtures/ipp_registry.json. To pick up new registrations, refresh it 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.