Let printer.print take a file path and upload and print it in one call (issue 1551)

This commit is contained in:
2026-10-21 01:41:21 +02:00
parent 23c3a2396e
commit a5bc37459b
5 changed files with 147 additions and 4 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ makes it (mode 0755) when it files the first scan.
|---|---|
| `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` |
| `printer.upload` | one part of a document to print later (`upload` id, `part`, `parts`, `sha256` of the whole, `data` base64): stored as `uploads/<upload>.pdf` or `.jpg` once all parts came and the sha256 matches; a wrong sha256, a part out of range or a document that is neither PDF nor JPEG stores nothing |
| `printer.print` | prints a kept scan (`object`) or a completed upload (`upload`) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state |
| `printer.print` | prints a file on the machine the tool runs on (`path`, absolute: the tool reads it, uploads it in parts and prints it in one call; PDF or JPEG by its bytes, never its name), a kept scan (`object`) or a completed upload (`upload`, for modules that send bytes over the bus) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state (with `path`, the upload id and the size when `path` was given) |
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title and forms with their fields, each with its current value (a radio group as one field with the checked button's value, a checkbox with `checked`, a select with the selected option) and `options`; the page's links only with `links: true`, hidden fields only with `hidden: true`; a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
### Trust and the password
@@ -539,3 +539,58 @@ func TestOnlyATooLargeAnswerEndsTheAskATransientFailureIsRetried(t *testing.T) {
t.Errorf("a too large answer: action %v, want term", act)
}
}
// 1551: printer.print with a path reads the file, uploads it in parts and prints it in one call; a document of
// several parts arrives whole, and the answer has the job's id and state.
func TestPrinterPrintTakesAPathAndUploadsAndPrintsInOneCall(t *testing.T) {
fake := newIPPFake(t, 31, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(2*PartSize + 1234)
file := filepath.Join(t.TempDir(), "letter.bin") // the name says nothing: the bytes are read
if err := os.WriteFile(file, doc, 0o600); err != nil {
t.Fatal(err)
}
got, err := r.call(t, "printer.print", map[string]any{"path": file, "copies": float64(2)})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["job-id"] != float64(31) || m["job-state"] != "processing" || m["format"] != "application/pdf" || m["bytes"] != len(doc) {
t.Errorf("answer = %v", m)
}
attrs, sent := fake.request(t)
if !bytes.Equal(sent, doc) || attrs.Attributes["copies"][0].Integer != 2 {
t.Errorf("sent %d bytes (want %d), attributes %v", len(sent), len(doc), attrs.Attributes)
}
ops := r.bus.ops()
if len(ops) != 4 || ops[0] != OpUpload || ops[2] != OpUpload || ops[3] != OpPrint {
t.Errorf("asks = %v, want three upload parts and a print", ops)
}
}
// 1551: a file that is neither a PDF nor a JPEG by its bytes is refused whatever its name, and a path that is
// relative, missing, a folder, or named together with an object is refused; nothing is queued.
func TestPrinterPrintRefusesAPathThatIsNotAPDFOrJPEG(t *testing.T) {
fake := newIPPFake(t, 1, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
dir := t.TempDir()
text := filepath.Join(dir, "fake.pdf")
os.WriteFile(text, []byte("just text, named .pdf"), 0o600)
for name, args := range map[string]map[string]any{
"text named pdf": {"path": text},
"relative": {"path": "fake.pdf"},
"missing": {"path": filepath.Join(dir, "none.pdf")},
"folder": {"path": dir},
"with object": {"path": text, "object": sha256Hex([]byte("x")) + ".pdf"},
} {
if _, err := r.call(t, "printer.print", args); err == nil {
t.Errorf("%s: printed", name)
}
}
if _, err := r.call(t, "printer.print", map[string]any{"path": text}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
t.Errorf("error = %v", err)
}
if r.bus.asks() != 0 || fake.body != nil {
t.Errorf("%d asks queued, printer sent %d bytes", r.bus.asks(), len(fake.body))
}
}
+19
View File
@@ -122,3 +122,22 @@ func List(folder string, limit int) ([]Entry, error) {
}
return out, nil
}
// ReadDocument is the file at path, read whole: a regular file of at most most bytes.
func ReadDocument(path string, most int64) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("path %s cannot be read: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("path %s is not a file", path)
}
if info.Size() > most {
return nil, fmt.Errorf("file %s is %d bytes, over the %d the module takes", path, info.Size(), most)
}
doc, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("path %s cannot be read: %w", path, err)
}
return doc, nil
}
+27 -3
View File
@@ -131,16 +131,40 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
},
{
Name: "printer.print",
Description: "Print a document on the printer: a scan kept in the bucket (object) or a completed upload (upload). The device " +
"process sends it as an IPP Print-Job in its own format (PDF or JPEG) and answers the job's id and state.",
Description: "Print a document on the printer: a file on the machine this tool runs on (path: reads it, uploads it in parts " +
"and prints it, in one call), a scan kept in the bucket (object), or a completed upload (upload; for a module that sends " +
"bytes over the bus). Only PDF and JPEG, told by the file's bytes and not its name. The device " +
"process sends it as an IPP Print-Job in its own format and answers the job's id and state (with path, the upload id and the size).",
Input: map[string]any{"type": "object", "properties": map[string]any{
"path": str("an absolute path of a PDF or JPEG file on the machine the tool runs on"),
"object": str("a scan's key, as scan.added names it"),
"upload": str("a completed upload's id"),
"copies": num("1 to 99 (default 1)"),
"sides": str("one-sided (default), two-sided-long-edge or two-sided-short-edge"),
"media": str("the paper's size name (default " + DefaultMedia + ")"),
}},
Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { return s.PrinterPrint(ctx, a) }, 2*time.Minute),
Run: func(args map[string]any) (any, error) {
path, err := strArg(args, "path")
if err != nil {
return nil, err
}
a, err := askFrom(args)
if err != nil {
return nil, err
}
s, err := service()
if err != nil {
return nil, err
}
if path == "" {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
return s.PrinterPrint(ctx, a)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
return s.PrintFile(ctx, path, a)
},
},
{
Name: "scanner.document",
+45
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"slices"
"strings"
@@ -290,6 +291,50 @@ func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, err
return s.call(ctx, a, s.AnswerWait)
}
// PrintFile prints a PDF or a JPEG that lies on the machine the tool runs on: it reads the file, uploads it in
// parts under an upload id of its own and prints that, in one call (issue 1551). The type is read from the
// file's first bytes, never its name; any other file is refused before anything is queued. The answer is the
// print's, with the upload id and the file's size.
func (s *Service) PrintFile(ctx context.Context, path string, a Ask) (map[string]any, error) {
if a.Object != "" || a.Upload != "" {
return nil, fmt.Errorf("name path, or object, or upload: one of the three")
}
if !filepath.IsAbs(path) {
return nil, fmt.Errorf("path %q is not an absolute path on the machine the tool runs on", path)
}
a.Upload = "p-" + newAskID()
if err := checkPrint(&a); err != nil {
return nil, err
}
if s.Settings.Printer == "" {
return nil, unsetError("printer")
}
doc, err := ReadDocument(path, MostParts*PartSize)
if err != nil {
return nil, err
}
if _, _, err := SniffFormat(doc); err != nil {
return nil, fmt.Errorf("file %s: %w", path, err)
}
sum, n := sha256Hex(doc), partCount(len(doc))
for i := 1; i <= n; i++ {
part, _, _ := cutPartOf(doc, i, PartSize)
up, err := s.PrinterUpload(ctx, Ask{Upload: a.Upload, Part: i, Parts: n, SHA256: sum, Data: base64.StdEncoding.EncodeToString(part)})
if err != nil {
return nil, fmt.Errorf("part %d of %d of %s could not be uploaded: %w", i, n, path, err)
}
if i == n && up["complete"] != true {
return nil, fmt.Errorf("the upload of %s is not complete after its last part: %v", path, up)
}
}
out, err := s.PrinterPrint(ctx, a)
if err != nil {
return nil, fmt.Errorf("%s is uploaded as %s but could not be printed: %w", path, a.Upload, err)
}
out["path"], out["upload"], out["bytes"] = path, a.Upload, len(doc)
return out, nil
}
// PrinterPrint asks the device process to print a kept scan or a completed upload.
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
a.Op = OpPrint