From a5bc37459b07726c585d8cd90e24e5251ec90fa1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 01:41:21 +0200 Subject: [PATCH] Let printer.print take a file path and upload and print it in one call (issue 1551) --- modules/brother/README.md | 2 +- modules/brother/cmd/brother/documents_test.go | 55 +++++++++++++++++++ modules/brother/cmd/brother/file.go | 19 +++++++ modules/brother/cmd/brother/main.go | 30 +++++++++- modules/brother/cmd/brother/printer.go | 45 +++++++++++++++ 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/modules/brother/README.md b/modules/brother/README.md index 7393401c..aaa732e5 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -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/.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 diff --git a/modules/brother/cmd/brother/documents_test.go b/modules/brother/cmd/brother/documents_test.go index ceaff0ba..541f5094 100644 --- a/modules/brother/cmd/brother/documents_test.go +++ b/modules/brother/cmd/brother/documents_test.go @@ -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)) + } +} diff --git a/modules/brother/cmd/brother/file.go b/modules/brother/cmd/brother/file.go index 44de023a..a5f0cc56 100644 --- a/modules/brother/cmd/brother/file.go +++ b/modules/brother/cmd/brother/file.go @@ -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 +} diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 436bb9c6..dfead8e5 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -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", diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index bcf7c8ed..505e0a77 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -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