opex-flow can sit between a production system (MES, ProLine, ERP) and the coding printers
on a line. A business system posts an order to the [ingest API](#ingest-api); a flow maps it
to printer jobs; a `printer` node loads the matching message on each printer and fills its
variable fields. Every job lands in a [job log](#job-log), and the caller gets a per-printer
answer.

## Protocols

| `protocol` | Printers | Link | What a job sends |
| --- | --- | --- | --- |
| `markem_imaje` | Markem-Imaje 9020/9030/9040 CIJ ("V24" protocol) | TCP (built-in Ethernet, default port 2101) or RS-232 | Select library message `5Ah` (1–127), external variables `5Bh` (1–10), counter reset `3Ah` / set `51h`. Status: jet state `32h` + faults `3Bh`. |
| `hitachi` | Hitachi RX / RX2 / UX CIJ (standard communication) | RS-232 (factory 4800 8N1, null-modem) or a serial device server | Go online `1F 73`, recall message `1F 20 31` (number), print items `DLE n text` (DC2 + resend once when the printer is "not ready"), count block initial value `1F 2C 31`. Status: clock inquiry `1F 75`. |
| `zipher` | Videojet CLARiTY family: TTO printers, CLARiTY laser controller | TCP (text-comms port, off by default) or RS-232 | `SLA\|job\|field=value\|` (or `JDA` without a job). Status: `GST`. |
| `eotsum` | Videojet/Alltec lasers with an AllprintBasic script (no CLARiTY) | RS-232 | Your templates, framed `STX payload EOT checksum`, ACK `06` / NAK `15`. |
| `ascii` | Any text-line input, e.g. "Scan & Print" / "Scan & Select" serial input of TIJ printers | TCP or RS-232 | Your templates + `terminator`; optional `ack` / `nak` reply regexes (no `ack` = no reply awaited). |
| `domino` | Domino Ax-Series CIJ (Ax150i/350i/550i), Codenet 2; other Domino families are not supported yet | TCP (default port 7000) or RS-232 (9600 8N1) | Select label slot `ESC P 1 NNN`, one external-data block `ESC OE` (values by index), counter refused. Status: identity `A` + status `1 C`. See [Domino](#domino-ax-series-codenet). |
| `zebra` | Zebra ZPL II printers (ZT/ZD/ZQ …); Honeywell / Intermec in ZPL (ZSim) emulation | TCP 9100 (raw) or RS-232 | Stored format recall `^XF` + `^FN<n>` fields + `^PQ`, or an inline ZPL template; values hex-escaped (`^FH`, `^CI28` for UTF-8). Status: `~HS`. See [Label printers](#label-printers-zebra-tsc-sato). |
| `tsc` | TSC TSPL / TSPL2 label printers (TTP / TDP / DA / TE / ML) | TCP 9100 or serial / USB | Inline template (`SIZE`/`GAP`/`DIRECTION`/`CLS`/`TEXT`/`BARCODE`/`QRCODE`/`PRINT`) or `RUN "FORM.BAS"`. Status: `<ESC>!?` (one byte). |
| `sato` | SATO SBPL label printers (CL4NX/CL6NX, PW4NX, FX3-LX, CT4-LX, S84NX …) | TCP 9100 or RS-232C | `<ESC>A … <ESC>Z` template (`<Q>` quantity) or stored form overlay recall `</>NN`. Status: `ENQ` (Status 4 / 3). |

`eotsum` and `ascii` render templates: `{message}`, `{counter}` and field names are
placeholders; `{BATCH:10}` left-aligns in 10 characters (truncating), `{WEIGHT:>7}`
right-aligns, `{COUNT:06}` zero-pads, `{{` is a literal brace. Their `data` template sends
every field each time, so a job that updates only `WEIGHT` reuses the last batch and date
sent to that printer; `defaults` covers fields no job has sent yet.

## Printers

Printers come from `printers:` in the config file (top level) and from the API; API edits
are saved in `<data_dir>/printers.runtime.json` and win on restart, so IT can move or swap a
printer without editing the config file.

```yaml
printers:
  - id: mi9040-s6l-01
name: Sachet 6L line 1
line: S6L-01                  # production line (flows can target it)
protocol: markem_imaje        # + protocol options: head, jet, enq
transport: { kind: tcp, host: 192.168.10.11, port: 2101 }
messages: { FMT-3: "1", FMT-4: "2" }   # logical format → library message number
fields: { LINE1: "1", LINE2: "2" }     # logical field → external variable
timeout_ms: 3000              # per reply (default 3000)
connect_timeout_ms: 5000      # default 5000
  - id: rx2-dus-01
protocol: hitachi             # options: go_online, count_block, counter_start, recall_delay_ms
transport: { kind: serial, path: /dev/ttyUSB0, baud: 4800, data_bits: 8, parity: none, stop_bits: 1 }
messages: { FMT-1: "1" }
fields: { LINE1: "1", LINE2: "2" }
```

**Name maps.** Flows speak logical names; each printer maps them to its own. A non-empty map
is a whitelist: a printer takes only the fields it maps (a flow may send a superset), and
refuses a message missing from its `messages` (it never prints with the wrong layout). Map a
message to `""` to accept it without selecting anything (one fixed layout). Empty maps pass
names through.

Each printer has one connection, opened on the first job and kept open; jobs to one printer
run one at a time, different printers in parallel. A job that fails on a dropped connection
is retried once on a fresh one.

## Domino Ax-Series (Codenet)

`protocol: domino` drives the **Domino Ax-Series** continuous ink jet printers (Ax150i, Ax350i,
Ax550i) over **Codenet 2**, the remote protocol Domino documents for them. It does **not** cover
Domino's other families: Gx/G thermal ink jet, D/F/Dx lasers and Cx/Vx printers use Domino's
*Dynamark* interface protocol, which Domino does not publish (see
[what to ask Domino](#what-to-confirm-with-domino)). `variant: ax` (the default and only value
today) reserves the switch for them; until then use `protocol: ascii` templates once Domino
gives you that guide.

**Link.** TCP to the printer's Codenet port (**7000** default; 7001, 7002 and 7004 are also
available) or RS-232 (printer defaults 9600 baud 8N1, RTS/CTS flow control). On the printer:
*Home > Setup > Printer network > Advanced*: Protocol Setting = **Codenet**, Protocol Mode =
**TCP**, TCP Port, tick **Protocol Enabled**, restart. TCP needs the Basic Comms hardware pack,
RS-232 the RS232 pack.

**Framing.** Every command is `ESC id parameters EOT` (`1B … 04`). The printer answers `ACK`
(`06`) or `NAK` (`15` + three digits; the driver reports the code with its meaning from the
manual's NAK table, e.g. `NAK 017 (specified print label number is invalid)`). With the
printer setting *Response Length = Fixed* ACK is `06 30 30 30`: set `fixed_response: true`.
Unsolicited status reports (`ESC 1 …`) between a command and its reply are skipped.

| Job part | Sent as | Mapping |
| --- | --- | --- |
| `message` | `ESC P 1 NNN EOT`: get label from store NNN and put it online | `messages:` value = label slot `1`–`255` |
| `fields` | **one** `ESC OE LLLL data EOT` external-data block: the values joined by `delimiter` in index order (gaps stay empty) | `fields:` value = item index `1`–`16` |
| `counter` | not supported: refused with a clear error | Codenet documents no counter command; make the counter part of a field the label reads, or reset it on the printer |
| status | `ESC A ? EOT` (identity, also sent on every connect: the manual says it is the first command on a link) + `ESC 1 C ? EOT` (current status) + `ESC OP ? EOT` (FiFo setup, raw) | |

The data block is `0123,456,ABCD`-style: the label's *updatable text fields* (external data,
comma delimited) each read their item by index (manual, "Insert Updatable Text Field",
example 2). Values are printable ASCII without the delimiter (a block is at most 1024 bytes). Every
update re-sends all fields, so fields a job leaves out keep the value last sent to that printer
(until the connector restarts), like the `ascii` driver.

Status returns the printer type and firmware, the status number (`000` = printer ready), its
class (`normal` / `warning` / `printing_inhibited` / `undefined` by the first digit), the ink jet
and the time of the status change.

```yaml
printers:
  - id: ax350-s6l-01
name: Sachet 6L line 1 — Domino Ax350i
line: S6L-01
protocol: domino
# variant: ax                  # only Ax-Series Codenet 2 today
# delimiter: ","               # must equal the delimiter of the label's updatable text fields
# fixed_response: false        # true when the printer is set to Response Length: Fixed
transport: { kind: tcp, host: 192.168.10.41, port: 7000 }
# transport: { kind: serial, path: /dev/ttyUSB0, baud: 9600, data_bits: 8, parity: none, stop_bits: 1, flow_control: hardware }
messages: { FMT-3: "3", FMT-4: "4" }          # logical format → label slot (001–255) in the printer's label store
fields: { BATCH: "1", PD: "2", ED: "3" }      # logical field → index in the external-data block (1–16)
```

### Verified and assumed

**Verified in Domino's manual** (EPT033760 Issue 1): the `ESC … EOT` framing, ACK/NAK and the
NAK code table, `P` (label select), `OE` (external data), `A` (identity), `1 C` (status and its
reply layout), the ports, the RS-232 defaults and the printer settings *Send Response* and
*Response Length*. The status request and ACK reply were also seen on a real Ax350i in a
field report (TCP 7000).

**Assumed, confirm on your printer:**

- The label has updatable text fields whose source is *external data* with your `delimiter` and
  the indexes in `fields:`. The driver never uploads label designs.
- How long an external-data block lives (consumed per print, kept until replaced, behaviour when
  the label changes) depends on the printer's FiFo setup (`OP`). The driver never writes `OP`;
  the status call returns its raw current value.
- Counters / serial numbers (`]`, `U`) appear in the manual's compatibility tables but are not
  specified there, so they are refused instead of guessed.
- The meaning of status numbers is decoded from the manual's status table by the last two digits
  (the table is partly garbled in the copy we read); the number itself is always returned.

### Commissioning checklist

1. Protocol = Codenet, Mode = TCP (or serial settings), a port, **Protocol Enabled**, restart.
2. Printer setting **Send Response = On Processed**. *On Received* ACKs before the data is
   validated, so a bad label slot would look successful.
3. `GET /api/printers/{id}` then `POST /api/printers/{id}/status`: identity and status come back
   (`ready: true` at status `000`).
4. Build the label in the printer UI with updatable text fields (external data), note slot number
   and field indexes, put them in `messages:` / `fields:`.
5. Run a job by hand (`POST /api/printers/{id}/jobs`) and read the printed label; try a wrong
   slot to see the NAK message in the job log.
6. Check what happens to a field when no new data arrives for the next print (FiFo behaviour).

### What to confirm with Domino

- The **Dynamark Interface Communications User Guide** (and the Dynamark Authentication
  Protocol guide) for Gx/G, D/F/Dx, Cx/Vx: transport/port, command syntax and variable
  addressing. Domino's EtherNet/IP command list names the commands (`LOADPROJECT`, `SETTEXT`,
  `SETVARIABLES`, `SETCOUNT`, `GETVERSION`, `BEGINTRANS`, `EXECTRANS`, …) but not their wire form.
- For Ax: the `OE` note about clearing the buffer, `OP` settings for your use, and the counter
  command (`]`, `U`).

### Sources

- Domino, *Ax-Series Codenet Protocol*, EPT033760 Issue 1, Aug 2017 ([copy](https://cdck-file-uploads-us1.s3.dualstack.us-west-2.amazonaws.com/flex026/uploads/nodered/original/3X/e/d/ed3894b3132f75d3d6fcbb335af4c1d73bee4d6f.pdf)); manuals are also on [MyDomino](https://mydomino.domino-printing.com/).
- [Field report](https://forum.inductiveautomation.com/t/using-ignition-to-communicate-and-control-an-ax350i-domino-inkjet-printer/88780) with an Ax350i over TCP 7000 and `ESC … EOT` framing.
- Domino, [*EtherNet/IP Command List*](https://www.domino-printing.com/resources/english/ethernetip/ethernet-command-list.pdf) (Dynamark command names).
- Domino, [*Dynamark Authentication Protocol*](https://www.domino-printing.com/z-api/custom/domino/downloadmydominoasset/325f7769-bce8-45b1-8c60-44bc565073ad/6fba649d-dfc2-45d4-9945-bc5a091c6808) (Doc-0018740 R03: lists Cx, D/F, Dx, G, Gx, Vx; UTF-8 line protocol over TCP).

## Label printers (Zebra, TSC, SATO)

`zebra`, `tsc` and `sato` print one label per job on thermal label printers: pack, pallet and
shipping labels with the order's data. They differ from coding printers in two ways. A job is a
whole page description (there is no "current message" to edit field by field), and the same
flow field map picks the data. Each driver has two ways to say what to print:

- a **stored** format or program in the printer (`message` names it), or
- an **inline template** of the printer's `templates:` map. `message` is the template name and
  `${field}` placeholders are replaced by the escaped value of the job field whose *native* name
  (the right-hand side of `fields:`) is `field`.

The native field name **`quantity`** is the label count (`fields: { QTY: quantity }`); `${quantity}`
puts it in a template. Without it the quantity is 1. `counter` jobs are refused on all three (use
the printer's own serial-number command inside the format).

A refused printer state ends the job with `rejected` (the flow sees *Fail*, nothing was sent). No
answer is `timeout` (*Retry*). Every status check is made before the job is sent.

### Zebra (ZPL II, TCP 9100)

Send to the printer's raw port **9100** (or serial). Honeywell and Intermec printers in ZPL
(**ZSim**) emulation use this same driver: set the printer's command language to ZPL, and see
the note below.

**Save a format once** with `^DF` (any ZPL tool or `nc printer 9100`), numbering the variable
fields with `^FN`:

```zpl
^XA^DFE:SHIPLBL.ZPL^FS
^FO50,50^A0N,40,40^FN1"Order"^FS
^FO50,110^A0N,40,40^FN2"Customer"^FS
^FO50,170^BCN,80^FN1^FS
^XZ
```

Each job then sends `^XA^XFE:SHIPLBL.ZPL^FS^FN1^FDSO-1001^FS^FN2^FDACME^FS^PQ4^XZ`:

```yaml
printers:
  - id: zt411-pack-01
name: Pack station 1
line: PACK-01
protocol: zebra
# drive: "E:"                 # where stored formats live (R: RAM, E: flash)
# precheck: true              # ~HS before the job: refuse when paused / head open / out of media
# confirm: false              # poll ~HS until no labels remain (confirm_timeout_ms, confirm_poll_ms)
transport: { kind: tcp, host: 192.168.10.60, port: 9100 }
messages: { SHIP: SHIPLBL, PALLET: PALLET }   # logical message → stored format name / template name
fields: { ORDER: "1", CUSTOMER: "2", SKU: sku, QTY: quantity }   # logical field → ^FN number, ${placeholder} or quantity
templates:                                    # inline jobs; message = template name
  PALLET: |
    ^XA
    ^FO50,50^A0N,50,50^FH^FD${sku}^FS
    ^FO50,130^BCN,100^FH^FD${sku}^FS
    ^XZ
```

With that map, a job `{message: PALLET, fields: {SKU: "A-1"}}` sends
`^XA^FO50,50^A0N,50,50^FH^FDA-1^FS^FO50,130^BCN,100^FH^FDA-1^FS^PQ1^XZ` (template lines are joined without
separators; `^PQ<qty>` is added before the last `^XZ` unless the template has `^PQ${quantity}` itself).

**Values are escaped.** Every value is written with `^FH` hex escapes (`_5E` for `^`, `_7E` for `~`,
`_5F` for `_`, `_XX` for controls), so a value can never end its field or start a command.
Non-ASCII text goes out as UTF-8 bytes with `^CI28` (firmware V60.14 / V50.14 or later). In
templates, every `${field}` must sit in a field with `^FH` before its `^FD`; the connector rejects the
printer configuration otherwise.

**Status** is `~HS`: the three host-status strings become paper out, pause, head open, ribbon
out (thermal transfer mode), buffer full, over / under temperature, formats in the receive buffer
and labels remaining. With `precheck` on, a job to a printer that is paused, head open, out of
media / ribbon or too hot is refused with `printer not ready: print head is open` (or the
matching reason); a full receive buffer and a printer that does not answer are *Retry*. Zebra
documents that the printer **stays silent** to `~HS` while media out, ribbon out, head open or
over-temperature, so on such printers an unready state can also show as a timeout. With
`confirm: true` the connector polls `~HS` after sending until no labels remain on two polls in a
row; a stall (printer stopped answering, head opened mid-batch, timeout) is *Fail* (never retried,
so a batch cannot print twice).

### TSC (TSPL / TSPL2)

```yaml
printers:
  - id: tsc-ship-01
protocol: tsc
transport: { kind: tcp, host: 192.168.10.61, port: 9100 }   # or serial / USB bridge
fields: { NAME: name, SKU: sku, QTY: quantity }
templates:
  SHIP: |
    SIZE 100 mm,60 mm
    GAP 3 mm,0 mm
    DIRECTION 1
    CLS
    TEXT 20,20,"3",0,1,1,"${name}"
    BARCODE 20,80,"128",60,1,0,2,2,"${sku}"
    PRINT 1,${quantity}
```

Lines are sent with CR LF. A `"` in a value becomes `\["]` (the manual's escape); control
characters and the sequence `\[` are refused. Add `CODEPAGE UTF-8` to the template to print
non-ASCII text. A stored program is run with `message: run:FORM.BAS` (`RUN "FORM.BAS"`, TSPL2
printers only); the manual documents no way to pass variables to it, so these jobs carry no fields.
Status is `<ESC>!?`: one byte (head open, paper jam, out of paper, out of ribbon, pause, printing,
other error), refused before the job is sent on every problem bit.

### SATO (SBPL)

Templates use the manual's notation: `<` stands for ESC and `>` is dropped, so
`<A><V>100<H>200<XM>${text}<Q>${quantity}<Z>` is `ESC A ESC V100 ESC H200 ESC XM… ESC Q… ESC Z`
(`\<` and `\>` are literal). Templates start with `<A>` and end with `<Z>`.

```yaml
printers:
  - id: sato-pallet-01
protocol: sato
# encoding: ascii             # or latin1 (single-byte code page); anything else is refused
transport: { kind: tcp, host: 192.168.10.62, port: 9100 }   # Port 3: data + status
messages: { PALLET: PALLET, STD: "form:01" }
fields: { TEXT: text, CODE: code, QTY: quantity }
templates:
  PALLET: "<A><V>100<H>200<P>2<L>0202<XM>${text}<V>300<H>200<B>103100*${code}*<Q>${quantity}<Z>"
```

`message: form:01` sends `ESC A ESC / 01 ESC Q n ESC Z`, which prints the form overlay saved with
`<&>01` (fixed content only; data fields need a template). Status is `ENQ`: the printer's
`PROTOCOL` setting must be **Status 4 (ENQ)** or Status 3. Offline, print halt and error codes
(head open, paper end, ribbon end, media / sensor / head / cutter errors …) refuse the job; buffer
near full is *Retry*. Values containing control characters (including ESC) are refused.

### Honeywell / Intermec

Honeywell (ex-Intermec) PM / PC / PD printers ship with Fingerprint as the default command
language but can run **ZPL emulation (ZSim)**: select ZPL as the printer's command language and
configure it as a `zebra` printer. How much of `^XF` / `^FN` / `^FH` / `^CI28` / `~HS` a model's
ZSim covers is in Honeywell's *ZSIM Command Reference*; set `precheck: false` on a model that
does not answer `~HS`. There is no Fingerprint (`PRTXT` / `PRINTFEED`) driver: it needs a
downloaded BASIC-like program per label, which the public documents do not cover well enough to
drive safely.

### Sources and unconfirmed points

Zebra, [*ZPL II, ZBI 2, Set-Get-Do, Mirror, WML Programming Guide*](https://docs.zebra.com/us/en/printers/software/zpl-pg/zpl-ii,-zbi-2,-set-get-do,-mirror,-wml-programming-guide.html)
(`~HS`, `^XF`, `^FN`, `^FH`, `^PQ`, `^CI`); TSC, [*TSPL/TSPL2 Programming Manual*](https://fs.tscprinters.com/system/files/31-0000001-00_tspl_tspl2_programming_3_0.pdf);
SATO, [*SBPL Command Reference* v7.0](https://www.satoamerica.com/content/dam/sato/sales/content/us/en/support-downloads/printers/industrial-printers/cl4-snr/Public_SBPL%20CommandReference_EN_v7.0.pdf.coredownload.inline.pdf);
Honeywell, [*ZSIM Command Reference*](https://assets.grandandtoy.com/assets/Additional-pdf4/10/77/1077786014.pdf).
Research notes with every `[UNCONFIRMED]` point are in the repository: `docs/printers/printer-zebra-zpl.md`,
`printer-tsc-tspl.md`, `printer-sato-sbpl.md`, `printer-honeywell-intermec.md`. The ones to check on a real printer:
Zebra's silent `~HS` in error states and `^CI28` firmware support; how a literal `\[` is written in TSPL; the byte-level
SBPL status reply (the test vectors come from the manual's layout and status table, not from a capture).

## The `printer` node

A printer node has **exactly one target**:

| Target | Reaches | Example |
| --- | --- | --- |
| `printer` | one printer (id, may be a `${path}` template) | `{ "type": "printer", "printer": "vj3340-s6l-13b", "message": "${metadata.message}" }` |
| `line` | **every enabled printer whose config has `line: <that line>`**, looked up at run time (literal or `${path}` template) | `{ "type": "printer", "line": "S6L-13", "message": "${metadata.message}", "fields_from": "metadata.fields" }` |
| `jobs_from` | the printers named in an array of `{ printer, message?, fields?, counter? }` in the record (the jobs run concurrently) | `{ "type": "printer", "jobs_from": "jobs" }` |

For `printer` and `line` the same `message`, `fields` (`{ "BATCH": "${order.batch}" }`),
`fields_from` (path of an object of fields) and `counter` (`reset` or digits) templates apply
to each printer reached, and **each printer then applies its own message / field maps** (the
whitelist above: unmapped fields are dropped, an unmapped message is refused). At least one of
them is required. Values are `${path}` lookups into the record; a value that renders empty is
not sent. `timeout_ms` caps each job (default: from the printer's timeouts, within the
invocation deadline).

On success the record continues with
`printer_result: { ok, printers: [{ printer_id, status, detail, duration_ms }] }`. A refused
or invalid job fails the node (`Term`); an unreachable or silent printer is a retry (`Nak`);
with several printers the node fails if any of them failed (the others' jobs still ran, see
the job log). A `line` with no enabled printer fails the node ("no enabled printer is on
line …"). In a dry run (`POST /api/flow/test`) the jobs are recorded, not sent: each printer
node adds an entry with every job (`printer`, `message`, `fields`, `counter`) to `sink_effects`.

Deploy-time checks: a printer node without anything to print is an **error**; a literal
`printer` id or `line` that no configured printer matches is only a **warning**, so a flow can
be deployed before the printers are commissioned (templated targets are checked at run time).

## Lines and the per-line pattern

Give every printer a `line` (`line: S6L-13`, letters/digits/`-_.`); `GET /api/printers/lines`
returns `{ lines: [{ line, printers: [ids] }] }`. Many printers across lines that make
different products are best kept as **one small branch per line** rather than one bulk node:

1. a `transform` turns the incoming order into a machine record whose `metadata` carries the
   `line`, the `message` and the `fields` (no printer lists in the code);
2. a `branch` routes by `metadata.line` (one port per line, `else` → a node that refuses the
   order, so the caller gets a `422` naming the line);
3. one `printer` node per line with `"line": "<line>"`.

Commissioning a printer on an existing line needs no flow edit (set `line:` on it); a new line
needs one branch port and one printer node. A printer that needs its own message or field
override gets a single-`printer` node of its own instead. The "Order to coding printer by line" example
(see [Examples](/docs/examples)) builds this pattern: the branch, one printer node per line and a refusal
for an unknown line.

## Ingest API

`POST /api/ingest/{source}/{id}?wait_ms=30000` (bearer token) takes any JSON object and runs
it through the flow source matching `{source}:{id}` or `{source}:*` (never the catch-all
`*`). The call waits for the flow to finish:

| HTTP | `result` | Meaning |
| --- | --- | --- |
| 200 | `done` | Every step and printer succeeded. |
| 422 | `failed` | Refused (bad order data, printer NAK); resending the same body fails again. |
| 503 | `retry` | A printer was unreachable or silent; resend later. |
| 202 | `pending` | Still running after `wait_ms`; see `GET /api/printers/jobs?invocation=`. |
| 404 | | No flow source matches. |

```json
{ "ok": false, "result": "failed", "invocation_id": 1761712345,
  "error": "set-printers: 1 of 2 printer job(s) failed: rx2-dus-01: rejected: set 2 field(s): NAK",
  "printers": [ { "printer_id": "mi9040-s6l-01", "status": "ok", "detail": "select message 2: ACK; set 2 field(s): ACK", … },
            { "printer_id": "rx2-dus-01", "status": "rejected", … } ] }
```

## Printer API and job log

| Method | Path | |
| --- | --- | --- |
| GET | `/api/printers` | Printers (incl. `line`) with origin (`config`/`runtime`), connection state, job counters, last job. |
| GET | `/api/printers/lines` | Printers grouped by production line: `{ lines: [{ line, printers: [ids] }] }`. |
| GET / PUT / DELETE | `/api/printers/{id}` | Read, add or replace (body = printer config), remove. |
| POST | `/api/printers/import` | Bulk create / replace: body = the `printers:` list of `printers.yaml` (YAML), a bare list, or JSON (send `Content-Type: application/json`). All-or-nothing: every printer is validated first (`422 { create, update, errors: [{ id?, message }] }` when any is refused). `?dry_run=true` only returns `{ create, update, errors }`. Example: `curl -X POST -H "Authorization: Bearer $TOKEN" --data-binary @printers.yaml "$HOST/api/printers/import?dry_run=true"`. |
| POST | `/api/printers/{id}/status` | Live status exchange (printer state, faults, clock: whatever the protocol offers). |
| POST | `/api/printers/{id}/jobs` | Run a job by hand: `{ "message": "FMT-3", "fields": { "LINE1": "…" } }`. |
| GET | `/api/printers/jobs?printer=&invocation=&before=&limit=` | Job log, newest first. |

The job log (`<data_dir>/printer_jobs.db`) keeps every job (printer, invocation, source,
logical message/fields/counter, outcome, steps, duration) for 90 days.

> **Caution**
>
> **Commissioning checks.** Markem-Imaje: the serial link needs the host's DTR held on (the
> printer's DSR); Ethernet and serial V24 are exclusive. Hitachi RX2-B: RS-232 is an option
> (EZJ127A board + software option 20); set "Communication mode = overwrite-enabled".
> Videojet CLARiTY: enable the text-comms TCP port in CLARiTYConfig. Send one job per printer
> with `POST /api/printers/{id}/jobs` before going live.