Mam skaner Epson WorkForce DS-7500 z przystawką sieciową (Epson Network Interface Unit, B12B808451). Fajny sprzęt: szyba, ADF, duplex, a na panelu jest przycisk, po którym skaner sam wypycha skan na wybrany komputer. Wybierasz komputer z listy, wybierasz zadanie („Skanuj do PDF"), Start — i plik ląduje na dysku. Piękne.
No właśnie — „na wybrany komputer". Haczyk jest taki, że każdy komputer z tej listy musi mieć zainstalowany soft Epsona (Epson Scan 2 albo Document Capture Pro), czyli w praktyce: Windows albo macOS, zalogowany użytkownik i maszyna, która akurat jest włączona. A ja od dłuższego czasu wszystkie papiery wrzucam do paperless-ngx na domowym serwerze. Marzenie było proste: naciskam Start na skanerze, PDF pojawia się w katalogu consume, paperless robi resztę. Bez żadnego Windowsa po drodze.
Problem: żeby serwer pojawił się na panelu skanera, musi udawać komputer z softem Epsona. A jak Epson rozmawia ze skanerem — tego nie wiedział nikt. Serio, szukałem: zero dokumentacji, zero implementacji, nawet SANE zna tylko kawałek tej układanki. No to klasyka: Wireshark, stary laptop z Epson Scan 2 jako królik doświadczalny i 17 MB pcapa ze scenariusza „włącz skaner → wybierz komputer → zeskanuj dwie strony → zakończ".
Warstwa pierwsza: skąd skaner wie, że istnieję
Pierwsze zaskoczenie było miłe. Po włączeniu skaner nie robi nic — dosłownie, jeden ARP announcement i cisza. Dopiero kiedy na panelu wchodzi się w menu wyboru komputera, skaner strzela multicastem. I to nie żadnym własnościowym wynalazkiem, tylko SLPv2 — najnormalniejszym Service Location Protocol z RFC 2608. Tyle że na porcie 2968 zamiast standardowego 427, bo czemu nie ;-).
Skaner pyta (AttrRqst) o usługę service:NetScanMonitor-agent w scope PID 0145 — to, nawiasem mówiąc, USB Product ID tego modelu. Klient odpowiada (AttrRply) listą atrybutów:
(Ver=3.0),(ClientName=SERWER),(IPAddress=172.27.72.10),(EventPort=2968),(Group=0)
I tyle — ClientName to nazwa, która pojawia się na panelu. Oryginalny klient wysyła odpowiedź trzy razy pod rząd (UDP, retransmisja „na piechotę"), więc mój responder też tak robi.
Napisanie tego zajęło wieczór. Debugowanie, czemu skaner ignoruje moje odpowiedzi — drugi wieczór. Nagłówek SLP ma 3-bajtowe pole długości pakietu i policzyłem je o 3 za dużo (raz w nagłówku, raz doliczając nagłówek — double-count). Skaner nie zgłasza błędu, nie odrzuca pakietu, po prostu nazwa się nie pojawia i patrzysz w pustą listę jak sroka w gnat. Porównanie hex dump w hex dump z oryginałem znalazło winowajcę.
Warstwa druga: HTTP jak z 2004 roku
Po wybraniu komputera na panelu skaner otwiera połączenie TCP na port z EventPort i wysyła... SOAP po HTTP/1.0. Namespace http://schemas.epson.net/EpsonNet/Scan/2004/pushscan zdradza rocznik tego rozwiązania ;-).
Zdarzenia są trzy: JobList (wybrano nasz komputer), PushScan (naciśnięto Start; w body siedzi JobNumberIn — indeks wybranego zadania) i ScanEnd (koniec sesji na panelu). Na każde trzeba odpowiedzieć dwustoma z <StatusOut>OK</StatusOut>.
Przy odtwarzaniu odpowiedzi oryginalnego klienta wyszły dwa smaczki, które zostawiłem 1:1, bo z takim sprzętem nigdy nie wiadomo:
- nagłówki HTTP odpowiedzi mają spację przed dwukropkiem:
Server : Epson Net Scan Monitor/2.0. HTTP/1.0 to toleruje, skaner — być może tego wymaga, nie sprawdzałem na odwrót; - namespace w odpowiedzi to
http://schema.epson.net/...— bez „s" w „schemas". Literówka sprzed dwudziestu lat, zabetonowana w firmware. Odtwarzam wiernie, razem z literówką ;-).
Niespodzianka: menu na panelu przychodzi... z komputera
To było moje ulubione odkrycie. Zawsze myślałem, że lista zadań („Skanuj do PDF" itd.) siedzi w skanerze. Otóż nie: po zdarzeniu JobList klient łączy się ze skanerem i wgrywa mu listę zadań komendą JOBW, której nie ma w żadnym publicznym kodzie. To, co widzisz w menu panelu, przed chwilą przyszło z komputera, którego dotyczy.
Dekodowanie payloadu JOBW to była osobna przygoda. Nazwy zadań są w UTF-16BE — big endian, choć wszystkie pola liczbowe obok są little endian. Zanim na to wpadłem, czytałem nazwy jako UTF-16LE i wszystko wyglądało na przesunięte o bajt; w notatkach miałem przez pewien czas wpis o „niewyjaśnionym off-by-one" ;-). Do tego każde zadanie niesie 10-bajtowy blob ustawień (źródło, dpi, rozmiar, format — panel wyświetla te parametry przy zadaniu).
A, i jeszcze jedno: jak wyślesz zepsuty JOBW, skaner nie zgłasza błędu, tylko... pyta na panelu o hasło. Żadnego hasła nigdzie nie ma. Straciłem na ten fałszywy trop pół sesji, kombinując, gdzie w NIU ustawia się hasła. Nigdzie. To po prostu uniwersalna reakcja na „coś mi się w tym payloadzie nie podoba".
Plan A umiera w trzy bajty
Na pobieranie samego obrazu miałem plan-minimum: warstwa transferu to ESC/I-2, który nominalnie implementuje backend epsonds w SANE. Miałem tylko odpowiadać na push i odpalać scanimage.
Guzik. Skaner za NIU wita się na porcie 1865 trzema bajtami, a epsonds (i epson2 też) uparcie czeka na pięć — i po timeoucie oznajmia, że skanera nie ma. Utrzymywanie forka SANE dla jednego read() wydało mi się smutniejsze niż napisanie własnego klienta. Zwłaszcza że SANE i tak nie zna JOBW ani całej strony push — więc poszedłem na całość: własny klient ESC/I-2, asyncio, zero zależności poza img2pdf.
ESC/I-2, czyli kolekcjonowanie INVD
Sesja skanowania to sekwencja komend w opakowaniu „IS" (12-bajtowy nagłówek framingu, ten akurat podpatrzony w źródłach SANE): INFO, CAPA, PARA (parametry), TRDT (start transferu), pętla IMG (strony jako JPEG), FIN. Brzmi prosto. W praktyce skaner na każde odstępstwo odpowiada lakonicznym INVD i domyślaj się człowieku, o co mu chodzi. Moja prywatna kolekcja:
- tokeny komend mają dokładnie 4 znaki:
INFOx0000000bez spacji, aleFIN x0000000ze spacją, bo FIN ma trzy litery. Jedna spacja za dużo → token ma 13 bajtów → INVD; - macierz korekcji kolorów to 9 bajtów danych, ale na drucie 12 — trzy bajty zerowego paddingu. Bez nich wszystkie kolejne pola się przesuwają → INVD;
- tablice gamma są trzy po 256 bajtów i wcale nie identyczne (RED == BLU, ale GRN jest inna — takie tam fabryczne kalibracje);
- PARA to dwa pakiety i dwie odpowiedzi: deklaracja długości (pusta odpowiedź!) i dopiero potem parametry (odpowiedź
parOK). Nie odczytasz pierwszej odpowiedzi → strumień się rozjeżdża i debugujesz duchy.
Matryca, czyli 13 pcapów i dwa skrypty
Największy blob w PARA to parametry skanu: rozdzielczość, okno, kalibracja. Chciałem, żeby zadania na panelu mogły mieć różne dpi/rozmiary/tryby, więc musiałem zrozumieć, co się w tym blobie zmienia, a co jest stałe. Metoda siłowa: nagrałem oficjalnym klientem matrycę 13 capture'ów — po jednym na kombinację źródła, dpi, rozmiaru i trybu koloru — i napisałem dwa narzędzia.
Pierwsze wyciąga bloby PARA z pcapa (tshark składa strumień TCP, skrypt parsuje framing IS). Drugie, para_diff.py, parsuje blob na pola i drukuje tabelkę porównawczą. Gramatyka tokenów okazała się na tyle regularna, że parser mieści się w 30 linijkach:
def parse(blob: bytes) -> list[tuple[str, str]]:
fields = []
off = 0
while off < len(blob):
assert blob[off:off + 1] == b"#", f"expected '#' at {off}"
tag = blob[off + 1:off + 4].decode("ascii")
off += 4
values = []
while off < len(blob) and blob[off:off + 1] != b"#":
c = blob[off:off + 1]
if c == b"i": # 7-cyfrowy int
values.append(str(int(blob[off + 1:off + 8])))
off += 8
elif c == b"d": # 3-cyfrowy int
values.append("d" + blob[off + 1:off + 4].decode())
off += 4
elif c == b"h": # payload o podanej długości
n = int(blob[off + 1:off + 4], 16)
off += 4
padded = (n + 3) // 4 * 4 # pad do 4 bajtów (patrz: CMX!)
data = blob[off:off + padded]
off += padded
values.append(f"[{n}B {hashlib.sha1(data).hexdigest()[:8]}]")
else: # gołe 4-znakowe słowo
values.append(blob[off:off + 4].decode("ascii"))
off += 4
fields.append((tag, " ".join(values)))
return fields
Trik z hashem zamiast zawartości robi całą robotę: 256-bajtowa tablica gamma to w tabelce [256B a1b2c3d4] i od razu widać, w których capture'ach jest taka sama, bez porównywania bajtów. Wyjście wygląda tak:
== #RSM
fb_600_a4_kolor.para01.bin 600
fb_300_a4_kolor.para01.bin 300
adf_300_auto_kolor.para01.bin 300
== #GMT
fb_600_a4_kolor.para01.bin RED [256B 07c3aa11]
fb_300_a4_kolor.para01.bin RED [256B 07c3aa11]
adf_300_auto_kolor.para01.bin RED [256B e49b02f7]
== #ACQ
fb_600_a4_kolor.para01.bin 0 0 4961 7016
fb_300_a4_kolor.para01.bin 0 0 2480 3508
adf_300_auto_kolor.para01.bin 0 0 2550 4200
I z takiej tabelki wnioski spadły same, w godzinę zamiast tygodnia: tablice gamma i macierz kolorów zależą wyłącznie od źródła (szyba ma swoją kalibrację, ADF swoją), nie od dpi ani rozmiaru. Okno skanowania #ACQ to po prostu rozmiar dokumentu razy dpi w pikselach. ADF centruje mniejsze dokumenty w poziomie, szyba kadruje od lewego górnego rogu. Czyli: mogę syntetyzować blob PARA dla dowolnej kombinacji parametrów, zamiast odtwarzać nagrane bajty. Builder w kodzie ma asserta, który przy każdym imporcie sprawdza, że dla referencyjnych parametrów produkuje blob bajt w bajt zgodny z nagranym — najlepsze 5 linijek w całym projekcie, złapały mi regresję dwa razy.
Wisienka: RE bez Wiresharka
Na koniec został drobiazg: kody rozmiarów dokumentu w blobie JOBW (żeby panel wyświetlał przy zadaniu „B5" czy „Legal"). Znałem trzy z capture'ów i szykowałem się na kolejne nagrywanie, aż zauważyłem, że bajty blobu to dosłowne kopie pól z plików konfiguracyjnych Document Capture Pro. A DCP ma eksport zadań do pliku .jxz — który okazał się zwykłym zipem z plikami INI.
Czyli: wyklikałem w DCP siedemnaście zadań różniących się tylko rozmiarem i typem pliku, wyeksportowałem, rozpakowałem i przeczytałem kody wprost z INI. Kompletna tabela kodów bez jednego pakietu w Wiresharku. Czasem najlepszy sniffer to unzip ;-).
Finał
Efekt nazywa się epson-pushscan i wisi na GitHubie (MIT, właśnie wyszło v0.9). Jeden kontener Docker, który udaje komputer z softem Epsona: odpowiada na discovery, wgrywa zadania na panel, odbiera zdarzenia i sam wyciąga obraz po ESC/I-2. Na panelu definiuję dowolne zadania — „Faktury" to ADF z duplexem w 300 dpi, „Zdjęcie" to szyba w A6 — a pliki lądują prosto w consume paperless-ngx, z właściwym uid/gid. Szyba, ADF z automatycznym podawaniem, duplex, sesje wielostronicowe, 200–600 dpi, formaty od PDF przez PNG po JPEG 2000 — wszystko przetestowane na żywym sprzęcie.
W repo leży też PROTOCOL.md — z tego co wiem, pierwszy publiczny opis tego protokołu, bajt po bajcie, razem ze wszystkimi pułapkami z tego wpisu. Jakby ktoś miał podobny sprzęt Epsona z NIU: częstujcie się. A jak wam zadziała na innym modelu — dajcie znać, chętnie poszerzę listę przetestowanych.
Skaner ma kilkanaście lat, protokół pamięta czasy SOAP-a, a mimo to całość okazała się do rozgryzienia w kilka wieczorów. Stary sprzęt ma tę zaletę, że nikt w nim jeszcze nie wymyślił szyfrowanego łączenia z chmurą ;-).