Files
mesh-catalog/modules/brother/cmd/brother/printer.go
T

314 lines
11 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// pagePattern is a page of the admin site a caller may ask for: an absolute path under the site ending in
// .html, with no query, no dot-dot and no second slash at its start (which would name another host).
var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`)
// DefaultPage is the page printer.settings reads when none is asked for.
const DefaultPage = "/home/status.html"
// The asks the verbs put to the device process (ADR 0468 clause 1; the shape of ADR 0436's mail-sends): the tools
// never hold a credential, so each verb queues an ask on the work-queue stream device-asks, and the device process,
// an account of its own, answers under the ask's id in the state device-answers.
const (
// EventAsked is the event on the work queue; the stream device-asks carries it.
EventAsked = "device.asked"
// AsksStream is the work-queue stream's name on the bus: the manifest's local name device-asks, capitalised
// with its hyphen an underscore, as the controller composes a stream's own bus name.
AsksStream = "DEVICE_ASKS"
// AnswersState is the state the device process writes its answers in.
AnswersState = "device-answers"
// DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller
// delivers its bus account as the own secret broker-device.
DeviceProcessID = "device"
// AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does.
AnswerWait = 60 * time.Second
ScanWait = 180 * time.Second
// StaleAfter is how old an ask may be when the device process takes it: older than twice the longest wait,
// nobody waits for it any more, so the device is not touched for it.
StaleAfter = 2 * ScanWait
)
// The operations of an ask.
const (
OpSettings = "settings"
OpScan = "scan"
OpDocument = "document"
OpUpload = "upload"
OpPrint = "print"
)
// Ask is what a verb queues: the operation, when it was asked, and the operation's own arguments.
type Ask struct {
ID string `json:"id"`
Op string `json:"op"`
At time.Time `json:"asked-at"`
Page string `json:"page,omitempty"` // settings
Links bool `json:"links,omitempty"` // settings: the page's links are answered too
Hidden bool `json:"hidden,omitempty"` // settings: the hidden fields are answered too
Object string `json:"object,omitempty"` // document, print: a scan's key
Upload string `json:"upload,omitempty"` // upload, print: an upload's id
Part int `json:"part,omitempty"` // document, upload: counted from 1
Parts int `json:"parts,omitempty"` // upload
SHA256 string `json:"sha256,omitempty"` // upload: the whole document's
Data string `json:"data,omitempty"` // upload: the part, base64
Copies int `json:"copies,omitempty"` // print
Sides string `json:"sides,omitempty"` // print
Media string `json:"media,omitempty"` // print
}
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
type AskBus interface {
Publish(id string, body any) error
Answer(id string) (json.RawMessage, error)
}
type sdkAskBus struct{}
func (sdkAskBus) Publish(id string, body any) error {
return stdio.Emit(EventAsked, body, stdio.EmitOptions{MessageID: id})
}
func (sdkAskBus) Answer(id string) (json.RawMessage, error) {
e, err := stdio.State(AnswersState).Get(id)
if err != nil || e == nil {
return nil, err
}
return e.Value, nil
}
// answerWait, scanWait and answerPoll are what the tools wait with; a test shortens them.
var (
answerWait = AnswerWait
scanWait = ScanWait
answerPoll = 500 * time.Millisecond
)
// askBus is the bus the tools use; a test puts a bus of its own in its place.
var askBus AskBus = sdkAskBus{}
func newAskID() string {
b := make([]byte, 12)
rand.Read(b)
return hex.EncodeToString(b)
}
// call queues the ask and waits for the device process's answer, at most wait. An answer that is an error is
// the error. Nothing here reads, opens or sends a credential (ADR 0436 §12).
func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[string]any, error) {
poll := s.AnswerPoll
if wait <= 0 {
wait = AnswerWait
}
if poll <= 0 {
poll = 500 * time.Millisecond
}
ask.ID, ask.At = newAskID(), s.Now().UTC()
if err := s.Asks.Publish(ask.ID, ask); err != nil {
return nil, fmt.Errorf("the ask for the device process could not be queued: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, wait)
defer cancel()
for {
raw, err := s.Asks.Answer(ask.ID)
if err != nil {
return nil, fmt.Errorf("the device process's answers cannot be read: %w", err)
}
if raw != nil {
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the device process's answer is not JSON: %w", err)
}
if msg, bad := out["error"].(string); bad {
return nil, fmt.Errorf("%s", msg)
}
return out, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the device is slow (ask %s)",
wait, ask.ID)
case <-time.After(poll):
}
}
}
// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for
// its answer. Nothing is queued before the page is checked and printer and printer-certificate are known to be set.
func (s *Service) PrinterSettings(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
if page == "" {
page = DefaultPage
}
if !validPage(page) {
return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page)
}
if err := s.Settings.MissingPrinter(); err != nil {
return nil, err
}
return s.call(ctx, Ask{Op: OpSettings, Page: page, Links: links, Hidden: hidden}, s.AnswerWait)
}
func validPage(page string) bool {
return pagePattern.MatchString(page) && !strings.Contains(page, "..") && !strings.Contains(page, "//")
}
var (
scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`)
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// Sides are the values the printer's sides take.
var sidesValues = []string{"one-sided", "two-sided-long-edge", "two-sided-short-edge"}
// DefaultMedia is the paper printer.print takes when none is named.
const DefaultMedia = "iso_a4_210x297mm"
// checkDocument is what is wrong with an ask for a scan's part, or nil.
func checkDocument(a Ask) error {
if !scanKeyPattern.MatchString(a.Object) {
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
}
if a.Part < 1 {
return fmt.Errorf("part is a whole number from 1")
}
return nil
}
// checkUpload is what is wrong with an ask for an upload's part, or nil: the numbers, the SHA-256, the size.
func checkUpload(a Ask) error {
if !uploadPattern.MatchString(a.Upload) {
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
}
if a.Parts < 1 || a.Parts > MostParts {
return fmt.Errorf("parts is a whole number from 1 to %d, not %d", MostParts, a.Parts)
}
if a.Part < 1 || a.Part > a.Parts {
return fmt.Errorf("part %d is out of range: the upload has parts 1 to %d", a.Part, a.Parts)
}
if !sha256Pattern.MatchString(a.SHA256) {
return fmt.Errorf("sha256 is the whole document's SHA-256, 64 lower-case hex digits")
}
raw, err := base64.StdEncoding.DecodeString(a.Data)
if err != nil {
return fmt.Errorf("data is not base64: %v", err)
}
if len(raw) > MaxPart {
return fmt.Errorf("a part is at most %d bytes (700 KiB), not %d", MaxPart, len(raw))
}
return nil
}
// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in.
func checkPrint(a *Ask) error {
if (a.Object == "") == (a.Upload == "") {
return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither")
}
if a.Object != "" && !scanKeyPattern.MatchString(a.Object) {
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
}
if a.Upload != "" && !uploadPattern.MatchString(a.Upload) {
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
}
if a.Copies == 0 {
a.Copies = 1
}
if a.Copies < 1 || a.Copies > 99 {
return fmt.Errorf("copies is a whole number from 1 to 99, not %d", a.Copies)
}
if a.Sides == "" {
a.Sides = "one-sided"
}
if !slices.Contains(sidesValues, a.Sides) {
return fmt.Errorf("sides is one of %s, not %q", strings.Join(sidesValues, ", "), a.Sides)
}
if a.Media == "" {
a.Media = DefaultMedia
}
if !mediaPattern.MatchString(a.Media) {
return fmt.Errorf("media %q is not a media size name such as %s", a.Media, DefaultMedia)
}
return nil
}
// ScanDocument asks for one part of a scan kept in the bucket.
func (s *Service) ScanDocument(ctx context.Context, object string, part int) (map[string]any, error) {
a := Ask{Op: OpDocument, Object: object, Part: part}
if err := checkDocument(a); err != nil {
return nil, err
}
return s.call(ctx, a, s.AnswerWait)
}
// PrinterUpload sends one part of a document to be printed later, and says whether the upload is complete.
func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, error) {
a.Op = OpUpload
if err := checkUpload(a); err != nil {
return nil, err
}
return s.call(ctx, a, s.AnswerWait)
}
// 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
if err := checkPrint(&a); err != nil {
return nil, err
}
if s.Settings.Printer == "" {
return nil, unsetError("printer")
}
return s.call(ctx, a, s.AnswerWait)
}
// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login
// (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and
// scanner is not set is said by name, and nothing is contacted.
func (s *Service) PrinterStatus(ctx context.Context) (map[string]any, error) {
var unset []string
if s.Settings.Printer == "" {
unset = append(unset, "printer")
}
if s.Settings.Scanner == "" {
unset = append(unset, "scanner")
}
if len(unset) > 0 {
return nil, unsetError(unset...)
}
out := map[string]any{}
if st, err := PrinterStateOf(ctx, s.Settings.Printer); err != nil {
out["answers"] = false
out["why"] = err.Error()
} else {
out["answers"] = true
out["state"] = st.State
out["state-code"] = st.StateCode
out["state-reasons"] = st.Reasons
out["make-and-model"] = st.MakeAndModel
out["markers"] = st.Markers
}
if es, err := NewClient(s.Settings.Scanner).Status(ctx); err != nil {
out["scanner"] = map[string]any{"answers": false, "why": err.Error()}
} else {
out["scanner"] = map[string]any{"answers": true, "state": es.State, "adf-state": es.AdfState}
}
return out, nil
}