diff --git a/modules/brother/cmd/brother/ipp.go b/modules/brother/cmd/brother/ipp.go new file mode 100644 index 00000000..306f6ad1 --- /dev/null +++ b/modules/brother/cmd/brother/ipp.go @@ -0,0 +1,236 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// IPP (RFC 8010, RFC 8011) by hand: one operation, Get-Printer-Attributes, which needs no login. + +const ( + ippGetPrinterAttributes = 0x000B + ippOperation = 0x01 + ippEnd = 0x03 + ippInteger = 0x21 + ippEnum = 0x23 + ippKeyword = 0x44 + ippURItag = 0x45 + ippCharset = 0x47 + ippLanguage = 0x48 +) + +// statusAttributes are the attributes printer.status asks for. +var statusAttributes = []string{ + "printer-state", "printer-state-reasons", "marker-names", "marker-levels", "marker-colors", "printer-make-and-model", +} + +// ippURL is where a printer's IPP service answers: the port IPP is registered on, and the path the printer +// serves (CUPS shows ipp:///ipp/port1). A variable so a test can put a server of its own in its place. +var ippURL = func(host string) string { + h := host + if hh, _, err := net.SplitHostPort(host); err == nil { + h = hh + } + if strings.Contains(h, ":") { + h = "[" + h + "]" + } + return "http://" + h + ":631/ipp/port1" +} + +// ippPrinterURI is the printer-uri operation attribute for a host. +func ippPrinterURI(host string) string { + h := host + if hh, _, err := net.SplitHostPort(host); err == nil { + h = hh + } + return "ipp://" + h + "/ipp/port1" +} + +func appendAttr(b []byte, tag byte, name, value string) []byte { + b = append(b, tag) + b = binary.BigEndian.AppendUint16(b, uint16(len(name))) + b = append(b, name...) + b = binary.BigEndian.AppendUint16(b, uint16(len(value))) + return append(b, value...) +} + +// EncodeGetPrinterAttributes is the request: IPP 2.0, operation 0x000B, request id 1, the operation attributes +// RFC 8011 requires first (charset, natural language, printer-uri), and the attributes asked for. +func EncodeGetPrinterAttributes(printerURI string, requested []string) []byte { + b := []byte{0x02, 0x00, 0x00, byte(ippGetPrinterAttributes), 0, 0, 0, 1, ippOperation} + b = appendAttr(b, ippCharset, "attributes-charset", "utf-8") + b = appendAttr(b, ippLanguage, "attributes-natural-language", "en") + b = appendAttr(b, ippURItag, "printer-uri", printerURI) + for i, r := range requested { + name := "" + if i == 0 { + name = "requested-attributes" + } + b = appendAttr(b, ippKeyword, name, r) + } + return append(b, ippEnd) +} + +// IPPValue is one value of an attribute: an integer or enum, or text. +type IPPValue struct { + Tag byte + Integer int + Text string +} + +// IPPAnswer is a decoded response: its status code and its attributes by name, each with its values in order. +type IPPAnswer struct { + Status int + Attributes map[string][]IPPValue +} + +// DecodeIPP reads a response. It reads only what Get-Printer-Attributes answers hold: groups of attributes of +// integers, enums and strings, whose additional values have an empty name. +func DecodeIPP(b []byte) (IPPAnswer, error) { + a := IPPAnswer{Attributes: map[string][]IPPValue{}} + if len(b) < 9 { + return a, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b)) + } + a.Status = int(binary.BigEndian.Uint16(b[2:4])) + i := 8 + last := "" + for i < len(b) { + tag := b[i] + i++ + if tag == ippEnd { + return a, nil + } + if tag <= 0x05 { // a group's start + last = "" + continue + } + if i+2 > len(b) { + return a, fmt.Errorf("the IPP answer ends inside an attribute") + } + nl := int(binary.BigEndian.Uint16(b[i:])) + i += 2 + if i+nl+2 > len(b) { + return a, fmt.Errorf("the IPP answer ends inside an attribute name") + } + name := string(b[i : i+nl]) + i += nl + vl := int(binary.BigEndian.Uint16(b[i:])) + i += 2 + if i+vl > len(b) { + return a, fmt.Errorf("the IPP answer ends inside the value of %q", name) + } + raw := b[i : i+vl] + i += vl + if name == "" { + name = last + } + last = name + v := IPPValue{Tag: tag} + if (tag == ippInteger || tag == ippEnum) && vl == 4 { + v.Integer = int(int32(binary.BigEndian.Uint32(raw))) + } else { + v.Text = string(raw) + } + a.Attributes[name] = append(a.Attributes[name], v) + } + return a, fmt.Errorf("the IPP answer has no end-of-attributes tag") +} + +func (a IPPAnswer) texts(name string) []string { + var out []string + for _, v := range a.Attributes[name] { + out = append(out, v.Text) + } + return out +} + +// printerStates are the values RFC 8011 gives printer-state. +var printerStates = map[int]string{3: "idle", 4: "processing", 5: "stopped"} + +// Marker is one consumable of the printer: a toner, a drum, a belt. A level below 0 is the standard's way +// to say it is unknown (-1) or not reported (-2). +type Marker struct { + Name string `json:"name"` + Level int `json:"level"` + Color string `json:"color,omitempty"` +} + +// PrinterState is what printer.status reads over IPP. +type PrinterState struct { + State string `json:"state"` + StateCode int `json:"state-code"` + Reasons []string `json:"state-reasons"` + MakeAndModel string `json:"make-and-model"` + Markers []Marker `json:"markers"` +} + +// State reads the answer into the printer's state; an answer whose status is not a success is an error. +func (a IPPAnswer) State() (PrinterState, error) { + if a.Status >= 0x0100 { + return PrinterState{}, fmt.Errorf("the printer refused the IPP request with status 0x%04x", a.Status) + } + st := a.Attributes["printer-state"] + if len(st) == 0 { + return PrinterState{}, fmt.Errorf("the IPP answer holds no printer-state") + } + code := st[0].Integer + name, ok := printerStates[code] + if !ok { + name = fmt.Sprintf("unknown (%d)", code) + } + s := PrinterState{State: name, StateCode: code, Reasons: a.texts("printer-state-reasons"), Markers: []Marker{}} + if s.Reasons == nil { + s.Reasons = []string{} + } + if m := a.texts("printer-make-and-model"); len(m) > 0 { + s.MakeAndModel = m[0] + } + names, levels, colors := a.texts("marker-names"), a.Attributes["marker-levels"], a.texts("marker-colors") + for i, n := range names { + m := Marker{Name: n, Level: -1} + if i < len(levels) { + m.Level = levels[i].Integer + } + if i < len(colors) { + m.Color = colors[i] + } + s.Markers = append(s.Markers, m) + } + return s, nil +} + +// PrinterStateOf asks the printer for its state over IPP. The printer is asked on its IPP port, not through the +// pinned HTTPS client, and nothing secret is sent. +func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host), + bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), statusAttributes))) + if err != nil { + return PrinterState{}, err + } + req.Header.Set("Content-Type", "application/ipp") + c := &http.Client{Timeout: 15 * time.Second} + resp, err := c.Do(req) + if err != nil { + return PrinterState{}, fmt.Errorf("the printer's IPP service does not answer: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return PrinterState{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return PrinterState{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status) + } + ans, err := DecodeIPP(body) + if err != nil { + return PrinterState{}, err + } + return ans.State() +} diff --git a/modules/brother/cmd/brother/ipp_test.go b/modules/brother/cmd/brother/ipp_test.go new file mode 100644 index 00000000..0a25934f --- /dev/null +++ b/modules/brother/cmd/brother/ipp_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func ippFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "ipp", name)) + if err != nil { + t.Fatal(err) + } + return b +} + +// fakeIPP replays the captured Get-Printer-Attributes answer (testdata/ipp, 2026-10-20) and records what it +// was sent. It stands in for the printer's port 631: no test reaches a real printer. +func fakeIPP(t *testing.T, answer []byte) (url string, got *[]byte) { + t.Helper() + var sent []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent, _ = io.ReadAll(r.Body) + if r.Header.Get("Content-Type") != "application/ipp" { + http.Error(w, "not ipp", http.StatusUnsupportedMediaType) + return + } + w.Header().Set("Content-Type", "application/ipp") + w.Write(answer) + })) + t.Cleanup(srv.Close) + return srv.URL, &sent +} + +// 1453/AC5: the request the module encodes is byte for byte the captured one the printer answered. +func TestTheIPPRequestIsTheCapturedOne(t *testing.T) { + got := EncodeGetPrinterAttributes(ippPrinterURI("192.0.2.10"), statusAttributes) + if want := ippFixture(t, "get-printer-attributes.request"); !bytes.Equal(got, want) { + t.Errorf("request =\n%x\nwant\n%x", got, want) + } +} + +// 1453/AC5: the captured IPP answer decodes to the printer's state, its reasons and its marker levels. +func TestTheCapturedIPPAnswerDecodes(t *testing.T) { + ans, err := DecodeIPP(ippFixture(t, "get-printer-attributes.answer")) + if err != nil { + t.Fatal(err) + } + st, err := ans.State() + if err != nil { + t.Fatal(err) + } + want := PrinterState{State: "idle", StateCode: 3, Reasons: []string{"toner-low-warning"}, + MakeAndModel: "Brother MFC-L8390CDW series", + Markers: []Marker{{"BK", 40, "#000000"}, {"C", 10, "#00FFFF"}, {"M", 0, "#FF00FF"}, {"Y", 100, "#FFFF00"}}} + if !reflect.DeepEqual(st, want) { + t.Errorf("state = %+v\nwant %+v", st, want) + } +} + +// 1453/AC5: an answer cut short, or one with a failing status, is an error and not a state. +func TestABrokenIPPAnswerIsAnError(t *testing.T) { + good := ippFixture(t, "get-printer-attributes.answer") + if _, err := DecodeIPP(good[:len(good)-20]); err == nil { + t.Errorf("a truncated answer decoded") + } + refused := append([]byte{}, good...) + refused[2], refused[3] = 0x04, 0x00 + ans, err := DecodeIPP(refused) + if err != nil { + t.Fatal(err) + } + if _, err := ans.State(); err == nil || !strings.Contains(err.Error(), "0x0400") { + t.Errorf("error = %v", err) + } +} + +// 1453/AC5: printer.status gives the IPP state and the eSCL state, replayed from the captures, and sends the +// captured request. +func TestPrinterStatusToolGivesTheIPPAndESCLState(t *testing.T) { + url, sent := fakeIPP(t, ippFixture(t, "get-printer-attributes.answer")) + old := ippURL + ippURL = func(string) string { return url } + t.Cleanup(func() { ippURL = old }) + f := newFakeScanner(t, nil) + tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), nil, time.Now) + got, err := tool(t, tl, "printer.status").Run(nil) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + if m["answers"] != true || m["state"] != "idle" || m["make-and-model"] != "Brother MFC-L8390CDW series" { + t.Errorf("answer = %v", m) + } + if r := m["state-reasons"].([]string); !reflect.DeepEqual(r, []string{"toner-low-warning"}) { + t.Errorf("reasons = %v", r) + } + if mk := m["markers"].([]Marker); len(mk) != 4 || mk[0] != (Marker{"BK", 40, "#000000"}) { + t.Errorf("markers = %v", mk) + } + if sc := m["scanner"].(map[string]any); sc["answers"] != true || sc["state"] != "Idle" || sc["adf-state"] != "ScannerAdfEmpty" { + t.Errorf("scanner = %v", sc) + } + if !bytes.Equal(*sent, ippFixture(t, "get-printer-attributes.request")) { + t.Errorf("the printer was sent %x", *sent) + } +} + +// 1453/AC4 and AC5: a part that does not answer is answers false with why, the other part is still given; an +// unset printer or scanner is said by name and nothing is contacted. +func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) { + old := ippURL + ippURL = func(string) string { return "http://127.0.0.1:1/ipp/port1" } + t.Cleanup(func() { ippURL = old }) + f := newFakeScanner(t, nil) + tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), nil, time.Now) + got, err := tool(t, tl, "printer.status").Run(nil) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + if m["answers"] != false || m["why"] == nil || m["scanner"].(map[string]any)["answers"] != true { + t.Errorf("answer = %v", m) + } + for _, c := range []struct{ js, want string }{ + {`{"scanner":"` + f.base() + `"}`, "printer is not set"}, + {`{"printer":"192.0.2.10"}`, "scanner is not set"}, + {`{}`, "printer and scanner"}, + } { + tl := tools(settingsFile(t, c.js), nil, time.Now) + if _, err := tool(t, tl, "printer.status").Run(nil); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: error = %v, want %q", c.js, err, c.want) + } + } +} diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 2f9ca11d..22084ddd 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -33,6 +33,22 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool PasswordFile: os.Getenv("BROTHER_ADMIN_PASSWORD")}, nil } return []stdio.Tool{ + { + Name: "printer.status", + Description: "The printer's state without a login: over IPP its state (idle, processing or stopped), its state " + + "reasons (such as toner-low-warning), its toner and other markers with their levels in percent (below 0 " + + "is unknown), and over eSCL the scanner's state and its document feeder's. A part that does not answer " + + "is said as answers false with why. Read-only.", + Run: func(map[string]any) (any, error) { + s, err := service() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return s.PrinterStatus(ctx) + }, + }, { Name: "printer.settings", Description: "Read one page of the printer's admin site, read-only: log in with the secret admin-password (the " + diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 06d3f6c4..9e22a14c 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -141,7 +141,7 @@ func TestTheManifestDeclaresAndClaimsTheScannerAndPrinterSeats(t *testing.T) { for _, want := range []seat{ {Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}}, // restart is round 2 of issue 1453, and is not declared yet. - {Name: "printer", Scope: "mesh", Serves: []string{"settings"}}, + {Name: "printer", Scope: "mesh", Serves: []string{"status", "settings"}}, } { for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} { found := false diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index b3330e58..89cc13ff 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -80,3 +80,38 @@ func nonNilForms(f []Form) []Form { } return f } + +// 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, fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", + strings.Join(unset, " and ")) + } + 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 +} diff --git a/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.request b/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.request index f33e31f9..58ed36b6 100644 Binary files a/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.request and b/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.request differ diff --git a/modules/brother/module.json b/modules/brother/module.json index 91f4eb00..cf5dfb2d 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -82,6 +82,7 @@ "name": "printer", "scope": "mesh", "serves": [ + "status", "settings" ] } @@ -100,11 +101,13 @@ "name": "printer", "scope": "mesh", "serves": [ + "status", "settings" ] } ], "tools": [ + "printer.status", "printer.settings", "scanner.scan", "scanner.status",