Own printers, driverless only
The fourth panel this desktop handed to GNOME Settings, and the last one worth owning. Driverless only, deliberately. Adding a printer that describes its own capabilities over IPP is supported; choosing a PPD or fetching a vendor driver is not, and the page says so rather than pretending. That restraint is the whole design: a wrong driver produces a printer that accepts jobs, reports success, and prints nothing, which is the worst failure this page could ship because it looks like it worked. A printer old enough to need a PPD stays a job for the system printer tool. Printers and the queue are separate cards because they answer separate questions. Which printers exist is one; where a document went is the other, and it is the one that actually brings someone here -- so the queue is a single list across every printer. Device URIs are validated by scheme before reaching CUPS, whose backends run as root. file: and pipe: do not lead to a printer and are refused here rather than further down. This machine has no printer, so the page was built against a temporary CUPS queue that was created, exercised through the service, and removed; the service was confirmed to observe the removal rather than merely perform it. Discovery and the driverless add path are verified by their refusals rather than against hardware. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
+286
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Printers, through CUPS' own API rather than by parsing lpstat.
|
||||
|
||||
Driverless only, on purpose. This adds printers that describe their own
|
||||
capabilities over IPP -- IPP Everywhere, which is every printer sold in roughly
|
||||
the last decade -- and does not choose PPDs or download vendor drivers. Driver
|
||||
selection is most of what the panel this replaces does, and getting it wrong
|
||||
produces a printer that accepts jobs and silently prints nothing. A printer old
|
||||
enough to need a PPD is better served by system-config-printer, and the page
|
||||
says so rather than pretending.
|
||||
|
||||
panama-printers snapshot
|
||||
panama-printers discover
|
||||
panama-printers add URI NAME
|
||||
panama-printers remove NAME
|
||||
panama-printers set-default NAME
|
||||
panama-printers pause NAME | resume NAME
|
||||
panama-printers cancel JOB_ID
|
||||
panama-printers test-page NAME
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# What may be printed to. Everything else -- file:, pipe:, a shell fragment --
|
||||
# is refused: this validates the URI rather than trusting a settings page,
|
||||
# because a device URI is handed to a backend that runs as root.
|
||||
SAFE_SCHEMES = ("ipp", "ipps", "socket", "dnssd", "http", "https")
|
||||
URI = re.compile(r"^(%s)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%%-]+$" % "|".join(SAFE_SCHEMES))
|
||||
|
||||
# CUPS queue names: no spaces, slashes, or '#'.
|
||||
QUEUE = re.compile(r"^[A-Za-z0-9_.-]{1,127}$")
|
||||
|
||||
# The only model this adds. Naming it in one place means the driverless promise
|
||||
# is checkable rather than scattered.
|
||||
DRIVERLESS_MODEL = "everywhere"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or CUPS failure."""
|
||||
|
||||
|
||||
def connect():
|
||||
try:
|
||||
import cups
|
||||
|
||||
return cups, cups.Connection()
|
||||
except Exception as error: # noqa: BLE001 - no cups is a legitimate state
|
||||
raise BoundaryError("The printing service is not answering.") from error
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer.") from error
|
||||
|
||||
|
||||
def service_state() -> dict:
|
||||
active = run(["systemctl", "is-active", "cups.service"]).stdout.strip()
|
||||
enabled = run(["systemctl", "is-enabled", "cups.service"]).stdout.strip()
|
||||
avahi = run(["systemctl", "is-active", "avahi-daemon.service"]).stdout.strip()
|
||||
return {
|
||||
"running": active == "active",
|
||||
# "on demand" is a real answer and a reasonable configuration, not a
|
||||
# problem to report: socket activation starts CUPS when something prints.
|
||||
"startsAtBoot": enabled == "enabled",
|
||||
"startMode": enabled or "unknown",
|
||||
"discoveryAvailable": avahi == "active",
|
||||
}
|
||||
|
||||
|
||||
def printer_state(attributes: dict) -> str:
|
||||
"""CUPS reports state as a number; people read words."""
|
||||
return {3: "idle", 4: "printing", 5: "stopped"}.get(
|
||||
int(attributes.get("printer-state", 0)), "unknown")
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
cups, connection = connect()
|
||||
try:
|
||||
raw = connection.getPrinters()
|
||||
default = connection.getDefault()
|
||||
jobs = connection.getJobs(which_jobs="not-completed", my_jobs=False,
|
||||
requested_attributes=[
|
||||
"job-id", "job-name", "job-printer-uri",
|
||||
"job-state", "job-originating-user-name",
|
||||
"job-impressions", "time-at-creation"])
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("The printing service could not be read.") from error
|
||||
|
||||
printers = []
|
||||
for name, attributes in raw.items():
|
||||
printers.append({
|
||||
"name": name,
|
||||
"description": str(attributes.get("printer-info") or name),
|
||||
"location": str(attributes.get("printer-location") or ""),
|
||||
"makeAndModel": str(attributes.get("printer-make-and-model") or ""),
|
||||
"uri": str(attributes.get("device-uri") or ""),
|
||||
"state": printer_state(attributes),
|
||||
"stateMessage": str(attributes.get("printer-state-message") or ""),
|
||||
"accepting": bool(attributes.get("printer-is-accepting-jobs", True)),
|
||||
"shared": bool(attributes.get("printer-is-shared", False)),
|
||||
"isDefault": name == default,
|
||||
})
|
||||
printers.sort(key=lambda entry: (not entry["isDefault"], entry["name"].lower()))
|
||||
|
||||
queue = []
|
||||
for job in jobs.values() if isinstance(jobs, dict) else []:
|
||||
printer_uri = str(job.get("job-printer-uri") or "")
|
||||
queue.append({
|
||||
"id": int(job.get("job-id") or 0),
|
||||
"name": str(job.get("job-name") or "Untitled"),
|
||||
"printer": printer_uri.rsplit("/", 1)[-1] if printer_uri else "",
|
||||
"state": {3: "pending", 4: "held", 5: "processing",
|
||||
6: "stopped", 7: "cancelled"}.get(int(job.get("job-state", 0)), "unknown"),
|
||||
"user": str(job.get("job-originating-user-name") or ""),
|
||||
"pages": int(job.get("job-impressions") or 0),
|
||||
"createdAt": int(job.get("time-at-creation") or 0),
|
||||
})
|
||||
queue.sort(key=lambda entry: entry["createdAt"])
|
||||
|
||||
return {"printers": printers, "jobs": queue, "service": service_state(), "error": ""}
|
||||
|
||||
|
||||
def discover() -> dict:
|
||||
"""Printers announcing themselves on the network, driverless ones only.
|
||||
|
||||
A printer that does not advertise IPP Everywhere is reported as found but
|
||||
not addable, rather than offered and then failing at the point of adding.
|
||||
"""
|
||||
if not shutil.which("avahi-browse"):
|
||||
return {"found": [], "error": "Network discovery is not available."}
|
||||
|
||||
found: dict[str, dict] = {}
|
||||
for service in ("_ipps._tcp", "_ipp._tcp"):
|
||||
result = run(["avahi-browse", "-rtp", service], timeout=20)
|
||||
for line in result.stdout.splitlines():
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
parts = line.split(";")
|
||||
if len(parts) < 10:
|
||||
continue
|
||||
name, host, port, text = parts[3], parts[6], parts[8], parts[9]
|
||||
scheme = "ipps" if service == "_ipps._tcp" else "ipp"
|
||||
resource = ""
|
||||
for field in re.findall(r"\"([^\"]*)\"", text):
|
||||
if field.startswith("rp="):
|
||||
resource = field[3:]
|
||||
# Only IPP Everywhere. The attribute a driverless printer publishes
|
||||
# is its PDF/JPEG support; without it, CUPS would need a driver.
|
||||
driverless = "application/pdf" in text or "URF=" in text or "urf=" in text
|
||||
uri = f"{scheme}://{host}:{port}/{resource}" if resource else f"{scheme}://{host}:{port}/"
|
||||
found[uri] = {
|
||||
"name": name.replace("\\032", " "),
|
||||
"uri": uri,
|
||||
"host": host,
|
||||
"driverless": driverless,
|
||||
}
|
||||
return {"found": sorted(found.values(), key=lambda entry: entry["name"]), "error": ""}
|
||||
|
||||
|
||||
def require_queue(name: str) -> str:
|
||||
if not QUEUE.fullmatch(name or ""):
|
||||
raise BoundaryError("That is not a printer name.")
|
||||
return name
|
||||
|
||||
|
||||
def add(uri: str, name: str) -> None:
|
||||
if not URI.fullmatch(uri or ""):
|
||||
raise BoundaryError("That address cannot be used to reach a printer.")
|
||||
require_queue(name)
|
||||
|
||||
cups, connection = connect()
|
||||
try:
|
||||
# ppdname is the driverless model and nothing else. There is no branch
|
||||
# here that selects a PPD, which is what keeps the promise checkable.
|
||||
connection.addPrinter(name, device=uri, ppdname=DRIVERLESS_MODEL)
|
||||
connection.enablePrinter(name)
|
||||
connection.acceptJobs(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
message = str(error)
|
||||
if "device-error" in message or "1284" in message:
|
||||
raise BoundaryError(
|
||||
"The printer did not answer, or does not support driverless printing.") from error
|
||||
if "not-authorized" in message or "forbidden" in message.lower():
|
||||
raise BoundaryError("Adding a printer was not authorized.") from error
|
||||
raise BoundaryError("That printer could not be added.") from error
|
||||
|
||||
|
||||
def remove(name: str) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.deletePrinter(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be removed.") from error
|
||||
|
||||
|
||||
def set_default(name: str) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.setDefault(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be made the default.") from error
|
||||
|
||||
|
||||
def set_paused(name: str, paused: bool) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
if paused:
|
||||
connection.disablePrinter(name)
|
||||
else:
|
||||
connection.enablePrinter(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be changed.") from error
|
||||
|
||||
|
||||
def cancel(job_id: str) -> None:
|
||||
if not job_id.isdigit():
|
||||
raise BoundaryError("That is not a job.")
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.cancelJob(int(job_id))
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That job could not be cancelled.") from error
|
||||
|
||||
|
||||
def test_page(name: str) -> None:
|
||||
require_queue(name)
|
||||
result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError((result.stderr.strip() or "The test page could not be sent.")[:200])
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if arguments == ["discover"]:
|
||||
print(json.dumps(discover(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 3 and arguments[0] == "add":
|
||||
add(arguments[1], arguments[2])
|
||||
elif len(arguments) == 2 and arguments[0] == "remove":
|
||||
remove(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "set-default":
|
||||
set_default(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "pause":
|
||||
set_paused(arguments[1], True)
|
||||
elif len(arguments) == 2 and arguments[0] == "resume":
|
||||
set_paused(arguments[1], False)
|
||||
elif len(arguments) == 2 and arguments[0] == "cancel":
|
||||
cancel(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "test-page":
|
||||
test_page(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | "
|
||||
"set-default NAME | pause NAME | resume NAME | cancel JOB_ID | test-page NAME")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"printers": [], "jobs": [], "service": service_state()}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user