brother: the printer seat's status verb, IPP Get-Printer-Attributes by hand and the eSCL state, replayed from captures (issue 1453)

This commit is contained in:
2026-10-20 21:12:45 +02:00
parent 34260d8c29
commit 6782816707
7 changed files with 434 additions and 1 deletions
+236
View File
@@ -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://<printer>/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()
}
+143
View File
@@ -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)
}
}
}
+16
View File
@@ -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 " +
+1 -1
View File
@@ -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
+35
View File
@@ -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
}
+3
View File
@@ -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",