epson-usb 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ Metadata-Version: 2.4
2
+ Name: epson-usb
3
+ Version: 0.1.0
4
+ Summary: USB (IEEE 1284.4 / D4) access to Epson printers: D4 session, EPSON-CTRL frames, EEPROM, status. Not affiliated with Seiko Epson.
5
+ Author: Onur Kesim, Ircama
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/onur-kesim/epson-l3251-usb-reset
8
+ Project-URL: Issues, https://github.com/onur-kesim/epson-l3251-usb-reset/issues
9
+ Keywords: epson,usb,d4,ieee-1284.4,eeprom,printer
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Printer
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: epson_usb/LICENSE
17
+ Requires-Dist: pyusb
18
+ Dynamic: license-file
19
+
20
+ Not affiliated with Seiko Epson.
21
+
22
+ # epson_usb
23
+
24
+ USB (IEEE 1284.4 / D4) access to Epson printers.
25
+
26
+ ```console
27
+ pip install epson-usb
28
+ ```
29
+
30
+ The distribution is called `epson-usb`; the import name is `epson_usb`.
31
+
32
+ **Status.** This package is a demonstration of what has been measured, not a
33
+ supported product. No maintenance is promised: patches are welcome, but fixes for
34
+ models the maintainer cannot reproduce are not, and no dates are given (in the
35
+ maintainer's own words on the tracker: "for those I can take patches but cannot
36
+ promise fixes, and I would rather not put a date on it",
37
+ [#35](https://github.com/Ircama/epson_print_conf/issues/35#issuecomment-5798831443)).
38
+
39
+ ## What has been measured on real hardware
40
+
41
+ Each row is one physical unit. "Standalone tool" means
42
+ [`epson_l3251_usb_reset.py`](https://github.com/onur-kesim/epson-l3251-usb-reset),
43
+ the program this library was extracted from, **not** the code in this package.
44
+
45
+ | # | Printer | OS | Who | Code that ran | What was observed | Source |
46
+ |---|---|---|---|---|---|---|
47
+ | 1 | L3251 | Windows | Onur Kesim | standalone tool | D4 session with the credit handshake over the Windows `USBPRINT` interface, no driver replacement. EEPROM reads and writes are answered on the secondary USB interface (`MI_01` on this unit). Full 256-cell bank-0 backup; counter reset with read-back verification. | [#35, 28 Aug 2026](https://github.com/Ircama/epson_print_conf/issues/35#issuecomment-5452048045) |
48
+ | 2 | L3251 | Windows | Onur Kesim | standalone tool | The waste counter is little-endian. What settled it was the printer's own error state: `0xCC 0x18` reads 6348 (100.0 %) little-endian and the printer was in the error state; `0x3B 0x18` reads 6203 (97.7 %) and it was not. | [#35, 4 Sep 2026](https://github.com/Ircama/epson_print_conf/issues/35#issuecomment-5538152419) |
49
+ | 3 | L3251 | Windows | Onur Kesim | standalone tool | The main waste counter is mirrored in three cells. After ten bordered photo pages, `0x30/0x31`, `0x34/0x35` and `0xC0/0xC1` each went 0 → 17 (`0xFC/0xFD` stayed 0). After zeroing the full set, all three stayed at 0 across two power cycles. | [#35, 4 Sep 2026](https://github.com/Ircama/epson_print_conf/issues/35#issuecomment-5538152419) |
50
+ | 4 | L3250 (firmware XF26P8, USB ID 04B8:118A) | Linux | endafk | `epson_usb` from `epson_print_conf` v8.0.0 | Printer at 100 % (`0x30/0x31` = 6346, status error `0x10`). Reset over USB by writing the 14-cell ET-2810 `raw_waste_reset` set (not `0xC0/0xC1`). After a power cycle everything stayed at 0, and `0xC0/0xC1` went to 0 by itself, as on the L3251. The maintainer of `epson_print_conf` replied: "it looks like the reset works as expected." | [#35, 24 Sep 2026](https://github.com/Ircama/epson_print_conf/issues/35#issuecomment-5810314920), [reply](https://github.com/Ircama/epson_print_conf/issues/35#issuecomment-5810422246) |
51
+
52
+ Row 4 ran the library code of v8.0.0. The code in this package is identical to it
53
+ apart from docstrings and comments (the parsed syntax trees of the five changed
54
+ library modules are equal once docstrings are removed; compared on 24 Sep 2026);
55
+ the tests are newer.
56
+
57
+ ## What has not been measured
58
+
59
+ | What | Status |
60
+ |---|---|
61
+ | This release (`epson-usb` 0.1.0) against a real printer | No record of it. Rows 1-3 used the standalone tool; row 4 used the earlier code described above. That this library behaves like the standalone tool is checked by hardware-free tests (`tests/test_fidelity.py`, against a frozen copy of the tool), not by a hardware run. |
62
+ | Printer models other than the two units above | Not measured. One unit of each; the L3251's firmware version was not recorded. |
63
+ | Firmware versions other than XF26P8 (L3250) | Not measured. |
64
+ | macOS | No backend measured. |
65
+ | Windows: the `libusb`, `pyusb` and `raw` backends | Not measured. Only the native `usbprint` route was used (rows 1-3, by the standalone tool). |
66
+ | Linux: which backend row 4 used | Not reported. `libusb`, `pyusb` and `raw` were not measured individually. |
67
+ | The `rw` service command over USB | Not measured. |
68
+ | Persistence beyond the power cycles listed | Not measured (two on the L3251, one on the L3250). |
69
+ | The test suite (no hardware) on platforms other than Linux / Python 3.10 (CI) and Windows / Python 3.14 (maintainer's machine, 24 Sep 2026) | Not run. |
70
+
71
+ The sections below were written for the copy of this package that lives inside
72
+ [`epson_print_conf`](https://github.com/Ircama/epson_print_conf). Where they
73
+ mention `epson_print_conf.py`, `ui.py`, `find_printers.py` or "the repository
74
+ root", they mean that project; those files are not part of this distribution.
75
+ The backends are described as that project describes them: only the rows above
76
+ were measured by the people named in them.
77
+
78
+ ## Platforms and backends
79
+
80
+ The library talks to the device through a *transport*; five are registered, and
81
+ the right one is chosen per platform, so the common case needs no options.
82
+
83
+ | backend | what it uses | available on |
84
+ |---|---|---|
85
+ | `usbprint` | Windows `USBPRINT` device interface, `ctypes` over SetupAPI + kernel32 — **no driver change** (no Zadig, no WinUSB) | Windows (tried first) |
86
+ | `libusb` | `libusb-1.0` through the package's own `ctypes` binding (no PyUSB needed) | Linux, macOS (on Windows: fallback, see below) |
87
+ | `pyusb` | PyUSB, if you prefer it (`pip install pyusb`) | Linux, macOS (on Windows: fallback) |
88
+ | `raw` | a POSIX character device (`/dev/usb/lp0`) | Linux, macOS |
89
+ | `mock` | the in-memory fake printer | everywhere (tests, demos) |
90
+
91
+ **On Windows the native backend is tried first.** `usbprint` talks to the channel
92
+ the installed Epson driver already publishes, so nothing has to be installed and
93
+ the driver is never replaced; that is what was verified on hardware. `libusb` and
94
+ `pyusb` stay available, but come after it in the default order, because both reach
95
+ the printer's vendor-specific interface directly and would therefore need that
96
+ driver swapped for WinUSB (Zadig) — plus a `libusb-1.0.dll` on the machine. Ask
97
+ for one explicitly if you have set that up:
98
+
99
+ ```console
100
+ python epson_print_conf.py -m XP-205 --usb --backend libusb -i # Windows: only if
101
+ python epson_print_conf.py -m XP-205 --usb --backend usbprint -i # the usual choice
102
+ ```
103
+
104
+ On Linux and macOS the order is `libusb` → `pyusb` → `raw`. `mock` is never
105
+ chosen automatically, so a test can never be mistaken for a printer.
106
+
107
+ ```console
108
+ python -c "from epson_usb.backends import describe_environment; print(describe_environment())"
109
+ ```
110
+
111
+ ## Quick start
112
+
113
+ `import epson_usb` works from the repository root,
114
+ because the package sits next to the scripts.
115
+
116
+ ### From the command line
117
+
118
+ `--usb` switches the transport; `-a/--address` is then not required (and is not
119
+ used). `--backend` and `--device` imply `--usb`:
120
+
121
+ ```console
122
+ python epson_print_conf.py -m XP-205 --usb -i # read the printer
123
+ python epson_print_conf.py -m XP-205 --usb --backend libusb -i # force a backend
124
+ python epson_print_conf.py -m XP-205 --usb --device 1:4 -i # force a device
125
+ python epson_print_conf.py -m XP-205 --backend mock -i # no hardware at all
126
+ python epson_print_conf.py -m XP-205 -a 192.168.1.87 -i # unchanged: SNMP
127
+ ```
128
+
129
+ On Windows `--backend` is rarely needed: `usbprint` is tried first, and
130
+ `libusb`/`pyusb` are the fallbacks. On Linux and macOS it chooses between
131
+ libusb, PyUSB and a character device.
132
+
133
+ ### From the GUI
134
+
135
+ `ui.py` has a **Printer Connection** box with two choices, `TCP/IP` and `USB`
136
+ (in the same row as the model and the address). Selecting USB replaces the
137
+ address field with a **`USB Port`** list: the devices found on this machine
138
+ *that can actually be opened*, refreshed by the ⟳ button next to it and by
139
+ `Detect Printers`. That filter is the point of the list — the enumeration
140
+ reports candidate paths, and on a Windows XP-205 three of the five candidates
141
+ are interface paths that `CreateFile` cannot open at all. Leave the first entry,
142
+ `Auto: first device found`, to let the library choose the device (its default
143
+ backend order); choose one to pin it, which is what tells two attached printers
144
+ apart or picks another interface of a multi-interface device. The choice is
145
+ handed to the transport as `backend`/`device`, the same two options the command
146
+ line fills from `--backend`/`--device`.
147
+
148
+ Selecting USB also disables the features that need the network, with a tooltip
149
+ explaining why each one is off: the printer web interface, the print tests and
150
+ nozzle cleaning (they print through LPR), and the configuration detection (it
151
+ reads SNMP-only MIB values). Everything else — status, EEPROM read/write, waste
152
+ resets, serial/MAC/TI/power-off parameters, access-key detection — works over
153
+ USB.
154
+
155
+ `Detect Printers` becomes a USB device listing in this mode (the backends
156
+ available on the machine, and every candidate the library enumerated), and the
157
+ transport can also be preselected from the command line:
158
+
159
+ ```console
160
+ python ui.py --usb -m XP-205 # or: python ui.py --usb
161
+ ```
162
+
163
+ ### From any other tool: the environment variable
164
+
165
+ `find_printers.py`, `parse_devices.py` and anything else that does
166
+ `from epson_print_conf import EpsonPrinter` follows automatically when
167
+ `EPSON_USB` is set, because the hook at the end of `epson_print_conf.py`
168
+ replaces the class at import time:
169
+
170
+ ```console
171
+ EPSON_USB=1 python3 find_printers.py
172
+ ```
173
+
174
+ ## Using the library directly
175
+
176
+ The library can also be used on its own, without `epson_print_conf`.
177
+
178
+ ### Opening a printer
179
+
180
+ Read and write keys are per-model facts, and the library carries none: it asks
181
+ for them. A host program does not even need them, because its OIDs already
182
+ contain the frames it built from its own configuration.
183
+
184
+ ```python
185
+ from epson_usb import EpsonUsbPrinter
186
+
187
+ with EpsonUsbPrinter(read_key=(0x11, 0x22), # your model's keys, not ours
188
+ write_key=b"example8") as printer:
189
+ print(printer.describe()) # e.g. "usbprint ... (D4 revision 0x10)"
190
+ ```
191
+
192
+ To take the keys from the parameters `epson_print_conf` already has:
193
+
194
+ ```python
195
+ from epson_print_conf import EpsonPrinter
196
+
197
+ parm = EpsonPrinter(model="XP-205").parm
198
+ printer = EpsonUsbPrinter(read_key=parm["read_key"], write_key=parm["write_key"])
199
+ ```
200
+
201
+ Useful constructor arguments: `device=` (a path or `bus:address`), `backend=`
202
+ (`usbprint`, `libusb`, `pyusb`, `raw`, `mock`), `transport=` (an already-built
203
+ transport, which is how the tests inject the fake printer), `dry_run=True`,
204
+ `timeouts=Timeouts.scaled(...)`, and anything the chosen backend needs.
205
+
206
+ ### Reading and writing EEPROM
207
+
208
+ ```python
209
+ printer.read_eeprom(0x30) # '3B' (two hex digits, like epson_print_conf)
210
+ printer.read_cell(0x30) # 59 (an int)
211
+ printer.read_serial(range(0x644, 0x64E)) # printable characters only
212
+ printer.dump_eeprom(0x00, 0xFF) # {address: value} for a whole bank
213
+ printer.write_eeprom(0x30, 0x00) # True only when the printer answered ':OK;'
214
+ printer.write_cells([(0x30, 0), (0x31, 0)]) # a set, each write read back
215
+ ```
216
+
217
+ Safety rules, unchanged from the tool this grew out of: nothing here writes
218
+ unless a write method is called; `dry_run=True` turns every write into a read
219
+ and logs what it would have done; a write that is not confirmed by `:OK;` is
220
+ reported as a failure, never as success.
221
+
222
+ ### Backups
223
+
224
+ ```python
225
+ path = printer.save_backup() # bank 0 -> JSON
226
+ report = printer.restore_backup(path, addresses=[0x30, 0x31, 0x1C])
227
+ print(report) # e.g. "3 written, 0 failed, 0 missing from the backup"
228
+ ```
229
+
230
+ The file format (`{"time": ..., "bank0": {"00": 12, ..., "FF": 94}}`) is the
231
+ historical one, so backups stay interchangeable with the source project's tool.
232
+ `addresses` is the caller's set: which cells a write path can touch is model
233
+ knowledge. A safety backup is taken before restoring unless told otherwise.
234
+
235
+ ### Status, serial, identification
236
+
237
+ ```python
238
+ printer.get_printer_status() # the @BDC ST2 block, decoded
239
+ printer.get_serial_number() # epson_print_conf's format, '?' for unreadable cells
240
+ printer.get_firmware_version() # 'AB11I5 11 May 2018'
241
+ printer.get_device_identification() # {Manufacturer: [...], Model: [...], ...}
242
+ printer.get_cartridges() # ['18XL', ...]
243
+ ```
244
+
245
+ `get_printer_status()` delegates to `epson_print_conf.status_parser` when that
246
+ package is importable, so USB users get the full decode rather than a second,
247
+ drifting copy of it. Without it, `epson_usb.status.parse_st2()` extracts a
248
+ documented subset.
249
+
250
+ ### The `rw` service command
251
+
252
+ ```python
253
+ printer.service_rw("SERIAL") # temporary waste reset, raw reply
254
+ ```
255
+
256
+ `rw` needs only the serial number, which is why it still works on firmware that
257
+ locks the EEPROM. Which serial string to hash is the caller's decision: the
258
+ EEPROM serial and the USB descriptor serial are not the same string. On Windows,
259
+ `epson_usb.backends.usbprint_win.usb_serial_candidates()` lists the forms the
260
+ USB stack reports.
261
+
262
+ ### The SNMP-shaped door (for host programs)
263
+
264
+ A host that already speaks the SNMP dialect can keep building OIDs and hand them
265
+ to the USB transport, which is exactly how the `epson_print_conf` integration
266
+ works:
267
+
268
+ ```python
269
+ oid = printer.eeprom_oid_read_address(0x30) # the OID SNMP would have used
270
+ printer.fetch_oid_values(oid) # [(OctetString, b'...')] over USB
271
+ ```
272
+
273
+ `epson_usb.epson_ctrl.snmp_oid()` and `parse_snmp_oid()` are the two directions
274
+ of that translation, and `is_epson_ctrl_oid()` tells an EPSON-CTRL OID apart
275
+ from a plain MIB one. Plain MIB OIDs cannot travel over a cable: they are
276
+ answered `(None, False)` and logged.
277
+
278
+ ### Bridging an existing class
279
+
280
+ ```python
281
+ from epson_usb.compat import patch_epson_print_conf, usb_printer
282
+
283
+ patch_epson_print_conf() # epson_print_conf.EpsonPrinter is now USB-capable
284
+ UsbPrinter = usb_printer() # or build the subclass without touching upstream
285
+ printer = UsbPrinter(model="XP-205") # no hostname: the device is on the USB bus
286
+ ```
287
+
288
+ `epson_print_conf.enable_usb_transport()` (used by the CLI and by the GUI) is a
289
+ thin wrapper around `patch_epson_print_conf()`, and the original class is kept as
290
+ `epson_print_conf.NetworkEpsonPrinter`.
291
+
292
+ A note on the reply check, because it cost a session. `epson_print_conf`'s own
293
+ `invalid_response()` required the leading zero byte that some firmwares pad
294
+ `@BDC` blocks with. A real XP-205 sends none -- it answers
295
+ `@BDC PS\r\nEE:01660F;\x0C` -- so every EEPROM value came back `None` on a printer
296
+ that had answered correctly, over SNMP *and* over USB, since the bridge
297
+ deliberately does not override that method. The library's rule was always the
298
+ tolerant one: `EpsonUsbPrinter.read_eeprom()` answered `0F` where the host
299
+ printed `None`. That firmware also answers *bare* blocks, with no `@BDC PS`
300
+ header (`b'ii:NA;\x0C'` for an empty ink slot, `b'||:41:NA;\x0C'` for a read it
301
+ refuses), so an element is now looked for anywhere in the reply — and the element
302
+ itself may contain colons, because a write is confirmed with the write opcode in
303
+ the middle: `b'||:42:OK;\x0C'`. A `:NA;` reply
304
+ is an *answer* -- the printer saying no (wrong key, locked EEPROM) -- not a
305
+ malformed one, so a wrong access key is reported as `Invalid read key` at info
306
+ level instead of one error per attempt, which is what makes the 65536 attempts of
307
+ `--detect-key` readable.
308
+
309
+ ### Errors
310
+
311
+ Everything raised derives from `epson_usb.EpsonUsbError`, with three names that
312
+ ask for different action: `DeviceNotFoundError` (nothing on the USB tree looks
313
+ like the printer, or the backend is not available here), `DeviceBusyError`
314
+ (something else holds it), `ProtocolError` (we reached it, the conversation did
315
+ not make sense). Transport errors also derive from `OSError`. The host program
316
+ converts them to its own `TimeoutError`, so a GUI or CLI user learns that the
317
+ printer is missing instead of seeing every value silently become `None`.
318
+
319
+ ## Choosing a backend
320
+
321
+ ```console
322
+ --backend usbprint | libusb | pyusb | raw | mock
323
+ --device 1:4 # bus:address (libusb/pyusb)
324
+ --device /dev/usb/lp0 # character device (raw)
325
+ --device '\\?\usb#vid_04b8&pid_...' # Windows interface path (usbprint)
326
+ ```
327
+
328
+ Environment variables, for the cases where a flag is inconvenient:
329
+
330
+ | variable | effect |
331
+ |---|---|
332
+ | `EPSON_USB` | any value: the host tools use the USB transport |
333
+ | `EPSON_PRINT_CONF_PATH` | directory holding `epson_print_conf.py`, when it is not installed |
334
+ | `EPSON_USB_LIBUSB` | path of the `libusb-1.0` shared library, if discovery fails (needed on Windows only for the `libusb`/`pyusb` fallbacks) |
335
+ | `EPSON_USB_RAW_DEVICE` | extra character device for the `raw` backend |
336
+ | `EPSON_INSTANCE_ID` | Windows device instance id, for the `usbprint` backend |
337
+ | `EPSON_USB_REQUIRE_UPSTREAM` | tests only: `1` turns the skipped host-program test classes into errors (see *Tests*) |
338
+
339
+ Permissions and packages:
340
+
341
+ * **Windows** — the `usbprint` backend needs nothing at all: SetupAPI and
342
+ kernel32 ship with the system, and the printer keeps working normally without
343
+ touching its driver. The `libusb`/`pyusb` fallbacks, if you ever need them,
344
+ would require replacing that driver with WinUSB (Zadig) and a
345
+ `libusb-1.0.dll` on the machine.
346
+ * **Linux** — install `libusb-1.0-0` (`apt`) or `libusb1` (`dnf`), and let your
347
+ user write the device:
348
+ `SUBSYSTEM=="usb", ATTR{idVendor}=="04b8", MODE="0666"` in a udev rule. The
349
+ kernel's `usblp` driver is detached automatically and re-attached on close.
350
+ * **macOS** — `brew install libusb`; no device node is involved.
351
+
352
+ When none of the three is available there, selecting USB would only fail on the
353
+ first command: `epson_print_conf.usb_transport_warning()` answers with what is
354
+ missing, and both the GUI (a line in the status box as soon as USB is chosen) and
355
+ the command line (`--usb`) ask it. On Windows it always answers `None`, because
356
+ the native backend needs nothing installed.
357
+
358
+ A USB session that is left behind (an interrupted command, a device the system
359
+ re-enumerated) answers nothing instead of failing: the key scan notices, closes
360
+ it so the next command opens a fresh one, and gives up with a message when
361
+ silence continues — a scan of 65536 attempts is not the place to discover that
362
+ the cable is not connected.
363
+
364
+ ## Tests
365
+
366
+ Everything runs without hardware:
367
+
368
+ ```console
369
+ python -m unittest discover -s epson_usb/tests -t .
370
+ python epson_usb/tests/mutant_run.py # proves the suite would catch a porting mistake
371
+ ```
372
+
373
+ Seven test classes need the host program, `epson_print_conf`. Without it they are
374
+ skipped, and `unittest` summarises that as `Ran 30 tests ... OK (skipped=7)` even
375
+ though 36 of the 66 tests never ran (it counts one skip per class and none of the
376
+ tests inside). Set `EPSON_USB_REQUIRE_UPSTREAM=1` and that skip becomes an error, so
377
+ a green run always means all 66 ran. CI does this; because `epson_print_conf` is not
378
+ on PyPI, CI checks it out at a pinned commit and puts it on `PYTHONPATH`.
379
+
380
+ They all rest on the `mock` backend: an in-memory printer that consumes the same
381
+ bytes a real one receives and produces the same framing back, so the protocol
382
+ code under test never learns that it is talking to a fake.
383
+
384
+ | file | what it pins |
385
+ | --- | --- |
386
+ | `tests/test_epson_usb.py` | the package's own behaviour: sessions, keys, safety gates, the EEPROM convention, backups, the upstream bridge, the OID bridge (`OidBridgeTests`) and end-to-end parity between the USB and SNMP envelopes (`TransportParityTests`). |
387
+ | `tests/test_fidelity.py` | that this is a *port*: it replays the historical implementation frozen in `tests/referans/` against the same fake printer and compares key agreement, handshake packets, frame builders and golden hex. |
388
+ | `tests/mutant_run.py` | that the suite is not vacuous: it flips the counter's byte order and the write frame's byte order, expects the suite to go red both times, and restores the files byte for byte. |
389
+
390
+ Both transports are driven through the same printer in `TransportParityTests`:
391
+ the question is never "does USB work?" but "does USB ask the printer exactly
392
+ what the network transport asked, and get the same answer?".
393
+
394
+ ## What does not work over USB
395
+
396
+ * **Plain MIB queries** (`get_snmp_info()`, the model/MAC/power-off values the
397
+ GUI shows in a status report) — there is no SNMP agent on a cable. They answer
398
+ "unavailable" and are logged; the corresponding GUI features are disabled in
399
+ USB mode.
400
+ * **Printing** (`print_check_nozzles()`, `print_clean_nozzles()`,
401
+ `print_test_color_pattern()`) — the host sends those through LPR to a network
402
+ address and raises `NotImplementedError` over USB.
403
+
404
+ **## onur-kesim/epson-l3251-usb-reset**
405
+
406
+ The implementation in the `epson_usb` directory currently reuses and extends code from [`onur-kesim/epson-l3251-usb-reset`](https://github.com/onur-kesim/epson-l3251-usb-reset).
407
+
408
+ Ideally, the `epson_usb` directory could serve as a preliminary implementation of a possible future backend layer for `epson-l3251-usb-reset`.
409
+
410
+ The `epson_usb` implementation is not intended to remain permanently embedded in `epson_print_conf`. A possible future approach would be to rely on an evolution of `onur-kesim/epson-l3251-usb-reset`, with its USB functionality exposed as a reusable backend/library.
411
+
412
+ We are deeply grateful to `onur-kesim/epson-l3251-usb-reset` for providing the USB D4 protocol implementation and the native Windows interface on which this work builds.