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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user